From 860649e3ac98fb2e2cd65e4d8e7a6d73225e7bb3 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 10 Jul 2026 10:15:56 +0700 Subject: [PATCH] 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 --- lib/screens/queue_tab.dart | 5126 +------------------ lib/screens/queue_tab_batch_actions.dart | 1111 ++++ lib/screens/queue_tab_collection_items.dart | 650 +++ lib/screens/queue_tab_filter_widgets.dart | 936 ++++ lib/screens/queue_tab_item_widgets.dart | 1269 +++++ lib/screens/queue_tab_navigation.dart | 585 +++ lib/screens/queue_tab_selection.dart | 589 +++ 7 files changed, 5150 insertions(+), 5116 deletions(-) create mode 100644 lib/screens/queue_tab_batch_actions.dart create mode 100644 lib/screens/queue_tab_collection_items.dart create mode 100644 lib/screens/queue_tab_filter_widgets.dart create mode 100644 lib/screens/queue_tab_item_widgets.dart create mode 100644 lib/screens/queue_tab_navigation.dart create mode 100644 lib/screens/queue_tab_selection.dart diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index efd6deed..c21caa78 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -50,6 +50,12 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; part 'queue_tab_helpers.dart'; part 'queue_tab_widgets.dart'; +part 'queue_tab_selection.dart'; +part 'queue_tab_navigation.dart'; +part 'queue_tab_collection_items.dart'; +part 'queue_tab_filter_widgets.dart'; +part 'queue_tab_batch_actions.dart'; +part 'queue_tab_item_widgets.dart'; String _formatDownloadSizeMB(num bytes) { return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; @@ -254,6 +260,10 @@ class _QueueTabState extends ConsumerState { double get _libraryAlbumGridExtent => (_libraryGridExtent * 1.45).clamp(150.0, 300.0); + /// setState is @protected, so the extension part files route rebuilds + /// through this forwarder. + void _setState(VoidCallback fn) => setState(fn); + void _handleLibraryGridScaleStart(ScaleStartDetails details) { if (details.pointerCount < 2) return; _libraryGridScaleStartExtent = _libraryGridExtent; @@ -899,591 +909,6 @@ class _QueueTabState extends ConsumerState { ); } - 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 items) { - setState(() { - _selectedIds.addAll(items.map((e) => e.id)); - }); - } - - void _hideSelectionOverlay() { - _selectionOverlayEntry?.remove(); - _selectionOverlayEntry = null; - } - - void _syncSelectionOverlay({ - required List 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 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 playlists) { - setState(() { - _selectedPlaylistIds.addAll(playlists.map((e) => e.id)); - }); - } - - Future _downloadAllSelectedPlaylists(BuildContext context) async { - final collectionsState = ref.read(libraryCollectionsProvider); - final selectedPlaylists = collectionsState.playlists - .where((p) => _selectedPlaylistIds.contains(p.id)) - .toList(); - - final totalTracks = selectedPlaylists.fold( - 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( - 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 _deleteSelectedPlaylists(BuildContext context) async { - final count = _selectedPlaylistIds.length; - final confirmed = await showDialog( - 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 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 _deleteSelected(List allItems) async { - final count = _selectedIds.length; - final confirmed = await showDialog( - 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 _readFileModTimeMillis(String? filePath) async { - return DownloadedEmbeddedCoverResolver.readFileModTimeMillis(filePath); - } - void _onEmbeddedCoverChanged() { if (!mounted || _embeddedCoverRefreshScheduled) return; _embeddedCoverRefreshScheduled = true; @@ -2195,587 +1620,6 @@ class _QueueTabState extends ConsumerState { ); } - Future _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 _playLibraryItem( - UnifiedLibraryItem item, - List 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 _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(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 _navigateToHistoryMetadataScreen( - DownloadHistoryItem item, { - List? 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( - 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? navigationItems, - int? navigationIndex, - }) { - _searchFocusNode.unfocus(); - Navigator.push( - context, - slidePageRoute( - page: TrackMetadataScreen( - localItem: item, - localNavigationItems: navigationItems, - navigationIndex: navigationIndex, - coverHeroTag: 'cover_lib_local_${item.id}', - ), - ), - ).then((_) => _searchFocusNode.unfocus()); - } - - List _filterHistoryItems( - List items, - String filterMode, - Map 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 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 _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 _showCreatePlaylistDialog(BuildContext context) async { - final controller = TextEditingController(); - final formKey = GlobalKey(); - - final playlistName = await showDialog( - 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 _onTrackDroppedOnPlaylist( - BuildContext context, - UnifiedLibraryItem item, - String playlistId, - String playlistName, { - List 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), - ), - ), - ], - ), - ), - ); - } - @override Widget build(BuildContext context) { _initializePageController(); @@ -3537,3956 +2381,6 @@ class _QueueTabState extends ConsumerState { return false; } - 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 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( - 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 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( - 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), - ), - ), - ], - ), - ); - }, - ); - } - } - - 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 [] - : ref - .watch( - downloadQueueLookupProvider.select((lookup) { - final ids = []; - 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 = { - for (final item in allFilteredUnifiedItems) item.id, - }; - List bridgeIds = const []; - if (filterMode != 'albums' && _completionBridge.isNotEmpty) { - final now = DateTime.now(); - final stale = []; - final pending = []; - 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 filteredUnifiedItems = allFilteredUnifiedItems; - if (bridgeIds.isNotEmpty) { - final pinnedHistoryIds = {for (final id in bridgeIds) 'dl_$id'}; - filteredUnifiedItems = allFilteredUnifiedItems - .where((item) => !pinnedHistoryIds.contains(item.id)) - .toList(growable: false); - } - - final downloadedNavigationItems = []; - final downloadedNavigationIndexByUnifiedId = {}; - final localNavigationItems = []; - final localNavigationIndexByUnifiedId = {}; - - 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( - 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( - 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( - 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 _showClearAllDialog( - BuildContext context, - WidgetRef ref, - ColorScheme colorScheme, - ) async { - final confirmed = await showDialog( - 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( - 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 _selectedItemsFromAll( - List allItems, - ) { - final itemsById = {for (final item in allItems) item.id: item}; - return _selectedIds - .map((id) => itemsById[id]) - .whereType() - .toList(growable: false); - } - - bool _isLocalOnlySelection(List allItems) { - final selectedItems = _selectedItemsFromAll(allItems); - return selectedItems.isNotEmpty && - selectedItems.every((item) => item.localItem != null); - } - - Future _safeDeleteTempFile(String path) async { - try { - final file = File(path); - if (await file.exists()) { - await file.delete(); - } - } catch (_) {} - } - - Future _cleanupTempFileAndParentDir(String path) async { - await _safeDeleteTempFile(path); - try { - final parent = File(path).parent; - if (await parent.exists()) { - await parent.delete(); - } - } catch (_) {} - } - - Future _applyQueueFfmpegReEnrichResult( - LocalLibraryItem item, - Map result, - ) async { - final tempPath = result['temp_path'] as String?; - final safUri = result['saf_uri'] as String?; - final ffmpegTarget = _hasTextValue(tempPath) ? tempPath! : item.filePath; - final downloadedCoverPath = result['cover_path'] as String?; - String? effectiveCoverPath = downloadedCoverPath; - String? extractedCoverPath; - - if (!_hasTextValue(effectiveCoverPath)) { - try { - final tempDir = await Directory.systemTemp.createTemp( - 'reenrich_cover_', - ); - final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; - final extracted = await PlatformBridge.extractCoverToFile( - ffmpegTarget, - coverOutput, - ); - if (extracted['error'] == null) { - effectiveCoverPath = coverOutput; - extractedCoverPath = coverOutput; - } else { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - } - } catch (_) {} - } - - final metadata = (result['metadata'] as Map?)?.map( - (k, v) => MapEntry(k, v.toString()), - ); - - final format = item.format?.toLowerCase(); - final lowerPath = item.filePath.toLowerCase(); - final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); - final isM4A = - format == 'm4a' || - format == 'aac' || - lowerPath.endsWith('.m4a') || - lowerPath.endsWith('.aac'); - final isOpus = - format == 'opus' || - format == 'ogg' || - lowerPath.endsWith('.opus') || - lowerPath.endsWith('.ogg'); - - final artistTagMode = ref.read(settingsProvider).artistTagMode; - String? ffmpegResult; - if (isMp3) { - ffmpegResult = await FFmpegService.embedMetadataToMp3( - mp3Path: ffmpegTarget, - coverPath: effectiveCoverPath, - metadata: metadata, - preserveMetadata: true, - ); - } else if (isM4A) { - ffmpegResult = await FFmpegService.embedMetadataToM4a( - m4aPath: ffmpegTarget, - coverPath: effectiveCoverPath, - metadata: metadata, - preserveMetadata: true, - ); - } else if (isOpus) { - ffmpegResult = await FFmpegService.embedMetadataToOpus( - opusPath: ffmpegTarget, - coverPath: effectiveCoverPath, - metadata: metadata, - artistTagMode: artistTagMode, - preserveMetadata: true, - ); - } - - if (ffmpegResult != null && - _hasTextValue(tempPath) && - _hasTextValue(safUri)) { - final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!); - if (!ok) { - if (_hasTextValue(downloadedCoverPath)) { - await _safeDeleteTempFile(downloadedCoverPath!); - } - if (_hasTextValue(extractedCoverPath)) { - await _cleanupTempFileAndParentDir(extractedCoverPath!); - } - await _safeDeleteTempFile(tempPath!); - return false; - } - await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result); - } - - if (_hasTextValue(downloadedCoverPath)) { - await _safeDeleteTempFile(downloadedCoverPath!); - } - if (_hasTextValue(extractedCoverPath)) { - await _cleanupTempFileAndParentDir(extractedCoverPath!); - } - if (_hasTextValue(tempPath)) { - await _safeDeleteTempFile(tempPath!); - } - - if (ffmpegResult != null) { - // Filesystem .lrc sidecar. SAF sidecar is written only after - // writeTempToSaf succeeds. - await writeReEnrichSidecarLrc( - audioFilePath: item.filePath, - reEnrichResult: result, - ); - } - - return ffmpegResult != null; - } - - Future _reEnrichQueueLocalTrack( - LocalLibraryItem item, { - List? updateFields, - }) async { - final durationMs = (item.duration ?? 0) * 1000; - final settings = ref.read(settingsProvider); - final artistTagMode = settings.artistTagMode; - await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); - final request = { - 'file_path': item.filePath, - 'cover_url': '', - 'max_quality': true, - 'embed_lyrics': settings.embedLyrics, - 'lyrics_mode': settings.lyricsMode, - 'artist_tag_mode': artistTagMode, - 'spotify_id': '', - 'track_name': item.trackName, - 'artist_name': item.artistName, - 'album_name': item.albumName, - 'album_artist': item.albumArtist ?? '', - 'track_number': item.trackNumber ?? 0, - 'disc_number': item.discNumber ?? 0, - 'release_date': item.releaseDate ?? '', - 'isrc': item.isrc ?? '', - 'genre': item.genre ?? '', - 'label': '', - 'copyright': '', - 'duration_ms': durationMs, - 'search_online': true, - // ignore: use_null_aware_elements - if (updateFields != null) 'update_fields': updateFields, - }; - - final result = await PlatformBridge.reEnrichFile(request); - final method = result['method'] as String?; - if (method == 'native') { - // Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin). - await writeReEnrichSidecarLrc( - audioFilePath: item.filePath, - reEnrichResult: result, - ); - return true; - } - if (method == 'ffmpeg') { - return _applyQueueFfmpegReEnrichResult(item, result); - } - return false; - } - - List _selectedFlacEligibleLocalItems( - List allItems, - ) { - final selectedItems = _selectedItemsFromAll(allItems); - return selectedItems - .map((item) => item.localItem) - .whereType() - .where(LocalTrackRedownloadService.isFlacUpgradeEligible) - .toList(growable: false); - } - - Future _queueSelectedLocalAsFlac( - List allItems, - ) async { - final selectedLocalItems = _selectedFlacEligibleLocalItems(allItems); - - if (selectedLocalItems.isEmpty) { - return; - } - - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.queueFlacAction), - content: Text( - context.l10n.queueFlacConfirmMessage(selectedLocalItems.length), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(context.l10n.queueFlacAction), - ), - ], - ), - ); - - if (confirmed != true || !mounted) { - return; - } - - final settings = ref.read(settingsProvider); - final extensionState = ref.read(extensionProvider); - final includeExtensions = - settings.useExtensionProviders && - extensionState.extensions.any( - (ext) => ext.enabled && ext.hasMetadataProvider, - ); - final targetService = LocalTrackRedownloadService.preferredFlacService( - settings, - extensionState, - ); - if (targetService.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), - ); - return; - } - final targetQuality = - LocalTrackRedownloadService.preferredFlacQualityForService( - targetService, - extensionState, - ); - - final matchedTracks = []; - var skippedCount = 0; - final total = selectedLocalItems.length; - - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.queueFlacAction, - total: total, - icon: Icons.queue_music, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (var i = 0; i < total; i++) { - if (!mounted || cancelled) break; - - BatchProgressDialog.update( - current: i + 1, - detail: selectedLocalItems[i].trackName, - ); - - try { - final resolution = await LocalTrackRedownloadService.resolveBestMatch( - selectedLocalItems[i], - includeExtensions: includeExtensions, - ); - if (resolution.canQueue && resolution.match != null) { - matchedTracks.add(resolution.match!); - } else { - skippedCount++; - } - } catch (_) { - skippedCount++; - } - } - - if (!mounted) { - return; - } - - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - - if (matchedTracks.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)), - ); - return; - } - - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - matchedTracks, - targetService, - qualityOverride: targetQuality, - ); - - final summary = skippedCount == 0 - ? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length) - : context.l10n.queueFlacQueuedWithSkipped( - matchedTracks.length, - skippedCount, - ); - - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(summary))); - setState(() { - _selectedIds.clear(); - _isSelectionMode = false; - }); - } - - Future _reEnrichSelectedLocalFromQueue( - List allItems, - ) async { - final selectedItems = _selectedItemsFromAll(allItems); - final selectedLocalItems = selectedItems - .map((item) => item.localItem) - .whereType() - .toList(growable: false); - - if (selectedLocalItems.isEmpty) { - return; - } - - // Hide the selection overlay: set the flag (prevents build() from - // re-inserting via postFrameCallback) and remove the entry immediately. - setState(() => _isSelectionMode = false); - _hideSelectionOverlay(); - - final selection = await showReEnrichFieldDialog( - context, - selectedCount: selectedLocalItems.length, - ); - - if (selection == null || !mounted) { - // Cancelled — restore selection mode; the next build cycle will - // re-create the overlay via _syncSelectionOverlay in postFrameCallback. - if (mounted) setState(() => _isSelectionMode = true); - return; - } - - final updateFields = selection.isAll ? null : selection.fields; - - var successCount = 0; - final total = selectedLocalItems.length; - - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.trackReEnrichProgress, - total: total, - icon: Icons.auto_fix_high, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (var i = 0; i < total; i++) { - if (!mounted || cancelled) break; - final item = selectedLocalItems[i]; - - BatchProgressDialog.update( - current: i + 1, - detail: '${item.trackName} - ${item.artistName}', - ); - - try { - final ok = await _reEnrichQueueLocalTrack( - item, - updateFields: updateFields, - ); - if (ok) { - successCount++; - } - } catch (_) {} - } - - if (!mounted) { - return; - } - - final settings = ref.read(settingsProvider); - final localLibraryPath = settings.localLibraryPath.trim(); - final iosBookmark = settings.localLibraryBookmark; - try { - if (localLibraryPath.isNotEmpty && - !ref.read(localLibraryProvider).isScanning) { - await ref - .read(localLibraryProvider.notifier) - .startScan( - localLibraryPath, - iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, - ); - } else { - await ref.read(localLibraryProvider.notifier).reloadFromStorage(); - } - } catch (_) { - await ref.read(localLibraryProvider.notifier).reloadFromStorage(); - } - - _exitSelectionMode(); - - if (!mounted) { - return; - } - - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - ScaffoldMessenger.of(context).clearSnackBars(); - final failedCount = total - successCount; - final summary = failedCount <= 0 - ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' - : context.l10n.trackReEnrichSuccessWithFailures( - successCount, - total, - failedCount, - ); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(summary))); - } - - /// Share selected tracks via system share sheet - Future _shareSelected(List allItems) async { - final itemsById = {for (final item in allItems) item.id: item}; - final safUris = []; - final filesToShare = []; - - for (final id in _selectedIds) { - final item = itemsById[id]; - if (item == null) continue; - final path = item.filePath; - if (isContentUri(path)) { - if (await fileExists(path)) safUris.add(path); - } else if (await fileExists(path)) { - filesToShare.add(XFile(path)); - } - } - - if (safUris.isEmpty && filesToShare.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.selectionShareNoFiles)), - ); - } - return; - } - - if (safUris.isNotEmpty) { - try { - if (safUris.length == 1) { - await PlatformBridge.shareContentUri(safUris.first); - } else { - await PlatformBridge.shareMultipleContentUris(safUris); - } - } catch (_) {} - } - - if (filesToShare.isNotEmpty) { - await SharePlus.instance.share(ShareParams(files: filesToShare)); - } - } - - Future _showBatchConvertSheet( - BuildContext context, - List allItems, - ) async { - final itemsById = {for (final item in allItems) item.id: item}; - final sourceFormats = {}; - final sourceBitDepths = []; - final sourceSampleRates = []; - for (final id in _selectedIds) { - final item = itemsById[id]; - if (item == null) continue; - final sourceFormat = convertibleAudioSourceFormat( - storedFormat: item.localItem?.format ?? item.historyItem?.format, - filePath: item.filePath, - fileName: item.historyItem?.safFileName, - ); - if (sourceFormat != null) sourceFormats.add(sourceFormat); - sourceBitDepths.add( - item.historyItem?.bitDepth ?? item.localItem?.bitDepth, - ); - sourceSampleRates.add( - item.historyItem?.sampleRate ?? item.localItem?.sampleRate, - ); - } - - final formats = audioConversionTargetFormats - .where( - (target) => sourceFormats.any( - (source) => canConvertAudioFormat( - sourceFormat: source, - targetFormat: target, - ), - ), - ) - .toList(); - - if (formats.isEmpty) return; - - var didStartConversion = false; - - // Resolve localized strings up front; the builder must not look up - // Localizations via the (possibly deactivated) State context. - final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle; - final sheetConfirmLabel = context.l10n.selectionConvertCount( - _selectedIds.length, - ); - - _suppressSelectionOverlay = true; - _hideSelectionOverlay(); - _hidePlaylistSelectionOverlay(); - - await showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (sheetContext) => BatchConvertSheet( - formats: formats, - title: sheetTitle, - confirmLabel: sheetConfirmLabel, - sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths), - sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates), - onConvert: (format, bitrate, losslessQuality, losslessProcessing) { - didStartConversion = true; - Navigator.pop(sheetContext); - _performBatchConversion( - allItems: allItems, - targetFormat: format, - bitrate: bitrate, - losslessQuality: losslessQuality, - losslessProcessing: losslessProcessing, - ); - }, - ), - ); - - // Wait out the sheet's exit animation before restoring the toolbar so it - // doesn't pop in front of the still-closing sheet. - await Future.delayed(const Duration(milliseconds: 260)); - if (!mounted) { - _suppressSelectionOverlay = false; - return; - } - _suppressSelectionOverlay = false; - if (didStartConversion) return; - if (_isSelectionMode) { - _syncSelectionOverlay( - items: allItems, - bottomPadding: MediaQuery.of(this.context).padding.bottom, - ); - } else if (_isPlaylistSelectionMode) { - _syncPlaylistSelectionOverlay( - playlists: ref.read(libraryCollectionsProvider).playlists, - bottomPadding: MediaQuery.of(this.context).padding.bottom, - ); - } - } - - /// Perform batch conversion on selected tracks - Future _performBatchConversion({ - required List allItems, - required String targetFormat, - required String bitrate, - LosslessConversionQuality losslessQuality = - const LosslessConversionQuality(), - LosslessConversionProcessing losslessProcessing = - const LosslessConversionProcessing(), - }) async { - final itemsById = {for (final item in allItems) item.id: item}; - final selectedItems = []; - for (final id in _selectedIds) { - final item = itemsById[id]; - if (item == null) continue; - final sourceFormat = convertibleAudioSourceFormat( - storedFormat: item.localItem?.format ?? item.historyItem?.format, - filePath: item.filePath, - fileName: item.historyItem?.safFileName, - ); - if (sourceFormat == null || - !canConvertAudioFormat( - sourceFormat: sourceFormat, - targetFormat: targetFormat, - )) { - continue; - } - selectedItems.add(item); - } - - if (selectedItems.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), - ); - } - return; - } - - final isLossless = isLosslessConversionTarget(targetFormat); - final losslessLabels = context.l10n.losslessConversionLabels; - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.selectionBatchConvertConfirmTitle), - content: Text( - isLossless && losslessQuality.hasCaps - ? context.l10n.selectionBatchConvertConfirmMessageLosslessCapped( - selectedItems.length, - targetFormat, - losslessQualityLabel( - losslessQuality, - originalLabel: losslessLabels.original, - originalQualityLabel: losslessLabels.originalQuality, - ), - ) - : isLossless - ? context.l10n.selectionBatchConvertConfirmMessageLossless( - selectedItems.length, - targetFormat, - ) - : context.l10n.selectionBatchConvertConfirmMessage( - selectedItems.length, - targetFormat, - bitrate, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(context.l10n.trackConvertFormat), - ), - ], - ), - ); - - if (confirmed != true || !mounted) return; - - int successCount = 0; - final total = selectedItems.length; - final historyDb = HistoryDatabase.instance; - final settings = ref.read(settingsProvider); - final shouldEmbedLyrics = - settings.embedLyrics && settings.lyricsMode != 'external'; - - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.trackConvertConverting, - total: total, - icon: Icons.transform, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (int i = 0; i < total; i++) { - if (!mounted || cancelled) break; - final item = selectedItems[i]; - - BatchProgressDialog.update(current: i + 1, detail: item.trackName); - - try { - final metadata = { - 'TITLE': item.trackName, - 'ARTIST': item.artistName, - 'ALBUM': item.albumName, - }; - try { - final result = await PlatformBridge.readFileMetadata(item.filePath); - if (result['error'] == null) { - mergePlatformMetadataForTagEmbed(target: metadata, source: result); - } - } catch (_) {} - await ensureLyricsMetadataForConversion( - metadata: metadata, - sourcePath: item.filePath, - shouldEmbedLyrics: shouldEmbedLyrics, - trackName: item.trackName, - artistName: item.artistName, - spotifyId: item.historyItem?.spotifyId ?? '', - durationMs: - ((item.historyItem?.duration ?? item.localItem?.duration) ?? 0) * - 1000, - ); - - String? coverPath; - try { - final tempDir = await getTemporaryDirectory(); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; - final coverResult = await PlatformBridge.extractCoverToFile( - item.filePath, - coverOutput, - ); - if (coverResult['error'] == null) { - coverPath = coverOutput; - } - } catch (_) {} - - String workingPath = item.filePath; - final isSaf = isContentUri(item.filePath); - String? safTempPath; - - if (isSaf) { - safTempPath = await PlatformBridge.copyContentUriToTemp( - item.filePath, - ); - if (safTempPath == null) continue; - workingPath = safTempPath; - } - - final newPath = await FFmpegService.convertAudioFormat( - inputPath: workingPath, - targetFormat: targetFormat.toLowerCase(), - bitrate: bitrate, - metadata: metadata, - coverPath: coverPath, - artistTagMode: settings.artistTagMode, - deleteOriginal: !isSaf, - sourceBitDepth: - item.historyItem?.bitDepth ?? item.localItem?.bitDepth, - losslessQuality: losslessQuality, - losslessProcessing: losslessProcessing, - ); - - if (coverPath != null) { - try { - await File(coverPath).delete(); - } catch (_) {} - } - - if (newPath == null) { - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - continue; - } - - final sourceBitDepth = - item.historyItem?.bitDepth ?? item.localItem?.bitDepth; - final sourceSampleRate = - item.historyItem?.sampleRate ?? item.localItem?.sampleRate; - final isLosslessOutput = isLosslessConversionTarget(targetFormat); - int? convertedBitDepth; - int? convertedSampleRate; - if (isLosslessOutput) { - try { - final convertedMetadata = await PlatformBridge.readFileMetadata( - newPath, - ); - if (convertedMetadata['error'] == null) { - convertedBitDepth = readPositiveAudioInt( - convertedMetadata['bit_depth'], - ); - convertedSampleRate = readPositiveAudioInt( - convertedMetadata['sample_rate'], - ); - } - } catch (_) {} - convertedBitDepth ??= losslessQuality.effectiveBitDepth( - sourceBitDepth, - ); - convertedSampleRate ??= losslessQuality.effectiveSampleRate( - sourceSampleRate, - ); - } - final newQuality = convertedAudioQualityLabel( - targetFormat: targetFormat, - bitrate: bitrate, - labels: losslessLabels, - losslessQuality: losslessQuality, - actualBitDepth: convertedBitDepth, - actualSampleRate: convertedSampleRate, - ); - - if (isSaf && item.historyItem != null) { - final hi = item.historyItem!; - final treeUri = hi.downloadTreeUri; - final relativeDir = hi.safRelativeDir ?? ''; - if (treeUri != null && treeUri.isNotEmpty) { - final oldFileName = hi.safFileName ?? ''; - final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 - ? oldFileName.substring(0, dotIdx) - : oldFileName; - final convTarget = convertTargetExtAndMime(targetFormat); - final newExt = convTarget.ext; - final mimeType = convTarget.mime; - final newFileName = '$baseName$newExt'; - - final safUri = await PlatformBridge.createSafFileFromPath( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: newFileName, - mimeType: mimeType, - srcPath: newPath, - ); - - if (safUri == null || safUri.isEmpty) { - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - continue; - } - - if (!isSameContentUri(item.filePath, safUri)) { - try { - await PlatformBridge.safDelete(item.filePath); - } catch (_) {} - } - - await historyDb.updateFilePath( - hi.id, - safUri, - newSafFileName: newFileName, - newQuality: newQuality, - newFormat: normalizedConvertedAudioFormat(targetFormat), - newBitrate: convertedAudioBitrateKbps( - targetFormat: targetFormat, - bitrate: bitrate, - ), - newBitDepth: convertedBitDepth, - newSampleRate: convertedSampleRate, - clearAudioSpecs: !isLosslessOutput, - ); - } - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - } else if (isSaf && item.localItem != null) { - final uri = Uri.parse(item.filePath); - final pathSegments = uri.pathSegments; - - String? treeUri; - String relativeDir = ''; - String oldFileName = ''; - - final treeIdx = pathSegments.indexOf('tree'); - final docIdx = pathSegments.indexOf('document'); - if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { - final treeId = pathSegments[treeIdx + 1]; - treeUri = - 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; - } - if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { - final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); - final slashIdx = docPath.lastIndexOf('/'); - if (slashIdx >= 0) { - oldFileName = docPath.substring(slashIdx + 1); - final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length - ? Uri.decodeFull(pathSegments[treeIdx + 1]) - : ''; - if (treeId.isNotEmpty && docPath.startsWith(treeId)) { - final afterTree = docPath.substring(treeId.length); - final trimmed = afterTree.startsWith('/') - ? afterTree.substring(1) - : afterTree; - final lastSlash = trimmed.lastIndexOf('/'); - relativeDir = lastSlash >= 0 - ? trimmed.substring(0, lastSlash) - : ''; - } - } else { - oldFileName = docPath; - } - } - - if (treeUri != null && oldFileName.isNotEmpty) { - final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 - ? oldFileName.substring(0, dotIdx) - : oldFileName; - final convTarget = convertTargetExtAndMime(targetFormat); - final newExt = convTarget.ext; - final mimeType = convTarget.mime; - final newFileName = '$baseName$newExt'; - - final safUri = await PlatformBridge.createSafFileFromPath( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: newFileName, - mimeType: mimeType, - srcPath: newPath, - ); - - if (safUri == null || safUri.isEmpty) { - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - continue; - } - - if (!isSameContentUri(item.filePath, safUri)) { - try { - await PlatformBridge.safDelete(item.filePath); - } catch (_) {} - } - await LibraryDatabase.instance.replaceWithConvertedItem( - item: item.localItem!, - newFilePath: safUri, - targetFormat: targetFormat, - bitrate: bitrate, - bitDepth: convertedBitDepth, - sampleRate: convertedSampleRate, - ); - } - - try { - await File(newPath).delete(); - } catch (_) {} - if (safTempPath != null) { - try { - await File(safTempPath).delete(); - } catch (_) {} - } - } else if (item.historyItem != null) { - await historyDb.updateFilePath( - item.historyItem!.id, - newPath, - newQuality: newQuality, - newFormat: normalizedConvertedAudioFormat(targetFormat), - newBitrate: convertedAudioBitrateKbps( - targetFormat: targetFormat, - bitrate: bitrate, - ), - newBitDepth: convertedBitDepth, - newSampleRate: convertedSampleRate, - clearAudioSpecs: !isLosslessOutput, - ); - } else if (item.localItem != null) { - await LibraryDatabase.instance.replaceWithConvertedItem( - item: item.localItem!, - newFilePath: newPath, - targetFormat: targetFormat, - bitrate: bitrate, - bitDepth: convertedBitDepth, - sampleRate: convertedSampleRate, - ); - } - - successCount++; - } catch (_) {} - } - - ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); - ref.read(localLibraryProvider.notifier).reloadFromStorage(); - - _exitSelectionMode(); - - if (mounted) { - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.selectionBatchConvertSuccess( - successCount, - total, - targetFormat, - ), - ), - ), - ); - } - } - - /// Batch-scan loudness and write ReplayGain tags to the selected tracks. - Future _runBatchReplayGain(List allItems) async { - final itemsById = {for (final item in allItems) item.id: item}; - final selectedItems = []; - for (final id in _selectedIds) { - final item = itemsById[id]; - if (item == null) continue; - selectedItems.add(item); - } - - if (selectedItems.isEmpty) return; - - _suppressSelectionOverlay = true; - _hideSelectionOverlay(); - _hidePlaylistSelectionOverlay(); - - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(ctx.l10n.replayGainBatchConfirmTitle), - content: Text( - ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(ctx.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(ctx.l10n.replayGainBatchConfirmTitle), - ), - ], - ), - ); - - if (!mounted) { - _suppressSelectionOverlay = false; - return; - } - if (confirmed != true) { - // Restore after the dialog's exit animation. - await Future.delayed(const Duration(milliseconds: 220)); - _suppressSelectionOverlay = false; - if (!mounted) return; - if (_isSelectionMode) { - _syncSelectionOverlay( - items: allItems, - bottomPadding: MediaQuery.paddingOf(context).bottom, - ); - } - return; - } - _suppressSelectionOverlay = false; - - var cancelled = false; - int successCount = 0; - final total = selectedItems.length; - - BatchProgressDialog.show( - context: context, - title: context.l10n.replayGainBatchAnalyzing, - total: total, - icon: Icons.graphic_eq, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (int i = 0; i < total; i++) { - if (!mounted || cancelled) break; - final item = selectedItems[i]; - BatchProgressDialog.update(current: i + 1, detail: item.trackName); - try { - final ok = await ReplayGainService.applyToFile(item.filePath); - if (ok) successCount++; - } catch (_) {} - } - - _exitSelectionMode(); - - if (!mounted) return; - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.replayGainBatchSuccess(successCount, total)), - ), - ); - } - - Widget _buildSelectionBottomBar( - BuildContext context, - ColorScheme colorScheme, - List unifiedItems, - double bottomPadding, - ) { - final selectedCount = _selectedIds.length; - final allSelected = - selectedCount == unifiedItems.length && unifiedItems.isNotEmpty; - final localOnlySelection = _isLocalOnlySelection(unifiedItems); - final flacEligibleCount = _selectedFlacEligibleLocalItems( - unifiedItems, - ).length; - - 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: _exitSelectionMode, - 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.selectionAllSelected - : context.l10n.downloadedAlbumTapToSelect, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ), - - TextButton.icon( - onPressed: () { - if (allSelected) { - _exitSelectionMode(); - } else { - _selectAll(unifiedItems); - } - }, - 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), - - LayoutBuilder( - builder: (context, constraints) { - const spacing = 8.0; - final itemWidth = (constraints.maxWidth - spacing) / 2; - final actions = []; - - if (localOnlySelection && flacEligibleCount > 0) { - actions.add( - _SelectionActionButton( - icon: Icons.download_for_offline_outlined, - label: - '${context.l10n.queueFlacAction} ($flacEligibleCount)', - onPressed: () => - _queueSelectedLocalAsFlac(unifiedItems), - colorScheme: colorScheme, - ), - ); - } - - actions.add( - _SelectionActionButton( - icon: localOnlySelection - ? Icons.auto_fix_high_outlined - : Icons.share_outlined, - label: localOnlySelection - ? '${context.l10n.trackReEnrich} ($selectedCount)' - : context.l10n.selectionShareCount(selectedCount), - onPressed: selectedCount > 0 - ? () => localOnlySelection - ? _reEnrichSelectedLocalFromQueue(unifiedItems) - : _shareSelected(unifiedItems) - : null, - colorScheme: colorScheme, - ), - ); - - actions.add( - _SelectionActionButton( - icon: Icons.swap_horiz, - label: context.l10n.selectionConvertCount(selectedCount), - onPressed: selectedCount > 0 - ? () => _showBatchConvertSheet(context, unifiedItems) - : null, - colorScheme: colorScheme, - ), - ); - - actions.add( - _SelectionActionButton( - icon: Icons.graphic_eq, - label: context.l10n.selectionReplayGainCount( - selectedCount, - ), - onPressed: selectedCount > 0 - ? () => _runBatchReplayGain(unifiedItems) - : null, - colorScheme: colorScheme, - ), - ); - - return Wrap( - spacing: spacing, - runSpacing: spacing, - children: [ - for (final action in actions) - SizedBox(width: itemWidth, child: action), - ], - ); - }, - ), - - const SizedBox(height: 8), - - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: selectedCount > 0 - ? () => _deleteSelected(unifiedItems) - : null, - icon: const Icon(Icons.delete_outline), - label: Text( - selectedCount > 0 - ? context.l10n.selectionDeleteTracksCount(selectedCount) - : context.l10n.selectionSelectToDelete, - ), - 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), - ), - ), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildQueueItem( - BuildContext context, - DownloadItem item, - ColorScheme colorScheme, - ) { - final isCompleted = item.status == DownloadStatus.completed; - final isActive = - item.status == DownloadStatus.queued || - item.status == DownloadStatus.downloading || - item.status == DownloadStatus.finalizing; - - return Dismissible( - key: ValueKey('dismiss_${item.id}'), - direction: DismissDirection.endToStart, - confirmDismiss: isActive - ? (_) async { - return await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.cancelDownloadTitle), - content: Text( - context.l10n.cancelDownloadContent(item.track.name), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: Text(context.l10n.cancelDownloadKeep), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: Text(context.l10n.dialogCancel), - ), - ], - ), - ) ?? - false; - } - : null, - onDismissed: (_) { - ref.read(downloadQueueProvider.notifier).dismissItem(item.id); - }, - background: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - decoration: BoxDecoration( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(12), - ), - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20), - child: Icon(Icons.delete_outline, color: colorScheme.onErrorContainer), - ), - child: DownloadSuccessOverlay( - showSuccess: isCompleted, - child: Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: isCompleted - ? () => _navigateToMetadataScreen(item) - : item.status == DownloadStatus.failed - ? () => _showDownloadErrorDialog(context, item) - : null, - borderRadius: BorderRadius.circular(12), - child: Stack( - children: [ - if (item.status == DownloadStatus.downloading) - Positioned.fill( - child: Align( - alignment: Alignment.centerLeft, - child: FractionallySizedBox( - widthFactor: item.progress.clamp(0.0, 1.0), - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - colorScheme.primary.withValues(alpha: 0.16), - colorScheme.primary.withValues(alpha: 0.04), - ], - ), - ), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - isCompleted - ? Hero( - tag: 'cover_${item.id}', - child: _buildCoverArt(item, colorScheme), - ) - : _buildCoverArt(item, colorScheme), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.track.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall - ?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 2), - ClickableArtistName( - artistName: item.track.artistName, - artistId: item.track.artistId, - coverUrl: item.track.coverUrl, - extensionId: item.track.source, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (item.status == DownloadStatus.downloading) ...[ - const SizedBox(height: 5), - Row( - children: [ - Icon( - Icons.download_rounded, - size: 12, - color: colorScheme.primary, - ), - const SizedBox(width: 4), - Expanded( - child: Text( - _formatDownloadStatusLine(context, item), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ], - if (item.status == DownloadStatus.failed) ...[ - const SizedBox(height: 4), - _buildDownloadFailureMessage( - context, - item, - colorScheme, - ), - ], - ], - ), - ), - const SizedBox(width: 8), - _buildActionButtons(context, item, colorScheme), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } - - /// Download error messages are stored as fixed English sentinels on the - /// item (so code can match on them); translate the known ones for display. - String _localizedDownloadError(BuildContext context, String raw) { - if (raw == safPermissionLostErrorMessage) { - return context.l10n.downloadErrorSafPermissionLost; - } - if (raw == downloadFolderAccessLostErrorMessage) { - return context.l10n.downloadErrorFolderAccessLost; - } - return raw; - } - - Widget _buildDownloadFailureMessage( - BuildContext context, - DownloadItem item, - ColorScheme colorScheme, - ) { - if (item.errorType != DownloadErrorType.rateLimit) { - return Text( - _localizedDownloadError(context, item.errorMessage), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: colorScheme.error), - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 1), - child: Icon( - Icons.hourglass_top_rounded, - size: 14, - color: colorScheme.tertiary, - ), - ), - const SizedBox(width: 6), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.queueRateLimitTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.tertiary, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 1), - Text( - context.l10n.queueRateLimitMessage, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.tertiary.withValues(alpha: 0.8), - ), - ), - ], - ), - ), - ], - ); - } - - Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) { - final coverSize = _queueCoverSize(); - final radius = BorderRadius.circular(8); - - final cover = item.track.coverUrl != null - ? CachedCoverImage( - imageUrl: item.track.coverUrl!, - width: coverSize, - height: coverSize, - borderRadius: radius, - fadeInDuration: const Duration(milliseconds: 180), - fadeOutDuration: const Duration(milliseconds: 90), - ) - : Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: radius, - ), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ); - - final isDownloading = - item.status == DownloadStatus.downloading || - item.status == DownloadStatus.finalizing; - if (!isDownloading) return cover; - - final progress = item.progress.clamp(0.0, 1.0); - final indeterminate = - item.status == DownloadStatus.finalizing || progress <= 0; - - return SizedBox( - width: coverSize, - height: coverSize, - child: Stack( - fit: StackFit.expand, - children: [ - cover, - ClipRRect( - borderRadius: radius, - child: ColoredBox(color: Colors.black.withValues(alpha: 0.45)), - ), - Center( - child: SizedBox( - width: coverSize * 0.6, - height: coverSize * 0.6, - child: CircularProgressIndicator( - value: indeterminate ? null : progress, - strokeWidth: 3, - color: Colors.white, - backgroundColor: Colors.white.withValues(alpha: 0.25), - ), - ), - ), - if (!indeterminate) - Center( - child: Text( - '${(progress * 100).round()}', - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - ); - } - - Widget _buildActionButtons( - BuildContext context, - DownloadItem item, - ColorScheme colorScheme, - ) { - switch (item.status) { - case DownloadStatus.queued: - return IconButton( - onPressed: () => - ref.read(downloadQueueProvider.notifier).cancelItem(item.id), - icon: Icon(Icons.close, color: colorScheme.error), - tooltip: context.l10n.dialogCancel, - style: IconButton.styleFrom( - backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), - ), - ); - case DownloadStatus.downloading: - return IconButton( - onPressed: () => - ref.read(downloadQueueProvider.notifier).cancelItem(item.id), - icon: Icon(Icons.stop, color: colorScheme.error), - tooltip: context.l10n.actionStop, - style: IconButton.styleFrom( - backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), - ), - ); - case DownloadStatus.finalizing: - return Semantics( - label: context.l10n.queueFinalizingDownload, - child: SizedBox( - width: 40, - height: 40, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - strokeWidth: 3, - color: colorScheme.tertiary, - ), - ExcludeSemantics( - child: Icon( - Icons.edit_note, - color: colorScheme.tertiary, - size: 16, - ), - ), - ], - ), - ), - ); - case DownloadStatus.completed: - return ValueListenableBuilder( - valueListenable: _fileExistsListenable(item.filePath), - builder: (context, fileExists, child) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (fileExists) - IconButton( - onPressed: () => _openFile( - item.filePath!, - title: item.track.name, - artist: item.track.artistName, - album: item.track.albumName, - coverUrl: item.track.coverUrl ?? '', - ), - icon: Icon(Icons.play_arrow, color: colorScheme.primary), - tooltip: context.l10n.tooltipPlay, - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer.withValues( - alpha: 0.3, - ), - ), - ) - else - Semantics( - label: context.l10n.queueDownloadedFileMissing, - child: ExcludeSemantics( - child: Icon( - Icons.error_outline, - color: colorScheme.error, - size: 20, - ), - ), - ), - const SizedBox(width: 4), - Semantics( - label: context.l10n.queueDownloadCompleted, - child: ExcludeSemantics( - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.check, - color: colorScheme.onPrimaryContainer, - size: 20, - ), - ), - ), - ), - ], - ); - }, - ); - case DownloadStatus.failed: - case DownloadStatus.skipped: - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () => - ref.read(downloadQueueProvider.notifier).retryItem(item.id), - icon: Icon(Icons.refresh, color: colorScheme.primary), - tooltip: context.l10n.dialogRetry, - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer.withValues( - alpha: 0.3, - ), - ), - ), - const SizedBox(width: 4), - IconButton( - onPressed: () => - ref.read(downloadQueueProvider.notifier).removeItem(item.id), - icon: Icon( - Icons.close, - color: item.status == DownloadStatus.failed - ? colorScheme.error - : colorScheme.onSurfaceVariant, - ), - tooltip: context.l10n.dialogRemove, - style: item.status == DownloadStatus.failed - ? IconButton.styleFrom( - backgroundColor: colorScheme.errorContainer.withValues( - alpha: 0.3, - ), - ) - : null, - ), - ], - ); - } - } - - Widget _buildFilterButton( - BuildContext context, - List unifiedItems, - ) { - return GestureDetector( - onLongPress: _activeFilterCount > 0 ? _resetFilters : null, - child: TextButton.icon( - onPressed: () => _showFilterSheet(context, unifiedItems), - icon: Badge( - isLabelVisible: _activeFilterCount > 0, - label: Text('$_activeFilterCount'), - child: const Icon(Icons.filter_list, size: 18), - ), - label: Text(context.l10n.libraryFilterTitle), - style: TextButton.styleFrom(visualDensity: VisualDensity.compact), - ), - ); - } - - /// When [size] is provided, renders at fixed dimensions (list mode). - /// When [size] is null, fills the parent container (grid mode). - Widget _buildUnifiedCoverImage( - UnifiedLibraryItem item, - ColorScheme colorScheme, [ - double? size, - ]) { - final isDownloaded = item.source == LibraryItemSource.downloaded; - - // For downloaded items, listen to embedded cover version so the cover - // updates after async extraction completes. - if (isDownloaded) { - return ValueListenableBuilder( - valueListenable: _embeddedCoverVersion, - builder: (context, _, child) => - _buildUnifiedCoverImageInner(item, colorScheme, isDownloaded, size), - ); - } - - return _buildUnifiedCoverImageInner(item, colorScheme, isDownloaded, size); - } - - Widget _buildUnifiedCoverImageInner( - UnifiedLibraryItem item, - ColorScheme colorScheme, - bool isDownloaded, [ - double? size, - ]) { - final cacheSize = size != null ? (size * 2).toInt() : 200; - final iconSize = size != null ? size * 0.4 : 32.0; - - Widget buildPlaceholder({bool isLocal = false}) { - final bgColor = (isDownloaded && !isLocal) - ? colorScheme.surfaceContainerHighest - : colorScheme.secondaryContainer; - final fgColor = (isDownloaded && !isLocal) - ? colorScheme.onSurfaceVariant - : colorScheme.onSecondaryContainer; - return Container( - width: size, - height: size, - decoration: size != null - ? BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(8), - ) - : null, - color: size != null ? null : bgColor, - child: Center( - child: Icon(Icons.music_note, color: fgColor, size: iconSize), - ), - ); - } - - Widget fadeInFileImage(Widget child, int? frame, bool wasSync) { - if (wasSync) return child; - final Widget backdrop; - if (isDownloaded && item.coverUrl != null) { - backdrop = CachedCoverImage( - imageUrl: item.coverUrl!, - width: size, - height: size, - memCacheWidth: cacheSize, - memCacheHeight: cacheSize, - placeholder: (context, url) => buildPlaceholder(), - errorWidget: (context, url, error) => buildPlaceholder(), - ); - } else { - backdrop = buildPlaceholder(isLocal: !isDownloaded); - } - final animated = Stack( - fit: StackFit.expand, - children: [ - backdrop, - AnimatedOpacity( - opacity: frame == null ? 0.0 : 1.0, - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - child: child, - ), - ], - ); - if (size == null) return animated; - return SizedBox(width: size, height: size, child: animated); - } - - if (isDownloaded) { - final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( - item.filePath, - ); - if (embeddedCoverPath != null) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.file( - File(embeddedCoverPath), - width: size, - height: size, - fit: BoxFit.cover, - cacheWidth: cacheSize, - cacheHeight: cacheSize, - gaplessPlayback: true, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => - fadeInFileImage(child, frame, wasSynchronouslyLoaded), - errorBuilder: (context, error, stackTrace) => buildPlaceholder(), - ), - ); - } - } - - if (item.coverUrl != null) { - return CachedCoverImage( - imageUrl: item.coverUrl!, - width: size, - height: size, - memCacheWidth: cacheSize, - memCacheHeight: cacheSize, - borderRadius: BorderRadius.circular(8), - placeholder: (context, url) => buildPlaceholder(), - errorWidget: (context, url, error) => buildPlaceholder(), - fadeInDuration: const Duration(milliseconds: 180), - fadeOutDuration: const Duration(milliseconds: 90), - ); - } - - if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.file( - File(item.localCoverPath!), - width: size, - height: size, - fit: BoxFit.cover, - cacheWidth: cacheSize, - cacheHeight: cacheSize, - gaplessPlayback: true, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => - fadeInFileImage(child, frame, wasSynchronouslyLoaded), - errorBuilder: (context, error, stackTrace) => - buildPlaceholder(isLocal: true), - ), - ); - } - - if (size != null) { - return buildPlaceholder(); - } - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: buildPlaceholder(), - ); - } - - Widget _buildUnifiedLibraryItem( - BuildContext context, - UnifiedLibraryItem item, - ColorScheme colorScheme, { - required List downloadedNavigationItems, - required int? downloadedNavigationIndex, - required List localNavigationItems, - required int? localNavigationIndex, - required List libraryItems, - }) { - final fileExistsListenable = _fileExistsListenable(item.filePath); - final isSelected = _selectedIds.contains(item.id); - final date = item.addedAt; - final dateStr = - '${_months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; - - final isDownloaded = item.source == LibraryItemSource.downloaded; - final sourceLabel = isDownloaded - ? context.l10n.librarySourceDownloaded - : context.l10n.librarySourceLocal; - final sourceColor = isDownloaded - ? colorScheme.primaryContainer - : colorScheme.secondaryContainer; - final sourceTextColor = isDownloaded - ? colorScheme.onPrimaryContainer - : colorScheme.onSecondaryContainer; - - return Semantics( - label: context.l10n.a11yTrackByArtist(item.trackName, item.artistName), - selected: isSelected, - child: Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : null, - child: InkWell( - onTap: _isSelectionMode - ? () => _toggleSelection(item.id) - : isDownloaded - ? () => _navigateToHistoryMetadataScreen( - item.historyItem!, - navigationItems: downloadedNavigationItems, - navigationIndex: downloadedNavigationIndex, - ) - : item.localItem != null - ? () => _navigateToLocalMetadataScreen( - item.localItem!, - navigationItems: localNavigationItems, - navigationIndex: localNavigationIndex, - ) - : () => _openFile( - item.filePath, - title: item.trackName, - artist: item.artistName, - album: item.albumName, - coverUrl: item.coverUrl ?? item.localCoverPath ?? '', - ), - onLongPress: _isSelectionMode - ? null - : () => _enterSelectionMode(item.id), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - if (_isSelectionMode) ...[ - Semantics( - checked: isSelected, - label: isSelected - ? context.l10n.a11yDeselectTrack - : context.l10n.a11ySelectTrack, - child: AnimatedSelectionCheckbox( - visible: true, - selected: isSelected, - colorScheme: colorScheme, - size: 24, - ), - ), - const SizedBox(width: 12), - ], - Hero( - tag: 'cover_lib_${item.id}', - child: _buildUnifiedCoverImage(item, colorScheme, 56), - ), - const SizedBox(width: 12), - - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - ClickableArtistName( - artistName: item.artistName, - coverUrl: item.coverUrl, - extensionId: item.historyItem?.service, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: sourceColor, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - sourceLabel, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: sourceTextColor, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(width: 8), - Flexible( - child: Text( - dateStr, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.onSurfaceVariant - .withValues(alpha: 0.7), - ), - ), - ), - if (item.quality != null && - item.quality!.isNotEmpty) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: item.quality!.startsWith('24') - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - item.quality!, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: item.quality!.startsWith('24') - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ], - ), - ], - ), - ), - const SizedBox(width: 8), - - if (!_isSelectionMode) - ValueListenableBuilder( - valueListenable: fileExistsListenable, - builder: (context, fileExists, child) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (fileExists) - IconButton( - onPressed: () => - _playLibraryItem(item, libraryItems), - icon: Icon( - Icons.play_arrow, - color: colorScheme.primary, - ), - tooltip: context.l10n.tooltipPlay, - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer - .withValues(alpha: 0.3), - ), - ) - else - Icon( - Icons.error_outline, - color: colorScheme.error, - size: 20, - ), - ], - ); - }, - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildUnifiedGridItem( - BuildContext context, - UnifiedLibraryItem item, - ColorScheme colorScheme, { - required List downloadedNavigationItems, - required int? downloadedNavigationIndex, - required List localNavigationItems, - required int? localNavigationIndex, - required List libraryItems, - }) { - final fileExistsListenable = _fileExistsListenable(item.filePath); - final isSelected = _selectedIds.contains(item.id); - final isDownloaded = item.source == LibraryItemSource.downloaded; - - return GestureDetector( - onTap: _isSelectionMode - ? () => _toggleSelection(item.id) - : isDownloaded - ? () => _navigateToHistoryMetadataScreen( - item.historyItem!, - navigationItems: downloadedNavigationItems, - navigationIndex: downloadedNavigationIndex, - ) - : item.localItem != null - ? () => _navigateToLocalMetadataScreen( - item.localItem!, - navigationItems: localNavigationItems, - navigationIndex: localNavigationIndex, - ) - : () => _openFile( - item.filePath, - title: item.trackName, - artist: item.artistName, - album: item.albumName, - coverUrl: item.coverUrl ?? item.localCoverPath ?? '', - ), - onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - AspectRatio( - aspectRatio: 1, - child: Hero( - tag: 'cover_lib_${item.id}', - child: _buildUnifiedCoverImage(item, colorScheme), - ), - ), - Positioned( - right: 4, - top: 4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - decoration: BoxDecoration( - color: isDownloaded - ? colorScheme.primaryContainer - : colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Icon( - isDownloaded ? Icons.download_done : Icons.folder, - size: 12, - color: isDownloaded - ? colorScheme.onPrimaryContainer - : colorScheme.onSecondaryContainer, - ), - ), - ), - if (item.quality != null && item.quality!.isNotEmpty) - Positioned( - left: 4, - top: 4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - decoration: BoxDecoration( - color: item.quality!.startsWith('24') - ? colorScheme.primary - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - _getQualityBadgeText(item.quality!), - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: item.quality!.startsWith('24') - ? colorScheme.onPrimary - : colorScheme.onSurfaceVariant, - fontSize: 9, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - if (!_isSelectionMode) - Positioned( - right: 4, - bottom: 4, - child: ValueListenableBuilder( - valueListenable: fileExistsListenable, - builder: (context, fileExists, child) { - return fileExists - ? Semantics( - button: true, - label: context.l10n.a11yPlayTrackByArtist( - item.trackName, - item.artistName, - ), - child: GestureDetector( - onTap: () => - _playLibraryItem(item, libraryItems), - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: colorScheme.primary, - shape: BoxShape.circle, - ), - child: ExcludeSemantics( - child: Icon( - Icons.play_arrow, - color: colorScheme.onPrimary, - size: 16, - ), - ), - ), - ), - ) - : Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: colorScheme.errorContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.error_outline, - color: colorScheme.error, - size: 14, - ), - ); - }, - ), - ), - if (_isSelectionMode) - Positioned.fill( - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.3) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - item.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), - ), - ClickableArtistName( - artistName: item.artistName, - coverUrl: item.coverUrl, - extensionId: item.historyItem?.service, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - if (_isSelectionMode) - Positioned( - right: 4, - top: 4, - child: Container( - decoration: BoxDecoration( - color: isSelected ? colorScheme.primary : colorScheme.surface, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) - : const SizedBox(width: 16, height: 16), - ), - ), - ], - ), - ); - } } class _AnimatedLibrarySliverGrid extends StatefulWidget { diff --git a/lib/screens/queue_tab_batch_actions.dart b/lib/screens/queue_tab_batch_actions.dart new file mode 100644 index 00000000..a27ea35b --- /dev/null +++ b/lib/screens/queue_tab_batch_actions.dart @@ -0,0 +1,1111 @@ +part of 'queue_tab.dart'; + +extension _QueueTabBatchActions on _QueueTabState { + Future _safeDeleteTempFile(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + + Future _cleanupTempFileAndParentDir(String path) async { + await _safeDeleteTempFile(path); + try { + final parent = File(path).parent; + if (await parent.exists()) { + await parent.delete(); + } + } catch (_) {} + } + + Future _applyQueueFfmpegReEnrichResult( + LocalLibraryItem item, + Map result, + ) async { + final tempPath = result['temp_path'] as String?; + final safUri = result['saf_uri'] as String?; + final ffmpegTarget = _hasTextValue(tempPath) ? tempPath! : item.filePath; + final downloadedCoverPath = result['cover_path'] as String?; + String? effectiveCoverPath = downloadedCoverPath; + String? extractedCoverPath; + + if (!_hasTextValue(effectiveCoverPath)) { + try { + final tempDir = await Directory.systemTemp.createTemp( + 'reenrich_cover_', + ); + final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final extracted = await PlatformBridge.extractCoverToFile( + ffmpegTarget, + coverOutput, + ); + if (extracted['error'] == null) { + effectiveCoverPath = coverOutput; + extractedCoverPath = coverOutput; + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + } + + final metadata = (result['metadata'] as Map?)?.map( + (k, v) => MapEntry(k, v.toString()), + ); + + final format = item.format?.toLowerCase(); + final lowerPath = item.filePath.toLowerCase(); + final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); + final isM4A = + format == 'm4a' || + format == 'aac' || + lowerPath.endsWith('.m4a') || + lowerPath.endsWith('.aac'); + final isOpus = + format == 'opus' || + format == 'ogg' || + lowerPath.endsWith('.opus') || + lowerPath.endsWith('.ogg'); + + final artistTagMode = ref.read(settingsProvider).artistTagMode; + String? ffmpegResult; + if (isMp3) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + preserveMetadata: true, + ); + } else if (isM4A) { + ffmpegResult = await FFmpegService.embedMetadataToM4a( + m4aPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + preserveMetadata: true, + ); + } else if (isOpus) { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + artistTagMode: artistTagMode, + preserveMetadata: true, + ); + } + + if (ffmpegResult != null && + _hasTextValue(tempPath) && + _hasTextValue(safUri)) { + final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!); + if (!ok) { + if (_hasTextValue(downloadedCoverPath)) { + await _safeDeleteTempFile(downloadedCoverPath!); + } + if (_hasTextValue(extractedCoverPath)) { + await _cleanupTempFileAndParentDir(extractedCoverPath!); + } + await _safeDeleteTempFile(tempPath!); + return false; + } + await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result); + } + + if (_hasTextValue(downloadedCoverPath)) { + await _safeDeleteTempFile(downloadedCoverPath!); + } + if (_hasTextValue(extractedCoverPath)) { + await _cleanupTempFileAndParentDir(extractedCoverPath!); + } + if (_hasTextValue(tempPath)) { + await _safeDeleteTempFile(tempPath!); + } + + if (ffmpegResult != null) { + // Filesystem .lrc sidecar. SAF sidecar is written only after + // writeTempToSaf succeeds. + await writeReEnrichSidecarLrc( + audioFilePath: item.filePath, + reEnrichResult: result, + ); + } + + return ffmpegResult != null; + } + + Future _reEnrichQueueLocalTrack( + LocalLibraryItem item, { + List? updateFields, + }) async { + final durationMs = (item.duration ?? 0) * 1000; + final settings = ref.read(settingsProvider); + final artistTagMode = settings.artistTagMode; + await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); + final request = { + 'file_path': item.filePath, + 'cover_url': '', + 'max_quality': true, + 'embed_lyrics': settings.embedLyrics, + 'lyrics_mode': settings.lyricsMode, + 'artist_tag_mode': artistTagMode, + 'spotify_id': '', + 'track_name': item.trackName, + 'artist_name': item.artistName, + 'album_name': item.albumName, + 'album_artist': item.albumArtist ?? '', + 'track_number': item.trackNumber ?? 0, + 'disc_number': item.discNumber ?? 0, + 'release_date': item.releaseDate ?? '', + 'isrc': item.isrc ?? '', + 'genre': item.genre ?? '', + 'label': '', + 'copyright': '', + 'duration_ms': durationMs, + 'search_online': true, + // ignore: use_null_aware_elements + if (updateFields != null) 'update_fields': updateFields, + }; + + final result = await PlatformBridge.reEnrichFile(request); + final method = result['method'] as String?; + if (method == 'native') { + // Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin). + await writeReEnrichSidecarLrc( + audioFilePath: item.filePath, + reEnrichResult: result, + ); + return true; + } + if (method == 'ffmpeg') { + return _applyQueueFfmpegReEnrichResult(item, result); + } + return false; + } + + List _selectedFlacEligibleLocalItems( + List allItems, + ) { + final selectedItems = _selectedItemsFromAll(allItems); + return selectedItems + .map((item) => item.localItem) + .whereType() + .where(LocalTrackRedownloadService.isFlacUpgradeEligible) + .toList(growable: false); + } + + Future _queueSelectedLocalAsFlac( + List allItems, + ) async { + final selectedLocalItems = _selectedFlacEligibleLocalItems(allItems); + + if (selectedLocalItems.isEmpty) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.queueFlacAction), + content: Text( + context.l10n.queueFlacConfirmMessage(selectedLocalItems.length), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.queueFlacAction), + ), + ], + ), + ); + + if (confirmed != true || !mounted) { + return; + } + + final settings = ref.read(settingsProvider); + final extensionState = ref.read(extensionProvider); + final includeExtensions = + settings.useExtensionProviders && + extensionState.extensions.any( + (ext) => ext.enabled && ext.hasMetadataProvider, + ); + final targetService = LocalTrackRedownloadService.preferredFlacService( + settings, + extensionState, + ); + if (targetService.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } + final targetQuality = + LocalTrackRedownloadService.preferredFlacQualityForService( + targetService, + extensionState, + ); + + final matchedTracks = []; + var skippedCount = 0; + final total = selectedLocalItems.length; + + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.queueFlacAction, + total: total, + icon: Icons.queue_music, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + for (var i = 0; i < total; i++) { + if (!mounted || cancelled) break; + + BatchProgressDialog.update( + current: i + 1, + detail: selectedLocalItems[i].trackName, + ); + + try { + final resolution = await LocalTrackRedownloadService.resolveBestMatch( + selectedLocalItems[i], + includeExtensions: includeExtensions, + ); + if (resolution.canQueue && resolution.match != null) { + matchedTracks.add(resolution.match!); + } else { + skippedCount++; + } + } catch (_) { + skippedCount++; + } + } + + if (!mounted) { + return; + } + + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } + + if (matchedTracks.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)), + ); + return; + } + + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue( + matchedTracks, + targetService, + qualityOverride: targetQuality, + ); + + final summary = skippedCount == 0 + ? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length) + : context.l10n.queueFlacQueuedWithSkipped( + matchedTracks.length, + skippedCount, + ); + + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(summary))); + _setState(() { + _selectedIds.clear(); + _isSelectionMode = false; + }); + } + + Future _reEnrichSelectedLocalFromQueue( + List allItems, + ) async { + final selectedItems = _selectedItemsFromAll(allItems); + final selectedLocalItems = selectedItems + .map((item) => item.localItem) + .whereType() + .toList(growable: false); + + if (selectedLocalItems.isEmpty) { + return; + } + + // Hide the selection overlay: set the flag (prevents build() from + // re-inserting via postFrameCallback) and remove the entry immediately. + _setState(() => _isSelectionMode = false); + _hideSelectionOverlay(); + + final selection = await showReEnrichFieldDialog( + context, + selectedCount: selectedLocalItems.length, + ); + + if (selection == null || !mounted) { + // Cancelled — restore selection mode; the next build cycle will + // re-create the overlay via _syncSelectionOverlay in postFrameCallback. + if (mounted) _setState(() => _isSelectionMode = true); + return; + } + + final updateFields = selection.isAll ? null : selection.fields; + + var successCount = 0; + final total = selectedLocalItems.length; + + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.trackReEnrichProgress, + total: total, + icon: Icons.auto_fix_high, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + for (var i = 0; i < total; i++) { + if (!mounted || cancelled) break; + final item = selectedLocalItems[i]; + + BatchProgressDialog.update( + current: i + 1, + detail: '${item.trackName} - ${item.artistName}', + ); + + try { + final ok = await _reEnrichQueueLocalTrack( + item, + updateFields: updateFields, + ); + if (ok) { + successCount++; + } + } catch (_) {} + } + + if (!mounted) { + return; + } + + final settings = ref.read(settingsProvider); + final localLibraryPath = settings.localLibraryPath.trim(); + final iosBookmark = settings.localLibraryBookmark; + try { + if (localLibraryPath.isNotEmpty && + !ref.read(localLibraryProvider).isScanning) { + await ref + .read(localLibraryProvider.notifier) + .startScan( + localLibraryPath, + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); + } else { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + } catch (_) { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + + _exitSelectionMode(); + + if (!mounted) { + return; + } + + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } + ScaffoldMessenger.of(context).clearSnackBars(); + final failedCount = total - successCount; + final summary = failedCount <= 0 + ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' + : context.l10n.trackReEnrichSuccessWithFailures( + successCount, + total, + failedCount, + ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(summary))); + } + + /// Share selected tracks via system share sheet + Future _shareSelected(List allItems) async { + final itemsById = {for (final item in allItems) item.id: item}; + final safUris = []; + final filesToShare = []; + + for (final id in _selectedIds) { + final item = itemsById[id]; + if (item == null) continue; + final path = item.filePath; + if (isContentUri(path)) { + if (await fileExists(path)) safUris.add(path); + } else if (await fileExists(path)) { + filesToShare.add(XFile(path)); + } + } + + if (safUris.isEmpty && filesToShare.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionShareNoFiles)), + ); + } + return; + } + + if (safUris.isNotEmpty) { + try { + if (safUris.length == 1) { + await PlatformBridge.shareContentUri(safUris.first); + } else { + await PlatformBridge.shareMultipleContentUris(safUris); + } + } catch (_) {} + } + + if (filesToShare.isNotEmpty) { + await SharePlus.instance.share(ShareParams(files: filesToShare)); + } + } + + Future _showBatchConvertSheet( + BuildContext context, + List allItems, + ) async { + final itemsById = {for (final item in allItems) item.id: item}; + final sourceFormats = {}; + final sourceBitDepths = []; + final sourceSampleRates = []; + for (final id in _selectedIds) { + final item = itemsById[id]; + if (item == null) continue; + final sourceFormat = convertibleAudioSourceFormat( + storedFormat: item.localItem?.format ?? item.historyItem?.format, + filePath: item.filePath, + fileName: item.historyItem?.safFileName, + ); + if (sourceFormat != null) sourceFormats.add(sourceFormat); + sourceBitDepths.add( + item.historyItem?.bitDepth ?? item.localItem?.bitDepth, + ); + sourceSampleRates.add( + item.historyItem?.sampleRate ?? item.localItem?.sampleRate, + ); + } + + final formats = audioConversionTargetFormats + .where( + (target) => sourceFormats.any( + (source) => canConvertAudioFormat( + sourceFormat: source, + targetFormat: target, + ), + ), + ) + .toList(); + + if (formats.isEmpty) return; + + var didStartConversion = false; + + // Resolve localized strings up front; the builder must not look up + // Localizations via the (possibly deactivated) State context. + final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle; + final sheetConfirmLabel = context.l10n.selectionConvertCount( + _selectedIds.length, + ); + + _suppressSelectionOverlay = true; + _hideSelectionOverlay(); + _hidePlaylistSelectionOverlay(); + + await showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => BatchConvertSheet( + formats: formats, + title: sheetTitle, + confirmLabel: sheetConfirmLabel, + sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths), + sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates), + onConvert: (format, bitrate, losslessQuality, losslessProcessing) { + didStartConversion = true; + Navigator.pop(sheetContext); + _performBatchConversion( + allItems: allItems, + targetFormat: format, + bitrate: bitrate, + losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, + ); + }, + ), + ); + + // Wait out the sheet's exit animation before restoring the toolbar so it + // doesn't pop in front of the still-closing sheet. + await Future.delayed(const Duration(milliseconds: 260)); + if (!mounted) { + _suppressSelectionOverlay = false; + return; + } + _suppressSelectionOverlay = false; + if (didStartConversion) return; + if (_isSelectionMode) { + _syncSelectionOverlay( + items: allItems, + bottomPadding: MediaQuery.of(this.context).padding.bottom, + ); + } else if (_isPlaylistSelectionMode) { + _syncPlaylistSelectionOverlay( + playlists: ref.read(libraryCollectionsProvider).playlists, + bottomPadding: MediaQuery.of(this.context).padding.bottom, + ); + } + } + + /// Perform batch conversion on selected tracks + Future _performBatchConversion({ + required List allItems, + required String targetFormat, + required String bitrate, + LosslessConversionQuality losslessQuality = + const LosslessConversionQuality(), + LosslessConversionProcessing losslessProcessing = + const LosslessConversionProcessing(), + }) async { + final itemsById = {for (final item in allItems) item.id: item}; + final selectedItems = []; + for (final id in _selectedIds) { + final item = itemsById[id]; + if (item == null) continue; + final sourceFormat = convertibleAudioSourceFormat( + storedFormat: item.localItem?.format ?? item.historyItem?.format, + filePath: item.filePath, + fileName: item.historyItem?.safFileName, + ); + if (sourceFormat == null || + !canConvertAudioFormat( + sourceFormat: sourceFormat, + targetFormat: targetFormat, + )) { + continue; + } + selectedItems.add(item); + } + + if (selectedItems.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), + ); + } + return; + } + + final isLossless = isLosslessConversionTarget(targetFormat); + final losslessLabels = context.l10n.losslessConversionLabels; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.selectionBatchConvertConfirmTitle), + content: Text( + isLossless && losslessQuality.hasCaps + ? context.l10n.selectionBatchConvertConfirmMessageLosslessCapped( + selectedItems.length, + targetFormat, + losslessQualityLabel( + losslessQuality, + originalLabel: losslessLabels.original, + originalQualityLabel: losslessLabels.originalQuality, + ), + ) + : isLossless + ? context.l10n.selectionBatchConvertConfirmMessageLossless( + selectedItems.length, + targetFormat, + ) + : context.l10n.selectionBatchConvertConfirmMessage( + selectedItems.length, + targetFormat, + bitrate, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.trackConvertFormat), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + int successCount = 0; + final total = selectedItems.length; + final historyDb = HistoryDatabase.instance; + final settings = ref.read(settingsProvider); + final shouldEmbedLyrics = + settings.embedLyrics && settings.lyricsMode != 'external'; + + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.trackConvertConverting, + total: total, + icon: Icons.transform, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + for (int i = 0; i < total; i++) { + if (!mounted || cancelled) break; + final item = selectedItems[i]; + + BatchProgressDialog.update(current: i + 1, detail: item.trackName); + + try { + final metadata = { + 'TITLE': item.trackName, + 'ARTIST': item.artistName, + 'ALBUM': item.albumName, + }; + try { + final result = await PlatformBridge.readFileMetadata(item.filePath); + if (result['error'] == null) { + mergePlatformMetadataForTagEmbed(target: metadata, source: result); + } + } catch (_) {} + await ensureLyricsMetadataForConversion( + metadata: metadata, + sourcePath: item.filePath, + shouldEmbedLyrics: shouldEmbedLyrics, + trackName: item.trackName, + artistName: item.artistName, + spotifyId: item.historyItem?.spotifyId ?? '', + durationMs: + ((item.historyItem?.duration ?? item.localItem?.duration) ?? 0) * + 1000, + ); + + String? coverPath; + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + item.filePath, + coverOutput, + ); + if (coverResult['error'] == null) { + coverPath = coverOutput; + } + } catch (_) {} + + String workingPath = item.filePath; + final isSaf = isContentUri(item.filePath); + String? safTempPath; + + if (isSaf) { + safTempPath = await PlatformBridge.copyContentUriToTemp( + item.filePath, + ); + if (safTempPath == null) continue; + workingPath = safTempPath; + } + + final newPath = await FFmpegService.convertAudioFormat( + inputPath: workingPath, + targetFormat: targetFormat.toLowerCase(), + bitrate: bitrate, + metadata: metadata, + coverPath: coverPath, + artistTagMode: settings.artistTagMode, + deleteOriginal: !isSaf, + sourceBitDepth: + item.historyItem?.bitDepth ?? item.localItem?.bitDepth, + losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, + ); + + if (coverPath != null) { + try { + await File(coverPath).delete(); + } catch (_) {} + } + + if (newPath == null) { + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + continue; + } + + final sourceBitDepth = + item.historyItem?.bitDepth ?? item.localItem?.bitDepth; + final sourceSampleRate = + item.historyItem?.sampleRate ?? item.localItem?.sampleRate; + final isLosslessOutput = isLosslessConversionTarget(targetFormat); + int? convertedBitDepth; + int? convertedSampleRate; + if (isLosslessOutput) { + try { + final convertedMetadata = await PlatformBridge.readFileMetadata( + newPath, + ); + if (convertedMetadata['error'] == null) { + convertedBitDepth = readPositiveAudioInt( + convertedMetadata['bit_depth'], + ); + convertedSampleRate = readPositiveAudioInt( + convertedMetadata['sample_rate'], + ); + } + } catch (_) {} + convertedBitDepth ??= losslessQuality.effectiveBitDepth( + sourceBitDepth, + ); + convertedSampleRate ??= losslessQuality.effectiveSampleRate( + sourceSampleRate, + ); + } + final newQuality = convertedAudioQualityLabel( + targetFormat: targetFormat, + bitrate: bitrate, + labels: losslessLabels, + losslessQuality: losslessQuality, + actualBitDepth: convertedBitDepth, + actualSampleRate: convertedSampleRate, + ); + + if (isSaf && item.historyItem != null) { + final hi = item.historyItem!; + final treeUri = hi.downloadTreeUri; + final relativeDir = hi.safRelativeDir ?? ''; + if (treeUri != null && treeUri.isNotEmpty) { + final oldFileName = hi.safFileName ?? ''; + final dotIdx = oldFileName.lastIndexOf('.'); + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final convTarget = convertTargetExtAndMime(targetFormat); + final newExt = convTarget.ext; + final mimeType = convTarget.mime; + final newFileName = '$baseName$newExt'; + + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: newFileName, + mimeType: mimeType, + srcPath: newPath, + ); + + if (safUri == null || safUri.isEmpty) { + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + continue; + } + + if (!isSameContentUri(item.filePath, safUri)) { + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} + } + + await historyDb.updateFilePath( + hi.id, + safUri, + newSafFileName: newFileName, + newQuality: newQuality, + newFormat: normalizedConvertedAudioFormat(targetFormat), + newBitrate: convertedAudioBitrateKbps( + targetFormat: targetFormat, + bitrate: bitrate, + ), + newBitDepth: convertedBitDepth, + newSampleRate: convertedSampleRate, + clearAudioSpecs: !isLosslessOutput, + ); + } + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + } else if (isSaf && item.localItem != null) { + final uri = Uri.parse(item.filePath); + final pathSegments = uri.pathSegments; + + String? treeUri; + String relativeDir = ''; + String oldFileName = ''; + + final treeIdx = pathSegments.indexOf('tree'); + final docIdx = pathSegments.indexOf('document'); + if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { + final treeId = pathSegments[treeIdx + 1]; + treeUri = + 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; + } + if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { + final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); + final slashIdx = docPath.lastIndexOf('/'); + if (slashIdx >= 0) { + oldFileName = docPath.substring(slashIdx + 1); + final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length + ? Uri.decodeFull(pathSegments[treeIdx + 1]) + : ''; + if (treeId.isNotEmpty && docPath.startsWith(treeId)) { + final afterTree = docPath.substring(treeId.length); + final trimmed = afterTree.startsWith('/') + ? afterTree.substring(1) + : afterTree; + final lastSlash = trimmed.lastIndexOf('/'); + relativeDir = lastSlash >= 0 + ? trimmed.substring(0, lastSlash) + : ''; + } + } else { + oldFileName = docPath; + } + } + + if (treeUri != null && oldFileName.isNotEmpty) { + final dotIdx = oldFileName.lastIndexOf('.'); + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final convTarget = convertTargetExtAndMime(targetFormat); + final newExt = convTarget.ext; + final mimeType = convTarget.mime; + final newFileName = '$baseName$newExt'; + + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: newFileName, + mimeType: mimeType, + srcPath: newPath, + ); + + if (safUri == null || safUri.isEmpty) { + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + continue; + } + + if (!isSameContentUri(item.filePath, safUri)) { + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} + } + await LibraryDatabase.instance.replaceWithConvertedItem( + item: item.localItem!, + newFilePath: safUri, + targetFormat: targetFormat, + bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, + ); + } + + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + } else if (item.historyItem != null) { + await historyDb.updateFilePath( + item.historyItem!.id, + newPath, + newQuality: newQuality, + newFormat: normalizedConvertedAudioFormat(targetFormat), + newBitrate: convertedAudioBitrateKbps( + targetFormat: targetFormat, + bitrate: bitrate, + ), + newBitDepth: convertedBitDepth, + newSampleRate: convertedSampleRate, + clearAudioSpecs: !isLosslessOutput, + ); + } else if (item.localItem != null) { + await LibraryDatabase.instance.replaceWithConvertedItem( + item: item.localItem!, + newFilePath: newPath, + targetFormat: targetFormat, + bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, + ); + } + + successCount++; + } catch (_) {} + } + + ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); + ref.read(localLibraryProvider.notifier).reloadFromStorage(); + + _exitSelectionMode(); + + if (mounted) { + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionBatchConvertSuccess( + successCount, + total, + targetFormat, + ), + ), + ), + ); + } + } + + /// Batch-scan loudness and write ReplayGain tags to the selected tracks. + Future _runBatchReplayGain(List allItems) async { + final itemsById = {for (final item in allItems) item.id: item}; + final selectedItems = []; + for (final id in _selectedIds) { + final item = itemsById[id]; + if (item == null) continue; + selectedItems.add(item); + } + + if (selectedItems.isEmpty) return; + + _suppressSelectionOverlay = true; + _hideSelectionOverlay(); + _hidePlaylistSelectionOverlay(); + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(ctx.l10n.replayGainBatchConfirmTitle), + content: Text( + ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(ctx.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(ctx.l10n.replayGainBatchConfirmTitle), + ), + ], + ), + ); + + if (!mounted) { + _suppressSelectionOverlay = false; + return; + } + if (confirmed != true) { + // Restore after the dialog's exit animation. + await Future.delayed(const Duration(milliseconds: 220)); + _suppressSelectionOverlay = false; + if (!mounted) return; + if (_isSelectionMode) { + _syncSelectionOverlay( + items: allItems, + bottomPadding: MediaQuery.paddingOf(context).bottom, + ); + } + return; + } + _suppressSelectionOverlay = false; + + var cancelled = false; + int successCount = 0; + final total = selectedItems.length; + + BatchProgressDialog.show( + context: context, + title: context.l10n.replayGainBatchAnalyzing, + total: total, + icon: Icons.graphic_eq, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + for (int i = 0; i < total; i++) { + if (!mounted || cancelled) break; + final item = selectedItems[i]; + BatchProgressDialog.update(current: i + 1, detail: item.trackName); + try { + final ok = await ReplayGainService.applyToFile(item.filePath); + if (ok) successCount++; + } catch (_) {} + } + + _exitSelectionMode(); + + if (!mounted) return; + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.replayGainBatchSuccess(successCount, total)), + ), + ); + } + +} diff --git a/lib/screens/queue_tab_collection_items.dart b/lib/screens/queue_tab_collection_items.dart new file mode 100644 index 00000000..1cd2d97b --- /dev/null +++ b/lib/screens/queue_tab_collection_items.dart @@ -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 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( + 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 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( + 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), + ), + ), + ], + ), + ); + }, + ); + } + } + +} diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart new file mode 100644 index 00000000..5730ffc7 --- /dev/null +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -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 [] + : ref + .watch( + downloadQueueLookupProvider.select((lookup) { + final ids = []; + 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 = { + for (final item in allFilteredUnifiedItems) item.id, + }; + List bridgeIds = const []; + if (filterMode != 'albums' && _completionBridge.isNotEmpty) { + final now = DateTime.now(); + final stale = []; + final pending = []; + 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 filteredUnifiedItems = allFilteredUnifiedItems; + if (bridgeIds.isNotEmpty) { + final pinnedHistoryIds = {for (final id in bridgeIds) 'dl_$id'}; + filteredUnifiedItems = allFilteredUnifiedItems + .where((item) => !pinnedHistoryIds.contains(item.id)) + .toList(growable: false); + } + + final downloadedNavigationItems = []; + final downloadedNavigationIndexByUnifiedId = {}; + final localNavigationItems = []; + final localNavigationIndexByUnifiedId = {}; + + 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( + 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( + 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( + 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 _showClearAllDialog( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, + ) async { + final confirmed = await showDialog( + 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( + 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 _selectedItemsFromAll( + List allItems, + ) { + final itemsById = {for (final item in allItems) item.id: item}; + return _selectedIds + .map((id) => itemsById[id]) + .whereType() + .toList(growable: false); + } + + bool _isLocalOnlySelection(List allItems) { + final selectedItems = _selectedItemsFromAll(allItems); + return selectedItems.isNotEmpty && + selectedItems.every((item) => item.localItem != null); + } + +} diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart new file mode 100644 index 00000000..0870074a --- /dev/null +++ b/lib/screens/queue_tab_item_widgets.dart @@ -0,0 +1,1269 @@ +part of 'queue_tab.dart'; + +extension _QueueTabItemWidgets on _QueueTabState { + Widget _buildSelectionBottomBar( + BuildContext context, + ColorScheme colorScheme, + List unifiedItems, + double bottomPadding, + ) { + final selectedCount = _selectedIds.length; + final allSelected = + selectedCount == unifiedItems.length && unifiedItems.isNotEmpty; + final localOnlySelection = _isLocalOnlySelection(unifiedItems); + final flacEligibleCount = _selectedFlacEligibleLocalItems( + unifiedItems, + ).length; + + 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: _exitSelectionMode, + 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.selectionAllSelected + : context.l10n.downloadedAlbumTapToSelect, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + + TextButton.icon( + onPressed: () { + if (allSelected) { + _exitSelectionMode(); + } else { + _selectAll(unifiedItems); + } + }, + 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), + + LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final itemWidth = (constraints.maxWidth - spacing) / 2; + final actions = []; + + if (localOnlySelection && flacEligibleCount > 0) { + actions.add( + _SelectionActionButton( + icon: Icons.download_for_offline_outlined, + label: + '${context.l10n.queueFlacAction} ($flacEligibleCount)', + onPressed: () => + _queueSelectedLocalAsFlac(unifiedItems), + colorScheme: colorScheme, + ), + ); + } + + actions.add( + _SelectionActionButton( + icon: localOnlySelection + ? Icons.auto_fix_high_outlined + : Icons.share_outlined, + label: localOnlySelection + ? '${context.l10n.trackReEnrich} ($selectedCount)' + : context.l10n.selectionShareCount(selectedCount), + onPressed: selectedCount > 0 + ? () => localOnlySelection + ? _reEnrichSelectedLocalFromQueue(unifiedItems) + : _shareSelected(unifiedItems) + : null, + colorScheme: colorScheme, + ), + ); + + actions.add( + _SelectionActionButton( + icon: Icons.swap_horiz, + label: context.l10n.selectionConvertCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _showBatchConvertSheet(context, unifiedItems) + : null, + colorScheme: colorScheme, + ), + ); + + actions.add( + _SelectionActionButton( + icon: Icons.graphic_eq, + label: context.l10n.selectionReplayGainCount( + selectedCount, + ), + onPressed: selectedCount > 0 + ? () => _runBatchReplayGain(unifiedItems) + : null, + colorScheme: colorScheme, + ), + ); + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final action in actions) + SizedBox(width: itemWidth, child: action), + ], + ); + }, + ), + + const SizedBox(height: 8), + + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 + ? () => _deleteSelected(unifiedItems) + : null, + icon: const Icon(Icons.delete_outline), + label: Text( + selectedCount > 0 + ? context.l10n.selectionDeleteTracksCount(selectedCount) + : context.l10n.selectionSelectToDelete, + ), + 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), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildQueueItem( + BuildContext context, + DownloadItem item, + ColorScheme colorScheme, + ) { + final isCompleted = item.status == DownloadStatus.completed; + final isActive = + item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing; + + return Dismissible( + key: ValueKey('dismiss_${item.id}'), + direction: DismissDirection.endToStart, + confirmDismiss: isActive + ? (_) async { + return await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.cancelDownloadTitle), + content: Text( + context.l10n.cancelDownloadContent(item.track.name), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(context.l10n.cancelDownloadKeep), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(context.l10n.dialogCancel), + ), + ], + ), + ) ?? + false; + } + : null, + onDismissed: (_) { + ref.read(downloadQueueProvider.notifier).dismissItem(item.id); + }, + background: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: Icon(Icons.delete_outline, color: colorScheme.onErrorContainer), + ), + child: DownloadSuccessOverlay( + showSuccess: isCompleted, + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: isCompleted + ? () => _navigateToMetadataScreen(item) + : item.status == DownloadStatus.failed + ? () => _showDownloadErrorDialog(context, item) + : null, + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + if (item.status == DownloadStatus.downloading) + Positioned.fill( + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: item.progress.clamp(0.0, 1.0), + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + colorScheme.primary.withValues(alpha: 0.16), + colorScheme.primary.withValues(alpha: 0.04), + ], + ), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + isCompleted + ? Hero( + tag: 'cover_${item.id}', + child: _buildCoverArt(item, colorScheme), + ) + : _buildCoverArt(item, colorScheme), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.track.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + ClickableArtistName( + artistName: item.track.artistName, + artistId: item.track.artistId, + coverUrl: item.track.coverUrl, + extensionId: item.track.source, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (item.status == DownloadStatus.downloading) ...[ + const SizedBox(height: 5), + Row( + children: [ + Icon( + Icons.download_rounded, + size: 12, + color: colorScheme.primary, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + _formatDownloadStatusLine(context, item), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + if (item.status == DownloadStatus.failed) ...[ + const SizedBox(height: 4), + _buildDownloadFailureMessage( + context, + item, + colorScheme, + ), + ], + ], + ), + ), + const SizedBox(width: 8), + _buildActionButtons(context, item, colorScheme), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// Download error messages are stored as fixed English sentinels on the + /// item (so code can match on them); translate the known ones for display. + String _localizedDownloadError(BuildContext context, String raw) { + if (raw == safPermissionLostErrorMessage) { + return context.l10n.downloadErrorSafPermissionLost; + } + if (raw == downloadFolderAccessLostErrorMessage) { + return context.l10n.downloadErrorFolderAccessLost; + } + return raw; + } + + Widget _buildDownloadFailureMessage( + BuildContext context, + DownloadItem item, + ColorScheme colorScheme, + ) { + if (item.errorType != DownloadErrorType.rateLimit) { + return Text( + _localizedDownloadError(context, item.errorMessage), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: colorScheme.error), + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon( + Icons.hourglass_top_rounded, + size: 14, + color: colorScheme.tertiary, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.queueRateLimitTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.tertiary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 1), + Text( + context.l10n.queueRateLimitMessage, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.tertiary.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) { + final coverSize = _queueCoverSize(); + final radius = BorderRadius.circular(8); + + final cover = item.track.coverUrl != null + ? CachedCoverImage( + imageUrl: item.track.coverUrl!, + width: coverSize, + height: coverSize, + borderRadius: radius, + fadeInDuration: const Duration(milliseconds: 180), + fadeOutDuration: const Duration(milliseconds: 90), + ) + : Container( + width: coverSize, + height: coverSize, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: radius, + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ); + + final isDownloading = + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing; + if (!isDownloading) return cover; + + final progress = item.progress.clamp(0.0, 1.0); + final indeterminate = + item.status == DownloadStatus.finalizing || progress <= 0; + + return SizedBox( + width: coverSize, + height: coverSize, + child: Stack( + fit: StackFit.expand, + children: [ + cover, + ClipRRect( + borderRadius: radius, + child: ColoredBox(color: Colors.black.withValues(alpha: 0.45)), + ), + Center( + child: SizedBox( + width: coverSize * 0.6, + height: coverSize * 0.6, + child: CircularProgressIndicator( + value: indeterminate ? null : progress, + strokeWidth: 3, + color: Colors.white, + backgroundColor: Colors.white.withValues(alpha: 0.25), + ), + ), + ), + if (!indeterminate) + Center( + child: Text( + '${(progress * 100).round()}', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } + + Widget _buildActionButtons( + BuildContext context, + DownloadItem item, + ColorScheme colorScheme, + ) { + switch (item.status) { + case DownloadStatus.queued: + return IconButton( + onPressed: () => + ref.read(downloadQueueProvider.notifier).cancelItem(item.id), + icon: Icon(Icons.close, color: colorScheme.error), + tooltip: context.l10n.dialogCancel, + style: IconButton.styleFrom( + backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), + ), + ); + case DownloadStatus.downloading: + return IconButton( + onPressed: () => + ref.read(downloadQueueProvider.notifier).cancelItem(item.id), + icon: Icon(Icons.stop, color: colorScheme.error), + tooltip: context.l10n.actionStop, + style: IconButton.styleFrom( + backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), + ), + ); + case DownloadStatus.finalizing: + return Semantics( + label: context.l10n.queueFinalizingDownload, + child: SizedBox( + width: 40, + height: 40, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + strokeWidth: 3, + color: colorScheme.tertiary, + ), + ExcludeSemantics( + child: Icon( + Icons.edit_note, + color: colorScheme.tertiary, + size: 16, + ), + ), + ], + ), + ), + ); + case DownloadStatus.completed: + return ValueListenableBuilder( + valueListenable: _fileExistsListenable(item.filePath), + builder: (context, fileExists, child) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (fileExists) + IconButton( + onPressed: () => _openFile( + item.filePath!, + title: item.track.name, + artist: item.track.artistName, + album: item.track.albumName, + coverUrl: item.track.coverUrl ?? '', + ), + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + tooltip: context.l10n.tooltipPlay, + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues( + alpha: 0.3, + ), + ), + ) + else + Semantics( + label: context.l10n.queueDownloadedFileMissing, + child: ExcludeSemantics( + child: Icon( + Icons.error_outline, + color: colorScheme.error, + size: 20, + ), + ), + ), + const SizedBox(width: 4), + Semantics( + label: context.l10n.queueDownloadCompleted, + child: ExcludeSemantics( + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + color: colorScheme.onPrimaryContainer, + size: 20, + ), + ), + ), + ), + ], + ); + }, + ); + case DownloadStatus.failed: + case DownloadStatus.skipped: + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => + ref.read(downloadQueueProvider.notifier).retryItem(item.id), + icon: Icon(Icons.refresh, color: colorScheme.primary), + tooltip: context.l10n.dialogRetry, + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues( + alpha: 0.3, + ), + ), + ), + const SizedBox(width: 4), + IconButton( + onPressed: () => + ref.read(downloadQueueProvider.notifier).removeItem(item.id), + icon: Icon( + Icons.close, + color: item.status == DownloadStatus.failed + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), + tooltip: context.l10n.dialogRemove, + style: item.status == DownloadStatus.failed + ? IconButton.styleFrom( + backgroundColor: colorScheme.errorContainer.withValues( + alpha: 0.3, + ), + ) + : null, + ), + ], + ); + } + } + + Widget _buildFilterButton( + BuildContext context, + List unifiedItems, + ) { + return GestureDetector( + onLongPress: _activeFilterCount > 0 ? _resetFilters : null, + child: TextButton.icon( + onPressed: () => _showFilterSheet(context, unifiedItems), + icon: Badge( + isLabelVisible: _activeFilterCount > 0, + label: Text('$_activeFilterCount'), + child: const Icon(Icons.filter_list, size: 18), + ), + label: Text(context.l10n.libraryFilterTitle), + style: TextButton.styleFrom(visualDensity: VisualDensity.compact), + ), + ); + } + + /// When [size] is provided, renders at fixed dimensions (list mode). + /// When [size] is null, fills the parent container (grid mode). + Widget _buildUnifiedCoverImage( + UnifiedLibraryItem item, + ColorScheme colorScheme, [ + double? size, + ]) { + final isDownloaded = item.source == LibraryItemSource.downloaded; + + // For downloaded items, listen to embedded cover version so the cover + // updates after async extraction completes. + if (isDownloaded) { + return ValueListenableBuilder( + valueListenable: _embeddedCoverVersion, + builder: (context, _, child) => + _buildUnifiedCoverImageInner(item, colorScheme, isDownloaded, size), + ); + } + + return _buildUnifiedCoverImageInner(item, colorScheme, isDownloaded, size); + } + + Widget _buildUnifiedCoverImageInner( + UnifiedLibraryItem item, + ColorScheme colorScheme, + bool isDownloaded, [ + double? size, + ]) { + final cacheSize = size != null ? (size * 2).toInt() : 200; + final iconSize = size != null ? size * 0.4 : 32.0; + + Widget buildPlaceholder({bool isLocal = false}) { + final bgColor = (isDownloaded && !isLocal) + ? colorScheme.surfaceContainerHighest + : colorScheme.secondaryContainer; + final fgColor = (isDownloaded && !isLocal) + ? colorScheme.onSurfaceVariant + : colorScheme.onSecondaryContainer; + return Container( + width: size, + height: size, + decoration: size != null + ? BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(8), + ) + : null, + color: size != null ? null : bgColor, + child: Center( + child: Icon(Icons.music_note, color: fgColor, size: iconSize), + ), + ); + } + + Widget fadeInFileImage(Widget child, int? frame, bool wasSync) { + if (wasSync) return child; + final Widget backdrop; + if (isDownloaded && item.coverUrl != null) { + backdrop = CachedCoverImage( + imageUrl: item.coverUrl!, + width: size, + height: size, + memCacheWidth: cacheSize, + memCacheHeight: cacheSize, + placeholder: (context, url) => buildPlaceholder(), + errorWidget: (context, url, error) => buildPlaceholder(), + ); + } else { + backdrop = buildPlaceholder(isLocal: !isDownloaded); + } + final animated = Stack( + fit: StackFit.expand, + children: [ + backdrop, + AnimatedOpacity( + opacity: frame == null ? 0.0 : 1.0, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: child, + ), + ], + ); + if (size == null) return animated; + return SizedBox(width: size, height: size, child: animated); + } + + if (isDownloaded) { + final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( + item.filePath, + ); + if (embeddedCoverPath != null) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(embeddedCoverPath), + width: size, + height: size, + fit: BoxFit.cover, + cacheWidth: cacheSize, + cacheHeight: cacheSize, + gaplessPlayback: true, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => + fadeInFileImage(child, frame, wasSynchronouslyLoaded), + errorBuilder: (context, error, stackTrace) => buildPlaceholder(), + ), + ); + } + } + + if (item.coverUrl != null) { + return CachedCoverImage( + imageUrl: item.coverUrl!, + width: size, + height: size, + memCacheWidth: cacheSize, + memCacheHeight: cacheSize, + borderRadius: BorderRadius.circular(8), + placeholder: (context, url) => buildPlaceholder(), + errorWidget: (context, url, error) => buildPlaceholder(), + fadeInDuration: const Duration(milliseconds: 180), + fadeOutDuration: const Duration(milliseconds: 90), + ); + } + + if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(item.localCoverPath!), + width: size, + height: size, + fit: BoxFit.cover, + cacheWidth: cacheSize, + cacheHeight: cacheSize, + gaplessPlayback: true, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => + fadeInFileImage(child, frame, wasSynchronouslyLoaded), + errorBuilder: (context, error, stackTrace) => + buildPlaceholder(isLocal: true), + ), + ); + } + + if (size != null) { + return buildPlaceholder(); + } + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: buildPlaceholder(), + ); + } + + Widget _buildUnifiedLibraryItem( + BuildContext context, + UnifiedLibraryItem item, + ColorScheme colorScheme, { + required List downloadedNavigationItems, + required int? downloadedNavigationIndex, + required List localNavigationItems, + required int? localNavigationIndex, + required List libraryItems, + }) { + final fileExistsListenable = _fileExistsListenable(item.filePath); + final isSelected = _selectedIds.contains(item.id); + final date = item.addedAt; + final dateStr = + '${_QueueTabState._months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + + final isDownloaded = item.source == LibraryItemSource.downloaded; + final sourceLabel = isDownloaded + ? context.l10n.librarySourceDownloaded + : context.l10n.librarySourceLocal; + final sourceColor = isDownloaded + ? colorScheme.primaryContainer + : colorScheme.secondaryContainer; + final sourceTextColor = isDownloaded + ? colorScheme.onPrimaryContainer + : colorScheme.onSecondaryContainer; + + return Semantics( + label: context.l10n.a11yTrackByArtist(item.trackName, item.artistName), + selected: isSelected, + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : null, + child: InkWell( + onTap: _isSelectionMode + ? () => _toggleSelection(item.id) + : isDownloaded + ? () => _navigateToHistoryMetadataScreen( + item.historyItem!, + navigationItems: downloadedNavigationItems, + navigationIndex: downloadedNavigationIndex, + ) + : item.localItem != null + ? () => _navigateToLocalMetadataScreen( + item.localItem!, + navigationItems: localNavigationItems, + navigationIndex: localNavigationIndex, + ) + : () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: item.coverUrl ?? item.localCoverPath ?? '', + ), + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(item.id), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + if (_isSelectionMode) ...[ + Semantics( + checked: isSelected, + label: isSelected + ? context.l10n.a11yDeselectTrack + : context.l10n.a11ySelectTrack, + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, + ), + ), + const SizedBox(width: 12), + ], + Hero( + tag: 'cover_lib_${item.id}', + child: _buildUnifiedCoverImage(item, colorScheme, 56), + ), + const SizedBox(width: 12), + + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + ClickableArtistName( + artistName: item.artistName, + coverUrl: item.coverUrl, + extensionId: item.historyItem?.service, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: sourceColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + sourceLabel, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: sourceTextColor, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + dateStr, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant + .withValues(alpha: 0.7), + ), + ), + ), + if (item.quality != null && + item.quality!.isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: item.quality!.startsWith('24') + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + item.quality!, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: item.quality!.startsWith('24') + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + ], + ), + ), + const SizedBox(width: 8), + + if (!_isSelectionMode) + ValueListenableBuilder( + valueListenable: fileExistsListenable, + builder: (context, fileExists, child) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (fileExists) + IconButton( + onPressed: () => + _playLibraryItem(item, libraryItems), + icon: Icon( + Icons.play_arrow, + color: colorScheme.primary, + ), + tooltip: context.l10n.tooltipPlay, + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer + .withValues(alpha: 0.3), + ), + ) + else + Icon( + Icons.error_outline, + color: colorScheme.error, + size: 20, + ), + ], + ); + }, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildUnifiedGridItem( + BuildContext context, + UnifiedLibraryItem item, + ColorScheme colorScheme, { + required List downloadedNavigationItems, + required int? downloadedNavigationIndex, + required List localNavigationItems, + required int? localNavigationIndex, + required List libraryItems, + }) { + final fileExistsListenable = _fileExistsListenable(item.filePath); + final isSelected = _selectedIds.contains(item.id); + final isDownloaded = item.source == LibraryItemSource.downloaded; + + return GestureDetector( + onTap: _isSelectionMode + ? () => _toggleSelection(item.id) + : isDownloaded + ? () => _navigateToHistoryMetadataScreen( + item.historyItem!, + navigationItems: downloadedNavigationItems, + navigationIndex: downloadedNavigationIndex, + ) + : item.localItem != null + ? () => _navigateToLocalMetadataScreen( + item.localItem!, + navigationItems: localNavigationItems, + navigationIndex: localNavigationIndex, + ) + : () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: item.coverUrl ?? item.localCoverPath ?? '', + ), + onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + AspectRatio( + aspectRatio: 1, + child: Hero( + tag: 'cover_lib_${item.id}', + child: _buildUnifiedCoverImage(item, colorScheme), + ), + ), + Positioned( + right: 4, + top: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + decoration: BoxDecoration( + color: isDownloaded + ? colorScheme.primaryContainer + : colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Icon( + isDownloaded ? Icons.download_done : Icons.folder, + size: 12, + color: isDownloaded + ? colorScheme.onPrimaryContainer + : colorScheme.onSecondaryContainer, + ), + ), + ), + if (item.quality != null && item.quality!.isNotEmpty) + Positioned( + left: 4, + top: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + decoration: BoxDecoration( + color: item.quality!.startsWith('24') + ? colorScheme.primary + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _getQualityBadgeText(item.quality!), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: item.quality!.startsWith('24') + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, + fontSize: 9, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + if (!_isSelectionMode) + Positioned( + right: 4, + bottom: 4, + child: ValueListenableBuilder( + valueListenable: fileExistsListenable, + builder: (context, fileExists, child) { + return fileExists + ? Semantics( + button: true, + label: context.l10n.a11yPlayTrackByArtist( + item.trackName, + item.artistName, + ), + child: GestureDetector( + onTap: () => + _playLibraryItem(item, libraryItems), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + child: ExcludeSemantics( + child: Icon( + Icons.play_arrow, + color: colorScheme.onPrimary, + size: 16, + ), + ), + ), + ), + ) + : Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.error_outline, + color: colorScheme.error, + size: 14, + ), + ); + }, + ), + ), + if (_isSelectionMode) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), + ), + ClickableArtistName( + artistName: item.artistName, + coverUrl: item.coverUrl, + extensionId: item.historyItem?.service, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + if (_isSelectionMode) + Positioned( + right: 4, + top: 4, + child: Container( + decoration: BoxDecoration( + color: isSelected ? colorScheme.primary : colorScheme.surface, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) + : const SizedBox(width: 16, height: 16), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/queue_tab_navigation.dart b/lib/screens/queue_tab_navigation.dart new file mode 100644 index 00000000..d298e3ca --- /dev/null +++ b/lib/screens/queue_tab_navigation.dart @@ -0,0 +1,585 @@ +part of 'queue_tab.dart'; + +extension _QueueTabNavigation on _QueueTabState { + Future _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 _playLibraryItem( + UnifiedLibraryItem item, + List 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 _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(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 _navigateToHistoryMetadataScreen( + DownloadHistoryItem item, { + List? 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( + 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? navigationItems, + int? navigationIndex, + }) { + _searchFocusNode.unfocus(); + Navigator.push( + context, + slidePageRoute( + page: TrackMetadataScreen( + localItem: item, + localNavigationItems: navigationItems, + navigationIndex: navigationIndex, + coverHeroTag: 'cover_lib_local_${item.id}', + ), + ), + ).then((_) => _searchFocusNode.unfocus()); + } + + List _filterHistoryItems( + List items, + String filterMode, + Map 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 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 _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 _showCreatePlaylistDialog(BuildContext context) async { + final controller = TextEditingController(); + final formKey = GlobalKey(); + + final playlistName = await showDialog( + 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 _onTrackDroppedOnPlaylist( + BuildContext context, + UnifiedLibraryItem item, + String playlistId, + String playlistName, { + List 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), + ), + ), + ], + ), + ), + ); + } + +} diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart new file mode 100644 index 00000000..a741744e --- /dev/null +++ b/lib/screens/queue_tab_selection.dart @@ -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 items) { + _setState(() { + _selectedIds.addAll(items.map((e) => e.id)); + }); + } + + void _hideSelectionOverlay() { + _selectionOverlayEntry?.remove(); + _selectionOverlayEntry = null; + } + + void _syncSelectionOverlay({ + required List 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 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 playlists) { + _setState(() { + _selectedPlaylistIds.addAll(playlists.map((e) => e.id)); + }); + } + + Future _downloadAllSelectedPlaylists(BuildContext context) async { + final collectionsState = ref.read(libraryCollectionsProvider); + final selectedPlaylists = collectionsState.playlists + .where((p) => _selectedPlaylistIds.contains(p.id)) + .toList(); + + final totalTracks = selectedPlaylists.fold( + 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( + 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 _deleteSelectedPlaylists(BuildContext context) async { + final count = _selectedPlaylistIds.length; + final confirmed = await showDialog( + 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 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 _deleteSelected(List allItems) async { + final count = _selectedIds.length; + final confirmed = await showDialog( + 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 _readFileModTimeMillis(String? filePath) async { + return DownloadedEmbeddedCoverResolver.readFileModTimeMillis(filePath); + } + +}