mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
refactor(library): unify track cards
This commit is contained in:
+12
-53
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user