refactor(library): unify track cards

This commit is contained in:
zarzet
2026-07-29 17:10:57 +07:00
parent dbdbf0ca41
commit 40034d184f
6 changed files with 842 additions and 867 deletions
+12 -53
View File
@@ -2,13 +2,18 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/widgets/error_card.dart';
import 'package:spotiflac_android/widgets/track_card.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:share_plus/share_plus.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/re_enrich_release_policy.dart';
@@ -227,7 +232,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
OverlayEntry? _selectionOverlayEntry;
final SelectionOverlayController _selectionOverlay =
SelectionOverlayController();
List<UnifiedLibraryItem> _selectionOverlayItems = const [];
double _selectionOverlayBottomPadding = 0;
@@ -237,7 +243,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
bool _isPlaylistSelectionMode = false;
final Set<String> _selectedPlaylistIds = {};
OverlayEntry? _playlistSelectionOverlayEntry;
final SelectionOverlayController _playlistSelectionOverlay =
SelectionOverlayController();
List<UserPlaylistCollection> _playlistSelectionOverlayItems = const [];
double _playlistSelectionOverlayBottomPadding = 0;
@@ -751,9 +758,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerLow,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => StatefulBuilder(
builder: (context, setSheetState) {
return SafeArea(
@@ -770,17 +774,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
const AppSheetHandle(),
Row(
children: [
@@ -1272,7 +1266,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
settingsProvider.select((s) => s.libraryQualityLabelMode),
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
final countsRequest = _QueueLibraryCountsRequest(
searchQuery: _searchQuery,
filterSource: _filterSource,
@@ -1424,41 +1417,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
).copyWith(overscroll: false),
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(
left: 24,
bottom: 16,
),
title: Text(
context.l10n.navLibrary,
style: TextStyle(
fontSize: 20 + (14 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
AppSliverHeader.tabRoot(title: context.l10n.navLibrary),
if (shouldShowLibraryControls || hasQueueItems)
SliverToBoxAdapter(
+133 -232
View File
@@ -6,7 +6,7 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
DownloadItem item,
ColorScheme colorScheme,
) {
final radius = BorderRadius.circular(8);
final radius = context.tokens.borderRadiusThumb;
final isDownloading = item.status == DownloadStatus.downloading;
final isFinalizing = item.status == DownloadStatus.finalizing;
final isQueued = item.status == DownloadStatus.queued;
@@ -19,13 +19,7 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
borderRadius: radius,
fadeInDuration: const Duration(milliseconds: 180),
)
: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: radius,
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
);
: const TrackCoverPlaceholder();
final onTap = isFailed || item.status == DownloadStatus.skipped
? () => _showDownloadErrorDialog(context, item)
@@ -34,111 +28,80 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
return SmoothedProgressScope(
value: item.progress,
animate: _shouldAnimateDownloadProgress(context, item),
child: GestureDetector(
child: TrackGridCard(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(borderRadius: radius, child: cover),
if (isDownloading || isFinalizing || isQueued)
ClipRRect(
borderRadius: radius,
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.45),
),
),
if (isDownloading || isFinalizing || isQueued)
Center(
child: isDownloading && progress > 0
? SmoothedProgressBuilder(
builder: (context, visualProgress, child) =>
Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(
value: visualProgress,
strokeWidth: 3,
color: Colors.white,
backgroundColor: Colors.white
.withValues(alpha: 0.25),
),
),
const SizedBox(height: 6),
Text(
'${(visualProgress * 100).round()}%',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 14,
),
),
],
),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
backgroundColor: Colors.white.withValues(
alpha: 0.25,
),
),
),
],
),
),
if (isFailed)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.error_outline,
color: colorScheme.error,
size: 14,
),
),
),
],
),
),
const SizedBox(height: 6),
Text(
item.track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
),
Text(
item.track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
semanticLabel: context.l10n.a11yTrackByArtist(
item.track.name,
item.track.artistName,
),
cover: cover,
overlays: [
if (isDownloading || isFinalizing || isQueued)
ClipRRect(
borderRadius: radius,
child: ColoredBox(color: Colors.black.withValues(alpha: 0.45)),
),
if (isDownloading || isFinalizing || isQueued)
Center(
child: isDownloading && progress > 0
? SmoothedProgressBuilder(
builder: (context, visualProgress, child) => Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(
value: visualProgress,
strokeWidth: 3,
color: Colors.white,
backgroundColor: Colors.white.withValues(
alpha: 0.25,
),
),
),
const SizedBox(height: 6),
Text(
'${(visualProgress * 100).round()}%',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 14,
),
),
],
),
)
: SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
backgroundColor: Colors.white.withValues(alpha: 0.25),
),
),
),
if (isFailed)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.error_outline,
color: colorScheme.error,
size: 14,
),
),
),
],
title: item.track.name,
subtitle: Text(item.track.artistName),
),
);
}
@@ -163,74 +126,32 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
? _buildUnifiedCoverImage(unifiedItem, colorScheme)
: track.coverUrl != null
? CachedCoverImage(imageUrl: track.coverUrl!, borderRadius: radius)
: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: radius,
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
);
: const TrackCoverPlaceholder();
final heroTag = historyItem != null
? 'cover_lib_dl_${historyItem.id}'
: 'cover_${item.id}';
final trackName = historyItem?.trackName ?? track.name;
final artistName = historyItem?.artistName ?? track.artistName;
return Semantics(
button: true,
label: context.l10n.a11yTrackByArtist(trackName, artistName),
child: GestureDetector(
onTap: () => historyItem != null
? _navigateToHistoryMetadataScreen(
historyItem,
navigationItems: navigationItems,
navigationIndex: navigationIndex,
)
: _navigateToMetadataScreen(item),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
Hero(
tag: heroTag,
child: ClipRRect(borderRadius: radius, child: cover),
),
if (quality != null && quality.isNotEmpty)
Positioned(
left: 4,
top: 4,
child: _buildLibraryQualityBadge(
context,
colorScheme,
quality,
),
),
],
),
),
const SizedBox(height: 6),
Text(
trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
),
Text(
artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
return TrackGridCard(
semanticLabel: context.l10n.a11yTrackByArtist(trackName, artistName),
onTap: () => historyItem != null
? _navigateToHistoryMetadataScreen(
historyItem,
navigationItems: navigationItems,
navigationIndex: navigationIndex,
)
: _navigateToMetadataScreen(item),
cover: Hero(tag: heroTag, child: cover),
overlays: [
if (quality != null && quality.isNotEmpty)
Positioned(
left: 4,
top: 4,
child: _buildLibraryQualityBadge(context, colorScheme, quality),
),
],
title: trackName,
subtitle: Text(artistName),
);
}
@@ -274,62 +195,41 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
: 'cover_${item.id}';
final trackName = historyItem?.trackName ?? track.name;
final artistName = historyItem?.artistName ?? track.artistName;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => historyItem != null
? _navigateToHistoryMetadataScreen(
historyItem,
navigationItems: navigationItems,
navigationIndex: navigationIndex,
)
: _navigateToMetadataScreen(item),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Hero(tag: heroTag, child: cover),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (quality != null && quality.isNotEmpty) ...[
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
child: _buildLibraryQualityBadge(
context,
colorScheme,
quality,
listStyle: true,
),
),
],
],
),
),
],
return TrackCard(
onTap: () => historyItem != null
? _navigateToHistoryMetadataScreen(
historyItem,
navigationItems: navigationItems,
navigationIndex: navigationIndex,
)
: _navigateToMetadataScreen(item),
leading: Hero(tag: heroTag, child: cover),
title: trackName,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
if (quality != null && quality.isNotEmpty) ...[
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
child: _buildLibraryQualityBadge(
context,
colorScheme,
quality,
listStyle: true,
),
),
],
],
),
);
}
@@ -340,16 +240,17 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
String quality, {
bool listStyle = false,
}) {
final tokens = context.tokens;
final isHighlightedQuality = shouldHighlightAudioQualityBadge(quality);
return Container(
padding: EdgeInsets.symmetric(horizontal: listStyle ? 6 : 4, vertical: 2),
padding: tokens.badgePadding,
decoration: BoxDecoration(
color: isHighlightedQuality
? listStyle
? colorScheme.primaryContainer
: colorScheme.primary
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
borderRadius: tokens.borderRadiusBadge,
),
child: Text(
listStyle ? quality : _getQualityBadgeText(quality),
@@ -359,7 +260,7 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
? colorScheme.onPrimaryContainer
: colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
fontSize: listStyle ? 10 : 9,
fontSize: tokens.badgeFontSize,
fontWeight: listStyle ? FontWeight.w500 : FontWeight.w600,
),
),
+306 -424
View File
@@ -188,135 +188,99 @@ extension _QueueTabItemWidgets on _QueueTabState {
),
child: DownloadSuccessOverlay(
showSuccess: isCompleted,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: isCompleted
? () => _navigateToMetadataScreen(item)
: item.status == DownloadStatus.failed ||
item.status == DownloadStatus.skipped
? () => _showDownloadErrorDialog(context, item)
: null,
onLongPress: item.status == DownloadStatus.queued
? () => _showQueuedItemMenu(context, item)
: null,
borderRadius: BorderRadius.circular(12),
child: Stack(
children: [
if (item.status == DownloadStatus.downloading)
Positioned.fill(
child: SmoothedProgressBuilder(
builder: (context, progress, child) => Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: progress,
child: child,
),
),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
colorScheme.primary.withValues(alpha: 0.16),
colorScheme.primary.withValues(alpha: 0.04),
],
),
),
child: TrackCard(
onTap: isCompleted
? () => _navigateToMetadataScreen(item)
: item.status == DownloadStatus.failed ||
item.status == DownloadStatus.skipped
? () => _showDownloadErrorDialog(context, item)
: null,
onLongPress: item.status == DownloadStatus.queued
? () => _showQueuedItemMenu(context, item)
: null,
background: item.status == DownloadStatus.downloading
? SmoothedProgressBuilder(
builder: (context, progress, child) => Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: progress,
child: child,
),
),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
colorScheme.primary.withValues(alpha: 0.16),
colorScheme.primary.withValues(alpha: 0.04),
],
),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
isCompleted
? Hero(
tag: 'cover_${item.id}',
child: _buildCoverArt(item, colorScheme),
)
: _buildCoverArt(item, colorScheme),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 2),
ClickableArtistName(
artistName: item.track.artistName,
artistId: item.track.artistId,
coverUrl: item.track.coverUrl,
extensionId: item.track.source,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (item.status ==
DownloadStatus.downloading) ...[
const SizedBox(height: 5),
Row(
children: [
Icon(
Icons.download_rounded,
size: 12,
color: colorScheme.primary,
),
const SizedBox(width: 4),
Expanded(
child: SmoothedProgressBuilder(
builder: (context, progress, child) =>
Text(
_formatDownloadStatusLine(
context,
item,
visualProgress: progress,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
),
],
)
: null,
leading: isCompleted
? Hero(
tag: 'cover_${item.id}',
child: _buildCoverArt(item, colorScheme),
)
: _buildCoverArt(item, colorScheme),
title: item.track.name,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ClickableArtistName(
artistName: item.track.artistName,
artistId: item.track.artistId,
coverUrl: item.track.coverUrl,
extensionId: item.track.source,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (item.status == DownloadStatus.downloading) ...[
const SizedBox(height: 5),
Row(
children: [
Icon(
Icons.download_rounded,
size: 12,
color: colorScheme.primary,
),
const SizedBox(width: 4),
Expanded(
child: SmoothedProgressBuilder(
builder: (context, progress, child) => Text(
_formatDownloadStatusLine(
context,
item,
visualProgress: progress,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
],
if (item.status == DownloadStatus.failed) ...[
const SizedBox(height: 4),
_buildDownloadFailureMessage(
context,
item,
colorScheme,
),
],
],
),
),
const SizedBox(width: 8),
_buildActionButtons(context, item, colorScheme),
],
),
),
],
),
],
),
if (item.status == DownloadStatus.failed) ...[
const SizedBox(height: 4),
_buildDownloadFailureMessage(context, item, colorScheme),
],
],
),
trailing: _buildActionButtons(context, item, colorScheme),
),
),
),
@@ -894,170 +858,123 @@ extension _QueueTabItemWidgets on _QueueTabState {
return Semantics(
label: context.l10n.a11yTrackByArtist(item.trackName, item.artistName),
selected: isSelected,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: null,
child: InkWell(
onTap: _isSelectionMode
? () => _toggleSelection(item.id)
: isDownloaded
? () => _navigateToHistoryMetadataScreen(
item.historyItem!,
navigationItems: downloadedNavigationItems,
navigationIndex: downloadedNavigationIndex,
)
: item.localItem != null
? () => _navigateToLocalMetadataScreen(
item.localItem!,
navigationItems: localNavigationItems,
navigationIndex: localNavigationIndex,
)
: () => _openFile(
item.filePath,
title: item.trackName,
artist: item.artistName,
album: item.albumName,
coverUrl: item.coverUrl ?? item.localCoverPath ?? '',
),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(item.id),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
child: TrackCard(
isSelectionMode: _isSelectionMode,
isSelected: isSelected,
onTap: _isSelectionMode
? () => _toggleSelection(item.id)
: isDownloaded
? () => _navigateToHistoryMetadataScreen(
item.historyItem!,
navigationItems: downloadedNavigationItems,
navigationIndex: downloadedNavigationIndex,
)
: item.localItem != null
? () => _navigateToLocalMetadataScreen(
item.localItem!,
navigationItems: localNavigationItems,
navigationIndex: localNavigationIndex,
)
: () => _openFile(
item.filePath,
title: item.trackName,
artist: item.artistName,
album: item.albumName,
coverUrl: item.coverUrl ?? item.localCoverPath ?? '',
),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(item.id),
leading: Hero(
tag: 'cover_lib_${item.id}',
child: _buildUnifiedCoverImage(
item,
colorScheme,
context.tokens.coverList,
),
),
title: item.trackName,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ClickableArtistName(
artistName: item.artistName,
coverUrl: item.coverUrl,
extensionId: item.historyItem?.service,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Row(
children: [
if (_isSelectionMode) ...[
Semantics(
checked: isSelected,
label: isSelected
? context.l10n.a11yDeselectTrack
: context.l10n.a11ySelectTrack,
child: AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
Container(
padding: context.tokens.badgePadding,
decoration: BoxDecoration(
color: sourceColor,
borderRadius: context.tokens.borderRadiusBadge,
),
const SizedBox(width: 12),
],
Hero(
tag: 'cover_lib_${item.id}',
child: _buildUnifiedCoverImage(item, colorScheme, 56),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
ClickableArtistName(
artistName: item.artistName,
coverUrl: item.coverUrl,
extensionId: item.historyItem?.service,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: sourceColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
sourceLabel,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: sourceTextColor,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
dateStr,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onSurfaceVariant
.withValues(alpha: 0.7),
),
),
),
if (quality != null && quality.isNotEmpty) ...[
const SizedBox(width: 8),
_buildLibraryQualityBadge(
context,
colorScheme,
quality,
listStyle: true,
),
],
],
),
],
child: Text(
sourceLabel,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: sourceTextColor,
fontSize: context.tokens.badgeFontSize,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
if (!_isSelectionMode)
ValueListenableBuilder<bool>(
valueListenable: fileExistsListenable,
builder: (context, fileExists, child) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (fileExists)
IconButton(
onPressed: () =>
_playLibraryItem(item, libraryItems),
icon: Icon(
Icons.play_arrow,
color: colorScheme.primary,
),
tooltip: context.l10n.tooltipPlay,
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer
.withValues(alpha: 0.3),
),
)
else
Icon(
Icons.error_outline,
color: colorScheme.error,
size: 20,
),
],
);
},
Flexible(
child: Text(
dateStr,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
if (quality != null && quality.isNotEmpty) ...[
const SizedBox(width: 8),
_buildLibraryQualityBadge(
context,
colorScheme,
quality,
listStyle: true,
),
],
],
),
),
],
),
trailing: ValueListenableBuilder<bool>(
valueListenable: fileExistsListenable,
builder: (context, fileExists, child) {
if (fileExists) {
return IconButton(
onPressed: () => _playLibraryItem(item, libraryItems),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
tooltip: context.l10n.tooltipPlay,
style: IconButton.styleFrom(
minimumSize: Size.square(context.tokens.minTouchTarget),
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
);
}
return Tooltip(
message: context.l10n.queueDownloadedFileMissing,
child: Icon(
Icons.error_outline,
color: colorScheme.error,
size: 20,
),
);
},
),
),
);
@@ -1078,7 +995,12 @@ extension _QueueTabItemWidgets on _QueueTabState {
final isDownloaded = item.source == LibraryItemSource.downloaded;
final quality = item.qualityForMode(_libraryQualityLabelMode);
return GestureDetector(
return TrackGridCard(
isSelected: _isSelectionMode && isSelected,
semanticLabel: context.l10n.a11yTrackByArtist(
item.trackName,
item.artistName,
),
onTap: _isSelectionMode
? () => _toggleSelection(item.id)
: isDownloaded
@@ -1101,156 +1023,116 @@ extension _QueueTabItemWidgets on _QueueTabState {
coverUrl: item.coverUrl ?? item.localCoverPath ?? '',
),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
AspectRatio(
aspectRatio: 1,
child: Hero(
tag: 'cover_lib_${item.id}',
child: _buildUnifiedCoverImage(item, colorScheme),
),
),
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: isDownloaded
? colorScheme.primaryContainer
: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
isDownloaded ? Icons.download_done : Icons.folder,
size: 12,
color: isDownloaded
? colorScheme.onPrimaryContainer
: colorScheme.onSecondaryContainer,
),
),
),
if (quality != null && quality.isNotEmpty)
Positioned(
left: 4,
top: 4,
child: _buildLibraryQualityBadge(
context,
colorScheme,
quality,
),
),
if (!_isSelectionMode)
Positioned(
right: 4,
bottom: 4,
child: ValueListenableBuilder<bool>(
valueListenable: fileExistsListenable,
builder: (context, fileExists, child) {
return fileExists
? Semantics(
button: true,
label: context.l10n.a11yPlayTrackByArtist(
item.trackName,
item.artistName,
),
child: GestureDetector(
onTap: () =>
_playLibraryItem(item, libraryItems),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: ExcludeSemantics(
child: Icon(
Icons.play_arrow,
color: colorScheme.onPrimary,
size: 16,
),
),
),
),
)
: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.error_outline,
color: colorScheme.error,
size: 14,
),
);
},
),
),
if (_isSelectionMode)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withValues(alpha: 0.3)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
),
),
],
cover: Hero(
tag: 'cover_lib_${item.id}',
child: _buildUnifiedCoverImage(item, colorScheme),
),
overlays: [
if (!_isSelectionMode)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: isDownloaded
? colorScheme.primaryContainer
: colorScheme.secondaryContainer,
borderRadius: context.tokens.borderRadiusBadge,
),
const SizedBox(height: 6),
Text(
item.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
),
ClickableArtistName(
artistName: item.artistName,
coverUrl: item.coverUrl,
extensionId: item.historyItem?.service,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
if (_isSelectionMode)
Positioned(
right: 4,
top: 4,
child: Container(
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
: const SizedBox(width: 16, height: 16),
child: Icon(
isDownloaded ? Icons.download_done : Icons.folder,
size: 12,
color: isDownloaded
? colorScheme.onPrimaryContainer
: colorScheme.onSecondaryContainer,
),
),
],
),
if (quality != null && quality.isNotEmpty)
Positioned(
left: 4,
top: 4,
child: _buildLibraryQualityBadge(context, colorScheme, quality),
),
if (!_isSelectionMode)
Positioned(
right: 4,
bottom: 4,
child: ValueListenableBuilder<bool>(
valueListenable: fileExistsListenable,
builder: (context, fileExists, child) {
if (fileExists) {
return Semantics(
button: true,
label: context.l10n.a11yPlayTrackByArtist(
item.trackName,
item.artistName,
),
child: IconButton.filled(
onPressed: () => _playLibraryItem(item, libraryItems),
visualDensity: VisualDensity.compact,
iconSize: 18,
icon: const Icon(Icons.play_arrow),
style: IconButton.styleFrom(
minimumSize: const Size.square(36),
padding: EdgeInsets.zero,
),
),
);
}
return Tooltip(
message: context.l10n.queueDownloadedFileMissing,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.error_outline,
color: colorScheme.error,
size: 14,
),
),
);
},
),
),
if (_isSelectionMode)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withValues(alpha: 0.3)
: Colors.transparent,
borderRadius: context.tokens.borderRadiusThumb,
),
),
),
if (_isSelectionMode)
Positioned(
right: 4,
top: 4,
child: AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
unselectedColor: colorScheme.surface,
),
),
],
title: item.trackName,
subtitle: ClickableArtistName(
artistName: item.artistName,
coverUrl: item.coverUrl,
extensionId: item.historyItem?.service,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
);
}
+32 -63
View File
@@ -1,10 +1,13 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/widgets/track_card.dart';
/// Shared track row for album-style screens: track number, selection
/// checkbox, title/subtitle, and a trailing play button that's replaced by
/// the checkbox once selection mode is active.
/// Album-style track row: track number, title/subtitle, and a trailing play
/// button that's hidden once selection mode is active.
///
/// A thin arrangement over [TrackCard]; the container, selection tick, padding
/// and typography all come from there.
class AlbumTrackTile extends StatelessWidget {
const AlbumTrackTile({
super.key,
@@ -33,66 +36,32 @@ class AlbumTrackTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
return TrackCard(
style: TrackCardStyle.flat,
isSelectionMode: isSelectionMode,
isSelected: isSelected,
onTap: isSelectionMode ? onToggleSelection : onOpen,
onLongPress: isSelectionMode ? null : onEnterSelectionMode,
leading: SizedBox(
width: 24,
child: Text(
trackNumber?.toString() ?? '-',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
onTap: isSelectionMode ? onToggleSelection : onOpen,
onLongPress: isSelectionMode ? null : onEnterSelectionMode,
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelectionMode) ...[
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
const SizedBox(width: 12),
],
SizedBox(
width: 24,
child: Text(
trackNumber?.toString() ?? '-',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
],
),
title: Text(
trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: subtitle,
trailing: isSelectionMode
? null
: IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: onPlay,
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
),
textAlign: TextAlign.center,
),
),
title: trackName,
subtitle: subtitle,
trailing: IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: onPlay,
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
minimumSize: Size.square(context.tokens.minTouchTarget),
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
);
+299
View File
@@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
/// How a [TrackCard] paints its container.
enum TrackCardStyle {
/// Filled card surface. Used where rows are discrete objects the user acts
/// on individually: the download queue, history, the local library.
filled,
/// Transparent container. Used inside a collection that already has its own
/// header and reads as one continuous list: album, playlist and folder track
/// lists.
flat,
}
/// The one track row in the app.
///
/// Six near-identical implementations existed before this
/// (`TrackListTile`, `AlbumTrackTile`, the queue item, the bridge item, the
/// unified library item and the folder tile), each with its own radius,
/// padding, title style and selection treatment. Screens now supply only the
/// parts that genuinely differ: [leading], [subtitle], [trailing] and an
/// optional [background] layer for download progress.
class TrackCard extends StatelessWidget {
const TrackCard({
super.key,
required this.leading,
required this.title,
this.subtitle,
this.trailing,
this.background,
this.style = TrackCardStyle.filled,
this.isSelectionMode = false,
this.isSelected = false,
this.onTap,
this.onLongPress,
this.margin,
this.titleStyle,
});
/// Track number, cover art, or anything else that identifies the row.
final Widget leading;
final String title;
/// Secondary line. A widget rather than a string because callers mix in
/// clickable artist names, quality badges and status text.
final Widget? subtitle;
/// Hidden while [isSelectionMode] is active, so the row cannot be acted on
/// while the user is picking items.
final Widget? trailing;
/// Painted behind the content, clipped to the card. Used for the queue's
/// progress fill.
final Widget? background;
final TrackCardStyle style;
final bool isSelectionMode;
final bool isSelected;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
/// Overrides the default outer margin. Prefer the default so track lists stay
/// aligned with each other.
final EdgeInsetsGeometry? margin;
/// Overrides the title text style for rows that need to signal a different
/// state (e.g. a dimmed unavailable track).
final TextStyle? titleStyle;
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final borderRadius = tokens.borderRadiusCover;
final Color? cardColor;
if (isSelected) {
cardColor = colorScheme.primaryContainer.withValues(alpha: 0.3);
} else if (style == TrackCardStyle.flat) {
cardColor = Colors.transparent;
} else {
cardColor = null; // Inherits cardTheme.color.
}
return Card(
elevation: 0,
// The two styles carry their own outer margin so no call site has to
// repeat it: flat rows sit tight inside a collection list, filled cards
// get the Material list margin.
margin:
margin ??
(style == TrackCardStyle.flat
? EdgeInsets.symmetric(horizontal: tokens.gapSm, vertical: 2)
: EdgeInsets.symmetric(
horizontal: tokens.gapLg,
vertical: tokens.gapXs,
)),
color: cardColor,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: borderRadius),
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: borderRadius,
child: Stack(
children: [
if (background != null) Positioned.fill(child: background!),
Padding(
padding: EdgeInsets.all(tokens.gapMd),
child: Row(
children: [
if (isSelectionMode) ...[
Semantics(
checked: isSelected,
label: isSelected
? context.l10n.a11yDeselectTrack
: context.l10n.a11ySelectTrack,
child: AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
),
SizedBox(width: tokens.gapMd),
],
leading,
SizedBox(width: tokens.gapMd),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
titleStyle ??
theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
subtitle!,
],
],
),
),
if (trailing != null && !isSelectionMode) ...[
SizedBox(width: tokens.gapSm),
trailing!,
],
],
),
),
],
),
),
);
}
}
/// Grid counterpart of [TrackCard]: square artwork with overlays, then the
/// title and subtitle underneath.
///
/// Replaces three copies that each re-declared the radius, the overlay stack
/// and the label typography, and used a bare `GestureDetector` (no ripple, no
/// semantics).
class TrackGridCard extends StatelessWidget {
const TrackGridCard({
super.key,
required this.cover,
required this.title,
this.subtitle,
this.overlays = const [],
this.onTap,
this.onLongPress,
this.isSelected = false,
this.semanticLabel,
});
/// Artwork, already sized to fill the square. Clipping is handled here.
final Widget cover;
final String title;
/// Secondary line. A widget so cells can host a clickable artist name; plain
/// `Text` children inherit the shared subtitle style.
final Widget? subtitle;
/// Progress rings, status badges and selection ticks stacked over the cover.
final List<Widget> overlays;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final bool isSelected;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final borderRadius = tokens.borderRadiusThumb;
return Semantics(
label: semanticLabel,
button: onTap != null,
selected: isSelected,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: borderRadius,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(borderRadius: borderRadius, child: cover),
if (isSelected)
DecoratedBox(
decoration: BoxDecoration(
borderRadius: borderRadius,
border: Border.all(
color: colorScheme.primary,
width: 2,
),
),
),
...overlays,
],
),
),
SizedBox(height: tokens.gapXs + 2),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (subtitle != null)
DefaultTextStyle.merge(
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall!.copyWith(
color: colorScheme.onSurfaceVariant,
),
child: subtitle!,
),
],
),
),
);
}
}
/// Square artwork placeholder shared by every track row and grid cell.
///
/// The `Container` + `surfaceContainerHighest` + `music_note` combination was
/// repeated at roughly a dozen call sites with four different radii.
class TrackCoverPlaceholder extends StatelessWidget {
const TrackCoverPlaceholder({super.key, this.size, this.borderRadius});
/// Omit to fill the available space (grid cells).
final double? size;
final BorderRadius? borderRadius;
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: borderRadius ?? tokens.borderRadiusThumb,
),
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
size: size == null ? null : size! * 0.45,
),
);
}
}
+60 -95
View File
@@ -7,7 +7,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/local_playback.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/track_card.dart';
import 'package:spotiflac_android/widgets/in_library_badge.dart';
import 'package:spotiflac_android/widgets/preview_button.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
@@ -70,106 +70,71 @@ class TrackListTile extends ConsumerWidget {
final isQueued = queueItem != null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
return TrackCard(
style: TrackCardStyle.flat,
isSelectionMode: isSelectionMode,
isSelected: isSelected,
leading: leading,
title: track.name,
subtitle: Row(
children: [
Flexible(
child: clickableArtist && !isSelectionMode
? ClickableArtistName(
artistName: track.artistName,
artistId: track.artistId,
coverUrl: track.coverUrl,
extensionId: track.source,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
)
: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelectionMode) ...[
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
const SizedBox(width: 12),
],
leading,
],
...buildQualityBadges(
audioQuality: track.audioQuality,
audioModes: track.audioModes,
colorScheme: colorScheme,
explicit: track.isExplicit,
),
title: Text(
track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
if (isInLocalLibrary || isInHistory) ...[
const SizedBox(width: 6),
const InLibraryBadge(),
],
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
PreviewButton(track: track),
TrackCollectionQuickActions(
track: track,
hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary,
),
],
),
onTap: isSelectionMode
? onToggleSelection
: () => _handleTap(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Row(
children: [
Flexible(
child: clickableArtist && !isSelectionMode
? ClickableArtistName(
artistName: track.artistName,
artistId: track.artistId,
coverUrl: track.coverUrl,
extensionId: track.source,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
)
: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
...buildQualityBadges(
audioQuality: track.audioQuality,
audioModes: track.audioModes,
colorScheme: colorScheme,
explicit: track.isExplicit,
),
if (isInLocalLibrary || isInHistory) ...[
const SizedBox(width: 6),
const InLibraryBadge(),
],
],
),
trailing: isSelectionMode
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
PreviewButton(track: track),
TrackCollectionQuickActions(
track: track,
hasLocalPlaybackCandidate:
isInHistory || isInLocalLibrary,
),
],
),
onTap: isSelectionMode
? onToggleSelection
: () => _handleTap(
ref,
isQueued: isQueued,
isInLocalLibrary: isInLocalLibrary,
),
onLongPress: isSelectionMode
? null
: onEnterSelectionMode ??
() => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
isQueued: isQueued,
isInLocalLibrary: isInLocalLibrary,
track,
hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary,
),
onLongPress: isSelectionMode
? null
: onEnterSelectionMode ??
() => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
track,
hasLocalPlaybackCandidate:
isInHistory || isInLocalLibrary,
),
),
),
);
}