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), ), ), ], ), ), ); } }