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:
zarzet
2026-07-10 10:15:56 +07:00
parent 2e7e2b1964
commit 860649e3ac
7 changed files with 5150 additions and 5116 deletions
+10 -5116
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+650
View File
@@ -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),
),
),
],
),
);
},
);
}
}
}
+936
View File
@@ -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
+585
View File
@@ -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),
),
),
],
),
),
);
}
}
+589
View File
@@ -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);
}
}