mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-25 21:40:57 +02:00
refactor(queue): split _QueueTabState method clusters into part files
queue_tab.dart was 7,567 lines with a single 7,300-line State class. Continue the existing part-file split by moving six cohesive method clusters into private extensions on _QueueTabState (same library, so private state access is unchanged): selection mode, navigation/open actions, collection item builders, filter UI, batch actions, and queue/library item builders. setState calls in the parts go through a _setState forwarder since @protected members cannot be called from extensions. queue_tab.dart is now 2,462 lines. # Conflicts: # lib/screens/queue_tab.dart
This commit is contained in:
+10
-5116
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,650 @@
|
||||
part of 'queue_tab.dart';
|
||||
|
||||
extension _QueueTabCollectionItemWidgets on _QueueTabState {
|
||||
Widget _buildDownloadGridItem(
|
||||
BuildContext context,
|
||||
DownloadItem item,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final radius = BorderRadius.circular(8);
|
||||
final isDownloading = item.status == DownloadStatus.downloading;
|
||||
final isFinalizing = item.status == DownloadStatus.finalizing;
|
||||
final isQueued = item.status == DownloadStatus.queued;
|
||||
final isFailed = item.status == DownloadStatus.failed;
|
||||
final progress = item.progress.clamp(0.0, 1.0);
|
||||
final pct = (progress * 100).round();
|
||||
|
||||
final cover = item.track.coverUrl != null
|
||||
? CachedCoverImage(
|
||||
imageUrl: item.track.coverUrl!,
|
||||
borderRadius: radius,
|
||||
fadeInDuration: const Duration(milliseconds: 180),
|
||||
)
|
||||
: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: radius,
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
);
|
||||
|
||||
final onTap = isFailed
|
||||
? () => _showDownloadErrorDialog(context, item)
|
||||
: item.status == DownloadStatus.skipped
|
||||
? () => ref.read(downloadQueueProvider.notifier).removeItem(item.id)
|
||||
: () => _confirmCancelDownload(context, item);
|
||||
|
||||
return GestureDetector(
|
||||
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: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: CircularProgressIndicator(
|
||||
value: (isFinalizing || isQueued || progress <= 0)
|
||||
? null
|
||||
: progress,
|
||||
strokeWidth: 3,
|
||||
color: Colors.white,
|
||||
backgroundColor: Colors.white.withValues(
|
||||
alpha: 0.25,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isDownloading && progress > 0) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'$pct%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBridgeGridItem(
|
||||
BuildContext context,
|
||||
Track track,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final radius = BorderRadius.circular(8);
|
||||
final cover = 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),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ClipRRect(borderRadius: radius, child: cover),
|
||||
if (track.hasAudioQuality)
|
||||
Positioned(
|
||||
left: 4,
|
||||
top: 4,
|
||||
child: AudioQualityBadge(
|
||||
label: track.audioQuality!,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
track.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBridgeListItem(
|
||||
BuildContext context,
|
||||
Track track,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final coverSize = _queueCoverSize();
|
||||
final radius = BorderRadius.circular(8);
|
||||
final cover = track.coverUrl != null
|
||||
? CachedCoverImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
borderRadius: radius,
|
||||
)
|
||||
: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: radius,
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
cover,
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCollectionListItem({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
IconData? icon,
|
||||
Color? iconColor,
|
||||
Color? iconBgColor,
|
||||
Widget? coverWidget,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
VoidCallback? onLongPress,
|
||||
}) {
|
||||
final cover =
|
||||
coverWidget ??
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBgColor ?? colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon ?? Icons.folder,
|
||||
color: iconColor ?? Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 56, height: 56, child: cover),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCollectionGridItem({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
IconData? icon,
|
||||
Color? iconColor,
|
||||
Color? iconBgColor,
|
||||
Widget? coverWidget,
|
||||
required String title,
|
||||
required int count,
|
||||
required VoidCallback onTap,
|
||||
VoidCallback? onLongPress,
|
||||
}) {
|
||||
final cover =
|
||||
coverWidget ??
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: iconBgColor ?? colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon ?? Icons.folder,
|
||||
color: iconColor ?? Colors.white,
|
||||
size: 40,
|
||||
),
|
||||
);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: context.l10n.a11yOpenItemCount(title, count),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
Text(
|
||||
context.l10n.itemCount(count),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_CollectionEntry> _getVisibleCollectionEntries(
|
||||
LibraryCollectionsState collectionState,
|
||||
) {
|
||||
final entries = <_CollectionEntry>[];
|
||||
if (collectionState.wishlistCount > 0) {
|
||||
entries.add(_CollectionEntry.wishlist);
|
||||
}
|
||||
if (collectionState.lovedCount > 0) {
|
||||
entries.add(_CollectionEntry.loved);
|
||||
}
|
||||
if (collectionState.favoriteArtistCount > 0) {
|
||||
entries.add(_CollectionEntry.favoriteArtists);
|
||||
}
|
||||
for (var i = 0; i < collectionState.playlists.length; i++) {
|
||||
entries.add(_CollectionEntry.playlist(i));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
Widget _buildAllTabGridCollectionItem({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
required _CollectionEntry entry,
|
||||
required LibraryCollectionsState collectionState,
|
||||
List<UnifiedLibraryItem> filteredUnifiedItems = const [],
|
||||
}) {
|
||||
switch (entry.type) {
|
||||
case _CollectionEntryType.wishlist:
|
||||
return _buildCollectionGridItem(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
icon: Icons.add_circle_outline,
|
||||
iconColor: Colors.white,
|
||||
iconBgColor: const Color(0xFF1DB954),
|
||||
title: context.l10n.collectionWishlist,
|
||||
count: collectionState.wishlistCount,
|
||||
onTap: _openWishlistFolder,
|
||||
);
|
||||
case _CollectionEntryType.loved:
|
||||
return _buildCollectionGridItem(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
icon: Icons.favorite,
|
||||
iconColor: Colors.white,
|
||||
iconBgColor: const Color(0xFF8C67AC),
|
||||
title: context.l10n.collectionLoved,
|
||||
count: collectionState.lovedCount,
|
||||
onTap: _openLovedFolder,
|
||||
);
|
||||
case _CollectionEntryType.favoriteArtists:
|
||||
return _buildCollectionGridItem(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
icon: Icons.person,
|
||||
iconColor: Colors.white,
|
||||
iconBgColor: const Color(0xFFE91E63),
|
||||
title: context.l10n.collectionFavoriteArtists,
|
||||
count: collectionState.favoriteArtistCount,
|
||||
onTap: _openFavoriteArtistsFolder,
|
||||
);
|
||||
case _CollectionEntryType.playlist:
|
||||
final playlist = collectionState.playlists[entry.playlistIndex];
|
||||
final isSelected = _selectedPlaylistIds.contains(playlist.id);
|
||||
return DragTarget<UnifiedLibraryItem>(
|
||||
onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode,
|
||||
onAcceptWithDetails: (details) {
|
||||
_onTrackDroppedOnPlaylist(
|
||||
context,
|
||||
details.data,
|
||||
playlist.id,
|
||||
playlist.name,
|
||||
allItems: filteredUnifiedItems,
|
||||
);
|
||||
},
|
||||
builder: (context, candidateData, rejectedData) {
|
||||
final isHovering = candidateData.isNotEmpty;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: isHovering
|
||||
? BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colorScheme.primary, width: 2),
|
||||
color: colorScheme.primary.withValues(alpha: 0.1),
|
||||
)
|
||||
: null,
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildCollectionGridItem(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
coverWidget: _buildPlaylistCover(
|
||||
context,
|
||||
playlist,
|
||||
colorScheme,
|
||||
),
|
||||
title: playlist.name,
|
||||
count: playlist.tracks.length,
|
||||
onTap: _isPlaylistSelectionMode
|
||||
? () => _togglePlaylistSelection(playlist.id)
|
||||
: () => _openPlaylistById(playlist.id),
|
||||
onLongPress: _isPlaylistSelectionMode
|
||||
? () => _togglePlaylistSelection(playlist.id)
|
||||
: () => _enterPlaylistSelectionMode(playlist.id),
|
||||
),
|
||||
if (_isPlaylistSelectionMode)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primary.withValues(alpha: 0.3)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isPlaylistSelectionMode)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedSelectionCheckbox(
|
||||
visible: true,
|
||||
selected: isSelected,
|
||||
colorScheme: colorScheme,
|
||||
size: 20,
|
||||
unselectedColor: colorScheme.surface.withValues(
|
||||
alpha: 0.85,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAllTabListCollectionItem({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
required _CollectionEntry entry,
|
||||
required LibraryCollectionsState collectionState,
|
||||
List<UnifiedLibraryItem> filteredUnifiedItems = const [],
|
||||
}) {
|
||||
switch (entry.type) {
|
||||
case _CollectionEntryType.wishlist:
|
||||
return _buildCollectionListItem(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
icon: Icons.add_circle_outline,
|
||||
iconColor: Colors.white,
|
||||
iconBgColor: const Color(0xFF1DB954),
|
||||
title: context.l10n.collectionWishlist,
|
||||
subtitle:
|
||||
'${context.l10n.collectionFoldersTitle} • ${collectionState.wishlistCount} ${collectionState.wishlistCount == 1 ? 'track' : 'tracks'}',
|
||||
onTap: _openWishlistFolder,
|
||||
);
|
||||
case _CollectionEntryType.loved:
|
||||
return _buildCollectionListItem(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
icon: Icons.favorite,
|
||||
iconColor: Colors.white,
|
||||
iconBgColor: const Color(0xFF8C67AC),
|
||||
title: context.l10n.collectionLoved,
|
||||
subtitle:
|
||||
'${context.l10n.collectionFoldersTitle} • ${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}',
|
||||
onTap: _openLovedFolder,
|
||||
);
|
||||
case _CollectionEntryType.favoriteArtists:
|
||||
return _buildCollectionListItem(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
icon: Icons.person,
|
||||
iconColor: Colors.white,
|
||||
iconBgColor: const Color(0xFFE91E63),
|
||||
title: context.l10n.collectionFavoriteArtists,
|
||||
subtitle:
|
||||
'${context.l10n.collectionFoldersTitle} • ${context.l10n.collectionArtistCount(collectionState.favoriteArtistCount)}',
|
||||
onTap: _openFavoriteArtistsFolder,
|
||||
);
|
||||
case _CollectionEntryType.playlist:
|
||||
final playlist = collectionState.playlists[entry.playlistIndex];
|
||||
final isSelected = _selectedPlaylistIds.contains(playlist.id);
|
||||
return DragTarget<UnifiedLibraryItem>(
|
||||
onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode,
|
||||
onAcceptWithDetails: (details) {
|
||||
_onTrackDroppedOnPlaylist(
|
||||
context,
|
||||
details.data,
|
||||
playlist.id,
|
||||
playlist.name,
|
||||
allItems: filteredUnifiedItems,
|
||||
);
|
||||
},
|
||||
builder: (context, candidateData, rejectedData) {
|
||||
final isHovering = candidateData.isNotEmpty;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: isHovering
|
||||
? BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colorScheme.primary, width: 2),
|
||||
color: colorScheme.primary.withValues(alpha: 0.1),
|
||||
)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
if (_isPlaylistSelectionMode)
|
||||
GestureDetector(
|
||||
onTap: () => _togglePlaylistSelection(playlist.id),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: AnimatedSelectionCheckbox(
|
||||
visible: true,
|
||||
selected: isSelected,
|
||||
colorScheme: colorScheme,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildCollectionListItem(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
coverWidget: _buildPlaylistCover(
|
||||
context,
|
||||
playlist,
|
||||
colorScheme,
|
||||
56,
|
||||
),
|
||||
title: playlist.name,
|
||||
subtitle:
|
||||
'${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}',
|
||||
onTap: _isPlaylistSelectionMode
|
||||
? () => _togglePlaylistSelection(playlist.id)
|
||||
: () => _openPlaylistById(playlist.id),
|
||||
onLongPress: _isPlaylistSelectionMode
|
||||
? () => _togglePlaylistSelection(playlist.id)
|
||||
: () => _enterPlaylistSelectionMode(playlist.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,936 @@
|
||||
part of 'queue_tab.dart';
|
||||
|
||||
extension _QueueTabFilterWidgets on _QueueTabState {
|
||||
Widget _buildFilterContent({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
required String filterMode,
|
||||
required String historyViewMode,
|
||||
required bool hasQueueItems,
|
||||
required _FilterContentData filterData,
|
||||
required LibraryCollectionsState collectionState,
|
||||
required bool hasMoreLibrary,
|
||||
required bool isPageLoading,
|
||||
double bottomInset = 0,
|
||||
}) {
|
||||
final historyItems = filterData.historyItems;
|
||||
final showFilteringIndicator = filterData.showFilteringIndicator;
|
||||
final filteredGroupedAlbums = filterData.filteredGroupedAlbums;
|
||||
final filteredGroupedLocalAlbums = filterData.filteredGroupedLocalAlbums;
|
||||
final unifiedItems = filterData.unifiedItems;
|
||||
final allFilteredUnifiedItems = filterData.filteredUnifiedItems;
|
||||
final totalTrackCount = filterData.totalTrackCount;
|
||||
final totalAlbumCount = filterData.totalAlbumCount;
|
||||
|
||||
final activeDownloadIds = filterMode == 'albums'
|
||||
? const <String>[]
|
||||
: ref
|
||||
.watch(
|
||||
downloadQueueLookupProvider.select((lookup) {
|
||||
final ids = <String>[];
|
||||
for (final id in lookup.itemIds) {
|
||||
final entry = lookup.byItemId[id];
|
||||
if (entry != null &&
|
||||
entry.status != DownloadStatus.completed) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return _QueueItemIdsSnapshot(ids);
|
||||
}),
|
||||
)
|
||||
.ids
|
||||
.reversed
|
||||
.toList(growable: false);
|
||||
|
||||
final libIdSet = <String>{
|
||||
for (final item in allFilteredUnifiedItems) item.id,
|
||||
};
|
||||
List<String> bridgeIds = const [];
|
||||
if (filterMode != 'albums' && _completionBridge.isNotEmpty) {
|
||||
final now = DateTime.now();
|
||||
final stale = <String>[];
|
||||
final pending = <String>[];
|
||||
final hasActiveDownloads = activeDownloadIds.isNotEmpty;
|
||||
_completionBridge.forEach((id, _) {
|
||||
final landed = libIdSet.contains('dl_$id');
|
||||
final addedAt = _completionBridgeAt[id];
|
||||
final expired =
|
||||
addedAt == null || now.difference(addedAt).inSeconds >= 6;
|
||||
if (activeDownloadIds.contains(id)) {
|
||||
// Re-queued (retry): the live row takes over from the bridge.
|
||||
stale.add(id);
|
||||
} else if (hasActiveDownloads) {
|
||||
// Keep just-completed tracks pinned in the lead zone while the
|
||||
// rest of the batch is still downloading, so they don't jump
|
||||
// below the remaining queue the moment they finish.
|
||||
pending.add(id);
|
||||
} else if (landed || expired) {
|
||||
stale.add(id);
|
||||
} else {
|
||||
pending.add(id);
|
||||
}
|
||||
});
|
||||
bridgeIds = pending;
|
||||
if (stale.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
var changed = false;
|
||||
for (final id in stale) {
|
||||
if (_completionBridge.remove(id) != null) changed = true;
|
||||
_completionBridgeAt.remove(id);
|
||||
_bridgePrecacheStarted.remove(id);
|
||||
}
|
||||
if (changed) _setState(() {});
|
||||
});
|
||||
}
|
||||
final toPrecache = pending
|
||||
.where((id) => !_bridgePrecacheStarted.contains(id))
|
||||
.toList(growable: false);
|
||||
if (toPrecache.isNotEmpty) {
|
||||
final historyItems = ref.read(downloadHistoryProvider).items;
|
||||
for (final id in toPrecache) {
|
||||
DownloadHistoryItem? historyItem;
|
||||
for (final h in historyItems) {
|
||||
if (h.id == id) {
|
||||
historyItem = h;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (historyItem == null) continue;
|
||||
_bridgePrecacheStarted.add(id);
|
||||
final coverUrl = historyItem.coverUrl;
|
||||
final embeddedPath = _resolveDownloadedEmbeddedCoverPath(
|
||||
historyItem.filePath,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
if (embeddedPath != null) {
|
||||
precacheImage(FileImage(File(embeddedPath)), context);
|
||||
}
|
||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(
|
||||
coverUrl,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
context,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks pinned as completion-bridge cells render in the lead zone;
|
||||
// hide their history rows so they don't appear twice.
|
||||
List<UnifiedLibraryItem> filteredUnifiedItems = allFilteredUnifiedItems;
|
||||
if (bridgeIds.isNotEmpty) {
|
||||
final pinnedHistoryIds = <String>{for (final id in bridgeIds) 'dl_$id'};
|
||||
filteredUnifiedItems = allFilteredUnifiedItems
|
||||
.where((item) => !pinnedHistoryIds.contains(item.id))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
final downloadedNavigationItems = <DownloadHistoryItem>[];
|
||||
final downloadedNavigationIndexByUnifiedId = <String, int>{};
|
||||
final localNavigationItems = <LocalLibraryItem>[];
|
||||
final localNavigationIndexByUnifiedId = <String, int>{};
|
||||
|
||||
for (final item in filteredUnifiedItems) {
|
||||
final historyItem = item.historyItem;
|
||||
if (historyItem != null) {
|
||||
downloadedNavigationIndexByUnifiedId[item.id] =
|
||||
downloadedNavigationItems.length;
|
||||
downloadedNavigationItems.add(historyItem);
|
||||
}
|
||||
|
||||
final localItem = item.localItem;
|
||||
if (localItem != null) {
|
||||
localNavigationIndexByUnifiedId[item.id] = localNavigationItems.length;
|
||||
localNavigationItems.add(localItem);
|
||||
}
|
||||
}
|
||||
|
||||
final leadCount = activeDownloadIds.length + bridgeIds.length;
|
||||
final collectionEntries = filterMode == 'all'
|
||||
? _getVisibleCollectionEntries(collectionState)
|
||||
: const <_CollectionEntry>[];
|
||||
final collectionCount = collectionEntries.length;
|
||||
|
||||
Widget leadGridCell(int index) {
|
||||
if (index < activeDownloadIds.length) {
|
||||
final id = activeDownloadIds[index];
|
||||
return _QueueItemSliverRow(
|
||||
key: ValueKey('dlgrid_$id'),
|
||||
itemId: id,
|
||||
colorScheme: colorScheme,
|
||||
itemBuilder: _buildDownloadGridItem,
|
||||
);
|
||||
}
|
||||
final bridgeId = bridgeIds[index - activeDownloadIds.length];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey('dlgrid_bridge_$bridgeId'),
|
||||
child: _buildBridgeGridItem(
|
||||
context,
|
||||
_completionBridge[bridgeId]!,
|
||||
colorScheme,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget leadListCell(int index) {
|
||||
if (index < activeDownloadIds.length) {
|
||||
final id = activeDownloadIds[index];
|
||||
return _QueueItemSliverRow(
|
||||
key: ValueKey('dllist_$id'),
|
||||
itemId: id,
|
||||
colorScheme: colorScheme,
|
||||
itemBuilder: _buildQueueItem,
|
||||
);
|
||||
}
|
||||
final bridgeId = bridgeIds[index - activeDownloadIds.length];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey('dllist_bridge_$bridgeId'),
|
||||
child: _buildBridgeListItem(
|
||||
context,
|
||||
_completionBridge[bridgeId]!,
|
||||
colorScheme,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final content = CustomScrollView(
|
||||
slivers: [
|
||||
if (totalTrackCount > 0 && filterMode == 'all')
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.queueTrackCount(totalTrackCount),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (!_isSelectionMode)
|
||||
_buildFilterButton(context, unifiedItems),
|
||||
if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty)
|
||||
TextButton.icon(
|
||||
onPressed: () => _showCreatePlaylistDialog(context),
|
||||
icon: const Icon(Icons.add, size: 20),
|
||||
label: Text(context.l10n.collectionCreatePlaylist),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if ((filteredGroupedAlbums.isNotEmpty ||
|
||||
filteredGroupedLocalAlbums.isNotEmpty) &&
|
||||
filterMode == 'albums')
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.queueAlbumCount(totalAlbumCount),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_buildFilterButton(context, unifiedItems),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (filteredGroupedAlbums.isEmpty &&
|
||||
filteredGroupedLocalAlbums.isEmpty &&
|
||||
filterMode == 'albums' &&
|
||||
(historyItems.isNotEmpty || unifiedItems.isNotEmpty))
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
_buildFilterButton(context, unifiedItems),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (filterMode == 'all' &&
|
||||
totalTrackCount == 0 &&
|
||||
!showFilteringIndicator &&
|
||||
(_activeFilterCount > 0 || unifiedItems.isNotEmpty))
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
if (!_isSelectionMode)
|
||||
_buildFilterButton(context, unifiedItems),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (filterMode == 'singles' &&
|
||||
totalTrackCount == 0 &&
|
||||
!showFilteringIndicator &&
|
||||
(_activeFilterCount > 0 || unifiedItems.isNotEmpty))
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
if (!_isSelectionMode)
|
||||
_buildFilterButton(context, unifiedItems),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (showFilteringIndicator)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
context.l10n.queueFilteringIndicator,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (filterMode == 'all') _buildQueueHeaderSliver(context, colorScheme),
|
||||
|
||||
if (filterMode == 'albums' &&
|
||||
(filteredGroupedAlbums.isNotEmpty ||
|
||||
filteredGroupedLocalAlbums.isNotEmpty))
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: _AnimatedLibrarySliverGrid(
|
||||
maxCrossAxisExtent: _libraryAlbumGridExtent,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.72,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index < filteredGroupedAlbums.length) {
|
||||
final album = filteredGroupedAlbums[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(album.key),
|
||||
child: _buildAlbumGridItem(context, album, colorScheme),
|
||||
);
|
||||
} else {
|
||||
final localIndex = index - filteredGroupedAlbums.length;
|
||||
final album = filteredGroupedLocalAlbums[localIndex];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey('local_${album.key}'),
|
||||
child: _buildLocalAlbumGridItem(
|
||||
context,
|
||||
album,
|
||||
colorScheme,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
childCount:
|
||||
filteredGroupedAlbums.length +
|
||||
filteredGroupedLocalAlbums.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (filterMode == 'all') ...[
|
||||
if (historyViewMode == 'grid')
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: _AnimatedLibrarySliverGrid(
|
||||
maxCrossAxisExtent: _libraryGridExtent,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 0.66,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index < collectionCount) {
|
||||
return _buildAllTabGridCollectionItem(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
entry: collectionEntries[index],
|
||||
collectionState: collectionState,
|
||||
filteredUnifiedItems: filteredUnifiedItems,
|
||||
);
|
||||
}
|
||||
final afterCollections = index - collectionCount;
|
||||
if (afterCollections < leadCount) {
|
||||
return leadGridCell(afterCollections);
|
||||
}
|
||||
final trackIndex = afterCollections - leadCount;
|
||||
if (trackIndex < filteredUnifiedItems.length) {
|
||||
final item = filteredUnifiedItems[trackIndex];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(item.id),
|
||||
child: LongPressDraggable<UnifiedLibraryItem>(
|
||||
data: item,
|
||||
feedback: _buildDragFeedback(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
),
|
||||
childWhenDragging: Opacity(
|
||||
opacity: 0.4,
|
||||
child: _buildUnifiedGridItem(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
downloadedNavigationItems:
|
||||
downloadedNavigationItems,
|
||||
downloadedNavigationIndex:
|
||||
downloadedNavigationIndexByUnifiedId[item.id],
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
libraryItems: filteredUnifiedItems,
|
||||
),
|
||||
),
|
||||
child: _buildUnifiedGridItem(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
downloadedNavigationItems:
|
||||
downloadedNavigationItems,
|
||||
downloadedNavigationIndex:
|
||||
downloadedNavigationIndexByUnifiedId[item.id],
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
libraryItems: filteredUnifiedItems,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
childCount:
|
||||
leadCount + collectionCount + filteredUnifiedItems.length,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index < collectionCount) {
|
||||
return _buildAllTabListCollectionItem(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
entry: collectionEntries[index],
|
||||
collectionState: collectionState,
|
||||
filteredUnifiedItems: filteredUnifiedItems,
|
||||
);
|
||||
}
|
||||
final afterCollections = index - collectionCount;
|
||||
if (afterCollections < leadCount) {
|
||||
return leadListCell(afterCollections);
|
||||
}
|
||||
final trackIndex = afterCollections - leadCount;
|
||||
if (trackIndex < filteredUnifiedItems.length) {
|
||||
final item = filteredUnifiedItems[trackIndex];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(item.id),
|
||||
child: LongPressDraggable<UnifiedLibraryItem>(
|
||||
data: item,
|
||||
feedback: _buildDragFeedback(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
),
|
||||
childWhenDragging: Opacity(
|
||||
opacity: 0.4,
|
||||
child: _buildUnifiedLibraryItem(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
downloadedNavigationItems:
|
||||
downloadedNavigationItems,
|
||||
downloadedNavigationIndex:
|
||||
downloadedNavigationIndexByUnifiedId[item.id],
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
libraryItems: filteredUnifiedItems,
|
||||
),
|
||||
),
|
||||
child: _buildUnifiedLibraryItem(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
downloadedNavigationItems: downloadedNavigationItems,
|
||||
downloadedNavigationIndex:
|
||||
downloadedNavigationIndexByUnifiedId[item.id],
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
libraryItems: filteredUnifiedItems,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
childCount:
|
||||
leadCount + collectionCount + filteredUnifiedItems.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (filterMode == 'singles')
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.queueTrackCount(totalTrackCount),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (!_isSelectionMode)
|
||||
_buildFilterButton(context, unifiedItems),
|
||||
if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty)
|
||||
TextButton.icon(
|
||||
onPressed: () => _showCreatePlaylistDialog(context),
|
||||
icon: const Icon(Icons.add, size: 20),
|
||||
label: Text(context.l10n.collectionCreatePlaylist),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (filterMode == 'singles')
|
||||
_buildQueueHeaderSliver(context, colorScheme),
|
||||
|
||||
if ((filteredUnifiedItems.isNotEmpty || leadCount > 0) &&
|
||||
filterMode == 'singles')
|
||||
historyViewMode == 'grid'
|
||||
? SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: _AnimatedLibrarySliverGrid(
|
||||
maxCrossAxisExtent: _libraryGridExtent,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 0.66,
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (index < leadCount) {
|
||||
return leadGridCell(index);
|
||||
}
|
||||
final item = filteredUnifiedItems[index - leadCount];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(item.id),
|
||||
child: _buildUnifiedGridItem(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
downloadedNavigationItems: downloadedNavigationItems,
|
||||
downloadedNavigationIndex:
|
||||
downloadedNavigationIndexByUnifiedId[item.id],
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
libraryItems: filteredUnifiedItems,
|
||||
),
|
||||
);
|
||||
}, childCount: leadCount + filteredUnifiedItems.length),
|
||||
),
|
||||
)
|
||||
: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (index < leadCount) {
|
||||
return leadListCell(index);
|
||||
}
|
||||
final item = filteredUnifiedItems[index - leadCount];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(item.id),
|
||||
child: _buildUnifiedLibraryItem(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
downloadedNavigationItems: downloadedNavigationItems,
|
||||
downloadedNavigationIndex:
|
||||
downloadedNavigationIndexByUnifiedId[item.id],
|
||||
localNavigationItems: localNavigationItems,
|
||||
localNavigationIndex:
|
||||
localNavigationIndexByUnifiedId[item.id],
|
||||
libraryItems: filteredUnifiedItems,
|
||||
),
|
||||
);
|
||||
}, childCount: leadCount + filteredUnifiedItems.length),
|
||||
),
|
||||
|
||||
if (!hasQueueItems &&
|
||||
totalTrackCount == 0 &&
|
||||
(filterMode != 'albums' ||
|
||||
(filteredGroupedAlbums.isEmpty &&
|
||||
filteredGroupedLocalAlbums.isEmpty)) &&
|
||||
!showFilteringIndicator &&
|
||||
!isPageLoading)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: _buildEmptyState(context, colorScheme, filterMode),
|
||||
)
|
||||
else if (isPageLoading)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (hasQueueItems ||
|
||||
totalTrackCount > 0 ||
|
||||
(filterMode == 'albums' &&
|
||||
(filteredGroupedAlbums.isNotEmpty ||
|
||||
filteredGroupedLocalAlbums.isNotEmpty)))
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: _isSelectionMode ? 100 : 16),
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||
],
|
||||
);
|
||||
|
||||
final scrollAwareContent = NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) => _handleLibraryScrollNotification(
|
||||
notification: notification,
|
||||
filterMode: filterMode,
|
||||
hasMoreLibrary: hasMoreLibrary,
|
||||
isPageLoading: isPageLoading,
|
||||
),
|
||||
child: content,
|
||||
);
|
||||
|
||||
if (historyViewMode != 'grid') return scrollAwareContent;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onScaleStart: _handleLibraryGridScaleStart,
|
||||
onScaleUpdate: _handleLibraryGridScaleUpdate,
|
||||
onScaleEnd: _handleLibraryGridScaleEnd,
|
||||
child: scrollAwareContent,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showClearAllDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.queueClearAll),
|
||||
content: Text(context.l10n.queueClearAllMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(backgroundColor: colorScheme.error),
|
||||
child: Text(context.l10n.dialogClear),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
ref.read(downloadQueueProvider.notifier).clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
String filterMode,
|
||||
) {
|
||||
String message;
|
||||
String subtitle;
|
||||
IconData icon;
|
||||
|
||||
switch (filterMode) {
|
||||
case 'albums':
|
||||
message = context.l10n.queueEmptyAlbums;
|
||||
subtitle = context.l10n.queueEmptyAlbumsSubtitle;
|
||||
icon = Icons.album;
|
||||
break;
|
||||
case 'singles':
|
||||
message = context.l10n.queueEmptySingles;
|
||||
subtitle = context.l10n.queueEmptySinglesSubtitle;
|
||||
icon = Icons.music_note;
|
||||
break;
|
||||
default:
|
||||
message = context.l10n.queueEmptyHistory;
|
||||
subtitle = context.l10n.queueEmptyHistorySubtitle;
|
||||
icon = Icons.history;
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumGridItem(
|
||||
BuildContext context,
|
||||
_GroupedAlbum album,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _embeddedCoverVersion,
|
||||
builder: (context, _, child) {
|
||||
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
|
||||
album.sampleFilePath,
|
||||
);
|
||||
return _buildAlbumGridItemCore(
|
||||
context: context,
|
||||
albumName: album.albumName,
|
||||
artistName: album.artistName,
|
||||
trackCount: album.displayTrackCount,
|
||||
colorScheme: colorScheme,
|
||||
coverWidget: embeddedCoverPath != null
|
||||
? Image.file(
|
||||
File(embeddedCoverPath),
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
cacheWidth: 300,
|
||||
cacheHeight: 300,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_albumPlaceholder(colorScheme),
|
||||
)
|
||||
: album.coverUrl != null
|
||||
? CachedCoverImage(
|
||||
imageUrl: album.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
memCacheWidth: 300,
|
||||
memCacheHeight: 300,
|
||||
)
|
||||
: null,
|
||||
badgeColor: colorScheme.primaryContainer,
|
||||
badgeTextColor: colorScheme.onPrimaryContainer,
|
||||
badgeIcon: Icons.music_note,
|
||||
coverUrl: album.coverUrl,
|
||||
onTap: () => _navigateToDownloadedAlbum(album),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocalAlbumGridItem(
|
||||
BuildContext context,
|
||||
_GroupedLocalAlbum album,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return _buildAlbumGridItemCore(
|
||||
context: context,
|
||||
albumName: album.albumName,
|
||||
artistName: album.artistName,
|
||||
trackCount: album.displayTrackCount,
|
||||
colorScheme: colorScheme,
|
||||
coverWidget: album.coverPath != null
|
||||
? Image.file(
|
||||
File(album.coverPath!),
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
cacheWidth: 300,
|
||||
cacheHeight: 300,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_albumPlaceholder(colorScheme),
|
||||
)
|
||||
: null,
|
||||
badgeColor: colorScheme.tertiaryContainer,
|
||||
badgeTextColor: colorScheme.onTertiaryContainer,
|
||||
badgeIcon: Icons.folder,
|
||||
onTap: () => _navigateToLocalAlbum(album),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _albumPlaceholder(ColorScheme colorScheme) {
|
||||
return Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 48),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumGridItemCore({
|
||||
required BuildContext context,
|
||||
required String albumName,
|
||||
required String artistName,
|
||||
required int trackCount,
|
||||
required ColorScheme colorScheme,
|
||||
required Widget? coverWidget,
|
||||
required Color badgeColor,
|
||||
required Color badgeTextColor,
|
||||
required IconData badgeIcon,
|
||||
required VoidCallback onTap,
|
||||
String? coverUrl,
|
||||
}) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: context.l10n.a11yOpenAlbumByArtistTrackCount(
|
||||
albumName,
|
||||
artistName,
|
||||
trackCount,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: coverWidget ?? _albumPlaceholder(colorScheme),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(badgeIcon, size: 12, color: badgeTextColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$trackCount',
|
||||
style: TextStyle(
|
||||
color: badgeTextColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
albumName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
ClickableArtistName(
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasTextValue(String? value) => value != null && value.trim().isNotEmpty;
|
||||
|
||||
List<UnifiedLibraryItem> _selectedItemsFromAll(
|
||||
List<UnifiedLibraryItem> allItems,
|
||||
) {
|
||||
final itemsById = {for (final item in allItems) item.id: item};
|
||||
return _selectedIds
|
||||
.map((id) => itemsById[id])
|
||||
.whereType<UnifiedLibraryItem>()
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
bool _isLocalOnlySelection(List<UnifiedLibraryItem> allItems) {
|
||||
final selectedItems = _selectedItemsFromAll(allItems);
|
||||
return selectedItems.isNotEmpty &&
|
||||
selectedItems.every((item) => item.localItem != null);
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,585 @@
|
||||
part of 'queue_tab.dart';
|
||||
|
||||
extension _QueueTabNavigation on _QueueTabState {
|
||||
Future<void> _openFile(
|
||||
String filePath, {
|
||||
String title = '',
|
||||
String artist = '',
|
||||
String album = '',
|
||||
String coverUrl = '',
|
||||
}) async {
|
||||
final cleanPath = _cleanFilePath(filePath);
|
||||
try {
|
||||
final fallbackTitle = cleanPath.split('/').last.split('\\').last;
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playLocalPath(
|
||||
path: cleanPath,
|
||||
title: title.isNotEmpty ? title : fallbackTitle,
|
||||
artist: artist,
|
||||
album: album,
|
||||
coverUrl: coverUrl,
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays [item] and queues the rest of the merged library (downloaded + local
|
||||
/// in display order) so playback continues to the next track. Honors player
|
||||
/// mode and shuffle.
|
||||
Future<void> _playLibraryItem(
|
||||
UnifiedLibraryItem item,
|
||||
List<UnifiedLibraryItem> libraryItems,
|
||||
) async {
|
||||
final playableItems = libraryItems
|
||||
.where(
|
||||
(u) => u.filePath.trim().isNotEmpty && !isCueVirtualPath(u.filePath),
|
||||
)
|
||||
.toList();
|
||||
if (playableItems.isEmpty) return;
|
||||
|
||||
var start = playableItems.indexWhere((u) => u.id == item.id);
|
||||
if (start < 0) start = 0;
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playMediaQueue(
|
||||
playableItems.map(_toPlayableMedia),
|
||||
startIndex: start,
|
||||
externalPath: item.filePath,
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlayableMedia _toPlayableMedia(UnifiedLibraryItem item) {
|
||||
final history = item.historyItem;
|
||||
if (history != null) return playableFromHistory(history);
|
||||
final local = item.localItem;
|
||||
if (local != null) return playableFromLocal(local);
|
||||
|
||||
final cover = item.coverUrl ?? item.localCoverPath ?? '';
|
||||
String? art;
|
||||
if (cover.isNotEmpty) {
|
||||
art =
|
||||
(cover.startsWith('http') ||
|
||||
cover.startsWith('content://') ||
|
||||
cover.startsWith('file://'))
|
||||
? cover
|
||||
: Uri.file(cover).toString();
|
||||
}
|
||||
return PlayableMedia(
|
||||
id: item.id,
|
||||
source: item.filePath,
|
||||
title: item.trackName,
|
||||
artist: item.artistName,
|
||||
album: item.albumName,
|
||||
artUri: art,
|
||||
);
|
||||
}
|
||||
|
||||
void _precacheCover(String? url) {
|
||||
if (url == null || url.isEmpty) return;
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return;
|
||||
}
|
||||
final dpr = MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
).clamp(1.0, 3.0).toDouble();
|
||||
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||
precacheImage(
|
||||
ResizeImage(
|
||||
cachedCoverImageProvider(url),
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _navigateToMetadataScreen(DownloadItem item) async {
|
||||
final historyItem = ref
|
||||
.read(downloadHistoryProvider)
|
||||
.items
|
||||
.firstWhere(
|
||||
(h) => h.filePath == item.filePath,
|
||||
orElse: () => DownloadHistoryItem(
|
||||
id: item.id,
|
||||
trackName: item.track.name,
|
||||
artistName: item.track.artistName,
|
||||
albumName: item.track.albumName,
|
||||
coverUrl: item.track.coverUrl,
|
||||
filePath: item.filePath ?? '',
|
||||
downloadedAt: DateTime.now(),
|
||||
service: item.service,
|
||||
),
|
||||
);
|
||||
|
||||
final navigator = Navigator.of(context);
|
||||
_precacheCover(historyItem.coverUrl);
|
||||
_searchFocusNode.unfocus();
|
||||
final beforeModTime = await _readFileModTimeMillis(historyItem.filePath);
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
slidePageRoute<bool>(page: TrackMetadataScreen(item: historyItem)),
|
||||
);
|
||||
_searchFocusNode.unfocus();
|
||||
if (result == true) {
|
||||
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||
historyItem.filePath,
|
||||
beforeModTime: beforeModTime,
|
||||
force: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||
historyItem.filePath,
|
||||
beforeModTime: beforeModTime,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _navigateToHistoryMetadataScreen(
|
||||
DownloadHistoryItem item, {
|
||||
List<DownloadHistoryItem>? navigationItems,
|
||||
int? navigationIndex,
|
||||
}) async {
|
||||
final navigator = Navigator.of(context);
|
||||
_precacheCover(item.coverUrl);
|
||||
_searchFocusNode.unfocus();
|
||||
final beforeModTime = await _readFileModTimeMillis(item.filePath);
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
slidePageRoute<bool>(
|
||||
page: TrackMetadataScreen(
|
||||
item: item,
|
||||
historyNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
coverHeroTag: 'cover_lib_dl_${item.id}',
|
||||
),
|
||||
),
|
||||
);
|
||||
_searchFocusNode.unfocus();
|
||||
if (result == true) {
|
||||
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||
item.filePath,
|
||||
beforeModTime: beforeModTime,
|
||||
force: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||
item.filePath,
|
||||
beforeModTime: beforeModTime,
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToLocalMetadataScreen(
|
||||
LocalLibraryItem item, {
|
||||
List<LocalLibraryItem>? navigationItems,
|
||||
int? navigationIndex,
|
||||
}) {
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.push(
|
||||
context,
|
||||
slidePageRoute<void>(
|
||||
page: TrackMetadataScreen(
|
||||
localItem: item,
|
||||
localNavigationItems: navigationItems,
|
||||
navigationIndex: navigationIndex,
|
||||
coverHeroTag: 'cover_lib_local_${item.id}',
|
||||
),
|
||||
),
|
||||
).then((_) => _searchFocusNode.unfocus());
|
||||
}
|
||||
|
||||
List<DownloadHistoryItem> _filterHistoryItems(
|
||||
List<DownloadHistoryItem> items,
|
||||
String filterMode,
|
||||
Map<String, int> albumCounts, [
|
||||
String searchQuery = '',
|
||||
]) {
|
||||
var filteredItems = items;
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final query = searchQuery;
|
||||
filteredItems = items.where((item) {
|
||||
final searchKey = _historySearchKeyForItem(item);
|
||||
return searchKey.contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
if (filterMode == 'all') return filteredItems;
|
||||
|
||||
switch (filterMode) {
|
||||
case 'albums':
|
||||
return filteredItems.where((item) {
|
||||
final key =
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
return (albumCounts[key] ?? 0) > 1;
|
||||
}).toList();
|
||||
case 'singles':
|
||||
return filteredItems.where((item) {
|
||||
final key =
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
return (albumCounts[key] ?? 0) == 1;
|
||||
}).toList();
|
||||
default:
|
||||
return filteredItems;
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateWithUnfocus(Route<dynamic> route) {
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.of(context).push(route).then((_) => _searchFocusNode.unfocus());
|
||||
}
|
||||
|
||||
void _navigateToDownloadedAlbum(_GroupedAlbum album) {
|
||||
_navigateWithUnfocus(
|
||||
slidePageRoute(
|
||||
page: DownloadedAlbumScreen(
|
||||
albumName: album.albumName,
|
||||
artistName: album.artistName,
|
||||
coverUrl: album.coverUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _navigateToLocalAlbum(_GroupedLocalAlbum album) async {
|
||||
var tracks = album.tracks;
|
||||
if (tracks.isEmpty && album.displayTrackCount > 0) {
|
||||
var rows = album.albumKey.isNotEmpty
|
||||
? await LibraryDatabase.instance.getQueueLocalAlbumTracksByKey(
|
||||
album.albumKey,
|
||||
)
|
||||
: await LibraryDatabase.instance.getQueueLocalAlbumTracks(
|
||||
album.albumName,
|
||||
album.artistName,
|
||||
);
|
||||
if (rows.isEmpty && album.albumKey.isNotEmpty) {
|
||||
rows = await LibraryDatabase.instance.getQueueLocalAlbumTracks(
|
||||
album.albumName,
|
||||
album.artistName,
|
||||
);
|
||||
}
|
||||
tracks = rows.map(LocalLibraryItem.fromJson).toList(growable: false);
|
||||
if (!mounted) return;
|
||||
}
|
||||
_navigateWithUnfocus(
|
||||
slidePageRoute(
|
||||
page: LocalAlbumScreen(
|
||||
albumName: album.albumName,
|
||||
artistName: album.artistName,
|
||||
coverPath: album.coverPath,
|
||||
tracks: tracks,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openWishlistFolder() {
|
||||
_navigateWithUnfocus(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LibraryTracksFolderScreen(
|
||||
mode: LibraryTracksFolderMode.wishlist,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openLovedFolder() {
|
||||
_navigateWithUnfocus(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LibraryTracksFolderScreen(
|
||||
mode: LibraryTracksFolderMode.loved,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openFavoriteArtistsFolder() {
|
||||
_navigateWithUnfocus(
|
||||
MaterialPageRoute(builder: (_) => const FavoriteArtistsScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
void _openPlaylistById(String playlistId) {
|
||||
_navigateWithUnfocus(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LibraryTracksFolderScreen(
|
||||
mode: LibraryTracksFolderMode.playlist,
|
||||
playlistId: playlistId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCreatePlaylistDialog(BuildContext context) async {
|
||||
final controller = TextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
final playlistName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(dialogContext.l10n.collectionCreatePlaylist),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: dialogContext.l10n.collectionPlaylistNameHint,
|
||||
),
|
||||
validator: (value) {
|
||||
final trimmed = value?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return dialogContext.l10n.collectionPlaylistNameRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) {
|
||||
if (formKey.currentState?.validate() != true) return;
|
||||
Navigator.of(dialogContext).pop(controller.text.trim());
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text(dialogContext.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (formKey.currentState?.validate() != true) return;
|
||||
Navigator.of(dialogContext).pop(controller.text.trim());
|
||||
},
|
||||
child: Text(dialogContext.l10n.actionCreate),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (playlistName == null || playlistName.isEmpty) return;
|
||||
await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.createPlaylist(playlistName);
|
||||
}
|
||||
|
||||
/// Pass a finite [size] (e.g. 56) for list view, or `null` for grid view
|
||||
/// where the widget should expand to fill its parent.
|
||||
Widget _buildPlaylistCover(
|
||||
BuildContext context,
|
||||
UserPlaylistCollection playlist,
|
||||
ColorScheme colorScheme, [
|
||||
double? size,
|
||||
]) {
|
||||
final borderRadius = BorderRadius.circular(8);
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final cacheExtent = size != null
|
||||
? (size * dpr).round().clamp(64, 1024)
|
||||
: 420;
|
||||
final placeholder = _playlistIconFallback(colorScheme, size);
|
||||
|
||||
final customCoverPath = playlist.coverImagePath;
|
||||
if (customCoverPath != null && customCoverPath.isNotEmpty) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: Image.file(
|
||||
File(customCoverPath),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheExtent,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return placeholder;
|
||||
},
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final firstCoverUrl = playlist.tracks
|
||||
.where((e) => e.track.coverUrl != null && e.track.coverUrl!.isNotEmpty)
|
||||
.map((e) => e.track.coverUrl!)
|
||||
.firstOrNull;
|
||||
|
||||
if (firstCoverUrl != null) {
|
||||
// Guard against local file paths that may have been stored as coverUrl
|
||||
final isLocalPath =
|
||||
!firstCoverUrl.startsWith('http://') &&
|
||||
!firstCoverUrl.startsWith('https://');
|
||||
if (isLocalPath) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: Image.file(
|
||||
File(firstCoverUrl),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheExtent,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return placeholder;
|
||||
},
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
return CachedCoverImage(
|
||||
imageUrl: firstCoverUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
memCacheWidth: cacheExtent,
|
||||
borderRadius: borderRadius,
|
||||
placeholder: (_, _) => placeholder,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
/// Icon fallback for playlists with no cover.
|
||||
/// When [size] is null the container expands to fill its parent (grid view)
|
||||
/// and uses a fixed icon size.
|
||||
Widget _playlistIconFallback(ColorScheme colorScheme, [double? size]) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF5085A5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.queue_music,
|
||||
color: Colors.white,
|
||||
size: size != null ? size * 0.5 : 40,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle a track being dropped onto a playlist.
|
||||
/// When selection mode is active and the dragged item is among the selected,
|
||||
/// all selected tracks are added to the playlist.
|
||||
Future<void> _onTrackDroppedOnPlaylist(
|
||||
BuildContext context,
|
||||
UnifiedLibraryItem item,
|
||||
String playlistId,
|
||||
String playlistName, {
|
||||
List<UnifiedLibraryItem> allItems = const [],
|
||||
}) async {
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
|
||||
if (_isSelectionMode &&
|
||||
_selectedIds.isNotEmpty &&
|
||||
_selectedIds.contains(item.id)) {
|
||||
final selectedItems = allItems
|
||||
.where((e) => _selectedIds.contains(e.id))
|
||||
.toList();
|
||||
if (selectedItems.isEmpty) {
|
||||
selectedItems.add(item);
|
||||
}
|
||||
|
||||
final batchResult = await notifier.addTracksToPlaylist(
|
||||
playlistId,
|
||||
selectedItems.map((selected) => selected.toTrack()),
|
||||
);
|
||||
final addedCount = batchResult.addedCount;
|
||||
final alreadyCount = batchResult.alreadyInPlaylistCount;
|
||||
|
||||
if (!context.mounted) return;
|
||||
final message = addedCount > 0
|
||||
? alreadyCount > 0
|
||||
? context.l10n.collectionAddedTracksToPlaylistWithExisting(
|
||||
addedCount,
|
||||
playlistName,
|
||||
alreadyCount,
|
||||
)
|
||||
: context.l10n.collectionAddedTracksToPlaylist(
|
||||
addedCount,
|
||||
playlistName,
|
||||
)
|
||||
: context.l10n.collectionAlreadyInPlaylist(playlistName);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
_exitSelectionMode();
|
||||
return;
|
||||
}
|
||||
|
||||
final track = item.toTrack();
|
||||
final added = await notifier.addTrackToPlaylist(playlistId, track);
|
||||
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
added
|
||||
? context.l10n.collectionAddedToPlaylist(playlistName)
|
||||
: context.l10n.collectionAlreadyInPlaylist(playlistName),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDragFeedback(
|
||||
BuildContext context,
|
||||
UnifiedLibraryItem item,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final isDraggingMultiple =
|
||||
_isSelectionMode &&
|
||||
_selectedIds.contains(item.id) &&
|
||||
_selectedIds.length > 1;
|
||||
final count = isDraggingMultiple ? _selectedIds.length : 1;
|
||||
|
||||
return Material(
|
||||
elevation: 6,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.playlist_add, size: 18, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 180),
|
||||
child: Text(
|
||||
isDraggingMultiple ? '$count tracks' : item.trackName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
part of 'queue_tab.dart';
|
||||
|
||||
extension _QueueTabSelectionActions on _QueueTabState {
|
||||
void _enterSelectionMode(String itemId) {
|
||||
HapticFeedback.mediumImpact();
|
||||
_setState(() {
|
||||
_isPlaylistSelectionMode = false;
|
||||
_selectedPlaylistIds.clear();
|
||||
_isSelectionMode = true;
|
||||
_selectedIds.add(itemId);
|
||||
});
|
||||
_hidePlaylistSelectionOverlay();
|
||||
}
|
||||
|
||||
void _exitSelectionMode() {
|
||||
_setState(() {
|
||||
_isSelectionMode = false;
|
||||
_selectedIds.clear();
|
||||
});
|
||||
_hideSelectionOverlay();
|
||||
}
|
||||
|
||||
void _toggleSelection(String itemId) {
|
||||
var shouldHideOverlay = false;
|
||||
_setState(() {
|
||||
if (_selectedIds.contains(itemId)) {
|
||||
_selectedIds.remove(itemId);
|
||||
if (_selectedIds.isEmpty) {
|
||||
_isSelectionMode = false;
|
||||
shouldHideOverlay = true;
|
||||
}
|
||||
} else {
|
||||
_selectedIds.add(itemId);
|
||||
}
|
||||
});
|
||||
if (shouldHideOverlay) {
|
||||
_hideSelectionOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
void _selectAll(List<UnifiedLibraryItem> items) {
|
||||
_setState(() {
|
||||
_selectedIds.addAll(items.map((e) => e.id));
|
||||
});
|
||||
}
|
||||
|
||||
void _hideSelectionOverlay() {
|
||||
_selectionOverlayEntry?.remove();
|
||||
_selectionOverlayEntry = null;
|
||||
}
|
||||
|
||||
void _syncSelectionOverlay({
|
||||
required List<UnifiedLibraryItem> items,
|
||||
required double bottomPadding,
|
||||
}) {
|
||||
if (!mounted) return;
|
||||
if (_suppressSelectionOverlay ||
|
||||
!_isSelectionMode ||
|
||||
_isPlaylistSelectionMode) {
|
||||
_hideSelectionOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
_selectionOverlayItems = items;
|
||||
_selectionOverlayBottomPadding = bottomPadding;
|
||||
|
||||
if (_selectionOverlayEntry != null) {
|
||||
_selectionOverlayEntry!.markNeedsBuild();
|
||||
return;
|
||||
}
|
||||
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
_selectionOverlayEntry = OverlayEntry(
|
||||
builder: (overlayContext) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _AnimatedOverlayBottomBar(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: _buildSelectionBottomBar(
|
||||
context,
|
||||
colorScheme,
|
||||
_selectionOverlayItems,
|
||||
_selectionOverlayBottomPadding,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
overlay.insert(_selectionOverlayEntry!);
|
||||
}
|
||||
|
||||
void _hidePlaylistSelectionOverlay() {
|
||||
_playlistSelectionOverlayEntry?.remove();
|
||||
_playlistSelectionOverlayEntry = null;
|
||||
}
|
||||
|
||||
void _syncPlaylistSelectionOverlay({
|
||||
required List<UserPlaylistCollection> playlists,
|
||||
required double bottomPadding,
|
||||
}) {
|
||||
if (!mounted) return;
|
||||
if (_suppressSelectionOverlay ||
|
||||
!_isPlaylistSelectionMode ||
|
||||
_isSelectionMode) {
|
||||
_hidePlaylistSelectionOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
_playlistSelectionOverlayItems = playlists;
|
||||
_playlistSelectionOverlayBottomPadding = bottomPadding;
|
||||
|
||||
if (_playlistSelectionOverlayEntry != null) {
|
||||
_playlistSelectionOverlayEntry!.markNeedsBuild();
|
||||
return;
|
||||
}
|
||||
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
_playlistSelectionOverlayEntry = OverlayEntry(
|
||||
builder: (overlayContext) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _AnimatedOverlayBottomBar(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: _buildPlaylistSelectionBottomBar(
|
||||
context,
|
||||
colorScheme,
|
||||
_playlistSelectionOverlayItems,
|
||||
_playlistSelectionOverlayBottomPadding,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
overlay.insert(_playlistSelectionOverlayEntry!);
|
||||
}
|
||||
|
||||
void _enterPlaylistSelectionMode(String playlistId) {
|
||||
HapticFeedback.mediumImpact();
|
||||
_setState(() {
|
||||
_isSelectionMode = false;
|
||||
_selectedIds.clear();
|
||||
_isPlaylistSelectionMode = true;
|
||||
_selectedPlaylistIds.add(playlistId);
|
||||
});
|
||||
_hideSelectionOverlay();
|
||||
}
|
||||
|
||||
void _exitPlaylistSelectionMode() {
|
||||
_setState(() {
|
||||
_isPlaylistSelectionMode = false;
|
||||
_selectedPlaylistIds.clear();
|
||||
});
|
||||
_hidePlaylistSelectionOverlay();
|
||||
}
|
||||
|
||||
void _togglePlaylistSelection(String playlistId) {
|
||||
var shouldHideOverlay = false;
|
||||
_setState(() {
|
||||
if (_selectedPlaylistIds.contains(playlistId)) {
|
||||
_selectedPlaylistIds.remove(playlistId);
|
||||
if (_selectedPlaylistIds.isEmpty) {
|
||||
_isPlaylistSelectionMode = false;
|
||||
shouldHideOverlay = true;
|
||||
}
|
||||
} else {
|
||||
_selectedPlaylistIds.add(playlistId);
|
||||
}
|
||||
});
|
||||
if (shouldHideOverlay) {
|
||||
_hidePlaylistSelectionOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
void _selectAllPlaylists(List<UserPlaylistCollection> playlists) {
|
||||
_setState(() {
|
||||
_selectedPlaylistIds.addAll(playlists.map((e) => e.id));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _downloadAllSelectedPlaylists(BuildContext context) async {
|
||||
final collectionsState = ref.read(libraryCollectionsProvider);
|
||||
final selectedPlaylists = collectionsState.playlists
|
||||
.where((p) => _selectedPlaylistIds.contains(p.id))
|
||||
.toList();
|
||||
|
||||
final totalTracks = selectedPlaylists.fold<int>(
|
||||
0,
|
||||
(sum, p) => sum + p.tracks.length,
|
||||
);
|
||||
|
||||
if (totalTracks == 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarSelectedPlaylistsEmpty)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.dialogDownloadAllTitle),
|
||||
content: Text(
|
||||
ctx.l10n.dialogDownloadPlaylistsMessage(
|
||||
totalTracks,
|
||||
selectedPlaylists.length,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(ctx.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(ctx.l10n.dialogDownload),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final queueNotifier = ref.read(downloadQueueProvider.notifier);
|
||||
|
||||
void enqueueAll({String? qualityOverride, String? service}) {
|
||||
final svc =
|
||||
service ??
|
||||
resolveEffectiveDownloadService(
|
||||
settings.defaultService,
|
||||
extensionState,
|
||||
);
|
||||
if (svc.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (final playlist in selectedPlaylists) {
|
||||
final tracks = playlist.tracks.map((e) => e.track).toList();
|
||||
queueNotifier.addMultipleToQueue(
|
||||
tracks,
|
||||
svc,
|
||||
qualityOverride: qualityOverride,
|
||||
playlistName: playlist.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: context.l10n.tracksCount(totalTracks),
|
||||
artistName: context.l10n.playlistsCount(selectedPlaylists.length),
|
||||
onSelect: (quality, service) {
|
||||
enqueueAll(qualityOverride: quality, service: service);
|
||||
if (!mounted) return;
|
||||
_exitPlaylistSelectionMode();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(totalTracks),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
enqueueAll();
|
||||
_exitPlaylistSelectionMode();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(totalTracks)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteSelectedPlaylists(BuildContext context) async {
|
||||
final count = _selectedPlaylistIds.length;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.collectionDeletePlaylist),
|
||||
content: Text(ctx.l10n.collectionDeletePlaylistsMessage(count)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(ctx.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(ctx.l10n.dialogDelete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
for (final id in _selectedPlaylistIds.toList()) {
|
||||
await notifier.deletePlaylist(id);
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
_exitPlaylistSelectionMode();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.collectionPlaylistsDeleted(count))),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaylistSelectionBottomBar(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
List<UserPlaylistCollection> playlists,
|
||||
double bottomPadding,
|
||||
) {
|
||||
final selectedCount = _selectedPlaylistIds.length;
|
||||
final allSelected =
|
||||
selectedCount == playlists.length && playlists.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, -4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
onPressed: _exitPlaylistSelectionMode,
|
||||
tooltip: MaterialLocalizations.of(
|
||||
context,
|
||||
).closeButtonTooltip,
|
||||
icon: const Icon(Icons.close),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.selectionSelected(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
allSelected
|
||||
? context.l10n.selectionAllPlaylistsSelected
|
||||
: context.l10n.selectionTapPlaylistsToSelect,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
if (allSelected) {
|
||||
_exitPlaylistSelectionMode();
|
||||
} else {
|
||||
_selectAllPlaylists(playlists);
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
allSelected ? Icons.deselect : Icons.select_all,
|
||||
size: 20,
|
||||
),
|
||||
label: Text(
|
||||
allSelected
|
||||
? context.l10n.actionDeselect
|
||||
: context.l10n.actionSelectAll,
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: selectedCount > 0
|
||||
? () => _downloadAllSelectedPlaylists(context)
|
||||
: null,
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
label: Text(
|
||||
selectedCount > 0
|
||||
? context.l10n.bulkDownloadPlaylistsButton(
|
||||
selectedCount,
|
||||
)
|
||||
: context.l10n.bulkDownloadSelectPlaylists,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: selectedCount > 0
|
||||
? colorScheme.primary
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: selectedCount > 0
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: selectedCount > 0
|
||||
? () => _deleteSelectedPlaylists(context)
|
||||
: null,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
selectedCount > 0
|
||||
? context.l10n.selectionDeletePlaylistsCount(
|
||||
selectedCount,
|
||||
)
|
||||
: context.l10n.selectionSelectPlaylistsToDelete,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: selectedCount > 0
|
||||
? colorScheme.error
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: selectedCount > 0
|
||||
? colorScheme.onError
|
||||
: colorScheme.onSurfaceVariant,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getQualityBadgeText(String quality) {
|
||||
final q = quality.trim().toLowerCase();
|
||||
if (q.contains('bit')) {
|
||||
return quality.split('/').first;
|
||||
}
|
||||
|
||||
final bitrateTextMatch = RegExp(
|
||||
r'(\d+)\s*k(?:bps)?',
|
||||
caseSensitive: false,
|
||||
).firstMatch(quality);
|
||||
if (bitrateTextMatch != null) {
|
||||
return '${bitrateTextMatch.group(1)}k';
|
||||
}
|
||||
|
||||
final bitrateIdMatch = RegExp(r'_(\d+)$').firstMatch(q);
|
||||
if (bitrateIdMatch != null) {
|
||||
return '${bitrateIdMatch.group(1)}k';
|
||||
}
|
||||
|
||||
return quality.split(' ').first;
|
||||
}
|
||||
|
||||
Future<void> _deleteSelected(List<UnifiedLibraryItem> allItems) async {
|
||||
final count = _selectedIds.length;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.dialogDeleteSelectedTitle),
|
||||
content: Text(context.l10n.dialogDeleteSelectedMessage(count)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(context.l10n.dialogDelete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
final localLibraryDb = LibraryDatabase.instance;
|
||||
final itemsById = {for (final item in allItems) item.id: item};
|
||||
|
||||
int deletedCount = 0;
|
||||
for (final id in _selectedIds) {
|
||||
final item = itemsById[id];
|
||||
if (item != null) {
|
||||
try {
|
||||
final cleanPath = _cleanFilePath(item.filePath);
|
||||
await deleteFile(cleanPath);
|
||||
} catch (_) {}
|
||||
|
||||
if (item.source == LibraryItemSource.downloaded) {
|
||||
historyNotifier.removeFromHistory(item.historyItem!.id);
|
||||
} else {
|
||||
await localLibraryDb.deleteByPath(item.filePath);
|
||||
}
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (allItems.any(
|
||||
(i) =>
|
||||
_selectedIds.contains(i.id) && i.source == LibraryItemSource.local,
|
||||
)) {
|
||||
ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||
}
|
||||
|
||||
_exitSelectionMode();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _cleanFilePath(String? filePath) {
|
||||
return DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
|
||||
}
|
||||
|
||||
Future<int?> _readFileModTimeMillis(String? filePath) async {
|
||||
return DownloadedEmbeddedCoverResolver.readFileModTimeMillis(filePath);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user