import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/path_match_keys.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; enum LibraryItemSource { downloaded, local } class UnifiedLibraryItem { final String id; final String trackName; final String artistName; final String albumName; final String? coverUrl; final String? localCoverPath; final String filePath; final String? quality; final DateTime addedAt; final LibraryItemSource source; final DownloadHistoryItem? historyItem; final LocalLibraryItem? localItem; UnifiedLibraryItem({ required this.id, required this.trackName, required this.artistName, required this.albumName, this.coverUrl, this.localCoverPath, required this.filePath, this.quality, required this.addedAt, required this.source, this.historyItem, this.localItem, }); factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) { return UnifiedLibraryItem( id: 'dl_${item.id}', trackName: item.trackName, artistName: item.artistName, albumName: item.albumName, coverUrl: item.coverUrl, filePath: item.filePath, quality: buildDisplayAudioQuality( bitDepth: item.bitDepth, sampleRate: item.sampleRate, storedQuality: item.quality, ), addedAt: item.downloadedAt, source: LibraryItemSource.downloaded, historyItem: item, ); } factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) { String? quality; if (item.bitrate != null && item.bitrate! > 0) { quality = buildDisplayAudioQuality( bitrateKbps: item.bitrate, format: item.format, ); } else if (item.bitDepth != null && item.bitDepth! > 0 && item.sampleRate != null) { quality = buildDisplayAudioQuality( bitDepth: item.bitDepth, sampleRate: item.sampleRate, ); } return UnifiedLibraryItem( id: 'local_${item.id}', trackName: item.trackName, artistName: item.artistName, albumName: item.albumName, coverUrl: null, localCoverPath: item.coverPath, filePath: item.filePath, quality: quality, addedAt: item.fileModTime != null ? DateTime.fromMillisecondsSinceEpoch(item.fileModTime!) : item.scannedAt, source: LibraryItemSource.local, localItem: item, ); } bool get hasCover => coverUrl != null || (localCoverPath != null && localCoverPath!.isNotEmpty); String? get albumArtist => historyItem?.albumArtist ?? localItem?.albumArtist; String? get releaseDate => historyItem?.releaseDate ?? localItem?.releaseDate; String? get genre => historyItem?.genre ?? localItem?.genre; String get searchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}'; String get albumKey => '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; /// Returns the collection key used to match this item against playlist /// entries. Uses the same logic as [trackCollectionKey] from the collections /// provider: prefer ISRC, fall back to source:id. String get collectionKey { if (historyItem != null) { final isrc = historyItem!.isrc?.trim(); if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; final source = historyItem!.service.trim().isNotEmpty ? historyItem!.service.trim() : 'builtin'; return '$source:${historyItem!.id}'; } if (localItem != null) { final isrc = localItem!.isrc?.trim(); if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; return 'local:${localItem!.id}'; } return 'builtin:$id'; } Track toTrack() { if (historyItem != null) { final h = historyItem!; return Track( id: h.id, name: h.trackName, artistName: h.artistName, albumName: h.albumName, albumArtist: h.albumArtist, coverUrl: h.coverUrl, isrc: h.isrc, duration: h.duration ?? 0, trackNumber: h.trackNumber, discNumber: h.discNumber, releaseDate: h.releaseDate, source: h.service, ); } if (localItem != null) { final l = localItem!; return Track( id: l.id, name: l.trackName, artistName: l.artistName, albumName: l.albumName, albumArtist: l.albumArtist, coverUrl: l.coverPath, isrc: l.isrc, duration: l.duration ?? 0, trackNumber: l.trackNumber, discNumber: l.discNumber, releaseDate: l.releaseDate, source: 'local', ); } return Track( id: id, name: trackName, artistName: artistName, albumName: albumName, coverUrl: coverUrl, duration: 0, ); } } class _GroupedAlbum { final String albumName; final String artistName; final String? coverUrl; final String sampleFilePath; final List tracks; final DateTime latestDownload; final String searchKey; _GroupedAlbum({ required this.albumName, required this.artistName, this.coverUrl, required this.sampleFilePath, required this.tracks, required this.latestDownload, }) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; String get key => '$albumName|$artistName'; } class _GroupedLocalAlbum { final String albumName; final String artistName; final String? coverPath; final List tracks; final DateTime latestScanned; final String searchKey; _GroupedLocalAlbum({ required this.albumName, required this.artistName, this.coverPath, required this.tracks, required this.latestScanned, }) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; String get key => '$albumName|$artistName'; } class _HistoryStats { final Map albumCounts; final Map localAlbumCounts; final List<_GroupedAlbum> groupedAlbums; final List<_GroupedLocalAlbum> groupedLocalAlbums; final int albumCount; final int singleTracks; final int localAlbumCount; final int localSingleTracks; const _HistoryStats({ required this.albumCounts, this.localAlbumCounts = const {}, required this.groupedAlbums, this.groupedLocalAlbums = const [], required this.albumCount, required this.singleTracks, this.localAlbumCount = 0, this.localSingleTracks = 0, }); int get totalAlbumCount => albumCount + localAlbumCount; int get totalSingleTracks => singleTracks + localSingleTracks; } class _FilterContentData { final List historyItems; final List unifiedItems; final List filteredUnifiedItems; final List<_GroupedAlbum> filteredGroupedAlbums; final List<_GroupedLocalAlbum> filteredGroupedLocalAlbums; final bool showFilteringIndicator; const _FilterContentData({ required this.historyItems, required this.unifiedItems, required this.filteredUnifiedItems, required this.filteredGroupedAlbums, required this.filteredGroupedLocalAlbums, required this.showFilteringIndicator, }); int get totalTrackCount => filteredUnifiedItems.length; int get totalAlbumCount => filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length; } class _UnifiedCacheEntry { final List historyItems; final List localItems; final Map localAlbumCounts; final String query; final List items; const _UnifiedCacheEntry({ required this.historyItems, required this.localItems, required this.localAlbumCounts, required this.query, required this.items, }); } class _QueueItemIdsSnapshot { final List ids; const _QueueItemIdsSnapshot(this.ids); @override bool operator ==(Object other) => identical(this, other) || other is _QueueItemIdsSnapshot && listEquals(ids, other.ids); @override int get hashCode => Object.hashAll(ids); } class _QueueGroupedAlbumFilterRequest { final String searchQuery; final String? filterSource; final String? filterQuality; final String? filterFormat; final String? filterMetadata; final String sortMode; const _QueueGroupedAlbumFilterRequest({ required this.searchQuery, required this.filterSource, required this.filterQuality, required this.filterFormat, required this.filterMetadata, required this.sortMode, }); @override bool operator ==(Object other) => identical(this, other) || other is _QueueGroupedAlbumFilterRequest && searchQuery == other.searchQuery && filterSource == other.filterSource && filterQuality == other.filterQuality && filterFormat == other.filterFormat && filterMetadata == other.filterMetadata && sortMode == other.sortMode; @override int get hashCode => Object.hash( searchQuery, filterSource, filterQuality, filterFormat, filterMetadata, sortMode, ); } class _QueueHistoryStatsMemoEntry { final List historyItems; final List localItems; final _HistoryStats stats; const _QueueHistoryStatsMemoEntry({ required this.historyItems, required this.localItems, required this.stats, }); } _QueueHistoryStatsMemoEntry? _queueHistoryStatsMemo; String _queueHistoryAlbumKey(String albumName, String artistName) { return '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; } String _queueFileExtLower(String filePath) { final slashIndex = filePath.lastIndexOf('/'); final dotIndex = filePath.lastIndexOf('.'); if (dotIndex == -1 || dotIndex < slashIndex + 1) { return ''; } return filePath.substring(dotIndex + 1).toLowerCase(); } bool _queueHasMetadataValue(String? value) { return value != null && value.trim().isNotEmpty; } String _queueNormalizedMetadataValue(String? value) { return value?.trim().toLowerCase() ?? ''; } DateTime? _queueParseReleaseDate(String? value) { final trimmed = value?.trim() ?? ''; if (trimmed.isEmpty) { return null; } final parsed = DateTime.tryParse(trimmed); if (parsed != null) { return parsed; } final yearMatch = RegExp(r'(\d{4})').firstMatch(trimmed); if (yearMatch == null) { return null; } final year = int.tryParse(yearMatch.group(1)!); if (year == null || year <= 0) { return null; } return DateTime(year); } bool _queueMatchesMetadataFilter({ required String? filterMetadata, required String? albumArtist, required String? releaseDate, required String? genre, }) { if (filterMetadata == null) { return true; } final hasAlbumArtist = _queueHasMetadataValue(albumArtist); final hasReleaseDate = _queueParseReleaseDate(releaseDate) != null; final hasGenre = _queueHasMetadataValue(genre); final isComplete = hasAlbumArtist && hasReleaseDate && hasGenre; switch (filterMetadata) { case 'complete': return isComplete; case 'missing-any': return !isComplete; case 'missing-year': return !hasReleaseDate; case 'missing-genre': return !hasGenre; case 'missing-album-artist': return !hasAlbumArtist; default: return true; } } bool _queueUnifiedItemMatchesMetadataFilter( UnifiedLibraryItem item, String? filterMetadata, ) { return _queueMatchesMetadataFilter( filterMetadata: filterMetadata, albumArtist: item.albumArtist, releaseDate: item.releaseDate, genre: item.genre, ); } int _queueCompareOptionalText( String? left, String? right, { bool descending = false, }) { final normalizedLeft = _queueNormalizedMetadataValue(left); final normalizedRight = _queueNormalizedMetadataValue(right); final leftEmpty = normalizedLeft.isEmpty; final rightEmpty = normalizedRight.isEmpty; if (leftEmpty && rightEmpty) { return 0; } if (leftEmpty) { return 1; } if (rightEmpty) { return -1; } final comparison = normalizedLeft.compareTo(normalizedRight); return descending ? -comparison : comparison; } int _queueCompareOptionalDate( DateTime? left, DateTime? right, { bool descending = false, }) { if (left == null && right == null) { return 0; } if (left == null) { return 1; } if (right == null) { return -1; } final comparison = left.compareTo(right); return descending ? -comparison : comparison; } DateTime? _queueGroupedAlbumReleaseDate(_GroupedAlbum album) { for (final track in album.tracks) { final releaseDate = _queueParseReleaseDate(track.releaseDate); if (releaseDate != null) { return releaseDate; } } return null; } DateTime? _queueGroupedLocalAlbumReleaseDate(_GroupedLocalAlbum album) { for (final track in album.tracks) { final releaseDate = _queueParseReleaseDate(track.releaseDate); if (releaseDate != null) { return releaseDate; } } return null; } String? _queueGroupedAlbumGenre(_GroupedAlbum album) { for (final track in album.tracks) { if (_queueHasMetadataValue(track.genre)) { return track.genre; } } return null; } String? _queueGroupedLocalAlbumGenre(_GroupedLocalAlbum album) { for (final track in album.tracks) { if (_queueHasMetadataValue(track.genre)) { return track.genre; } } return null; } String? _queueLocalQualityLabel(LocalLibraryItem item) { if (item.bitrate != null && item.bitrate! > 0) { return '${item.bitrate}kbps'; } if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) { return null; } return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; } bool _queuePassesQualityFilter(String? filterQuality, String? quality) { if (filterQuality == null) return true; if (quality == null) return filterQuality == 'lossy'; final normalized = quality.toLowerCase(); switch (filterQuality) { case 'hires': return normalized.startsWith('24'); case 'cd': return normalized.startsWith('16'); case 'lossy': return !normalized.startsWith('24') && !normalized.startsWith('16'); default: return true; } } bool _queuePassesFormatFilter(String? filterFormat, String filePath) { if (filterFormat == null) return true; return _queueFileExtLower(filePath) == filterFormat; } _HistoryStats _buildQueueHistoryStats( List items, [ List localItems = const [], ]) { final memo = _queueHistoryStatsMemo; if (memo != null && identical(memo.historyItems, items) && identical(memo.localItems, localItems)) { return memo.stats; } final albumCounts = {}; final albumMap = >{}; for (final item in items) { final key = _queueHistoryAlbumKey( item.albumName, item.albumArtist ?? item.artistName, ); albumCounts[key] = (albumCounts[key] ?? 0) + 1; albumMap.putIfAbsent(key, () => []).add(item); } var singleTracks = 0; var albumCount = 0; for (final count in albumCounts.values) { if (count > 1) { albumCount++; } else { singleTracks += count; } } final groupedAlbums = <_GroupedAlbum>[]; albumMap.forEach((_, tracks) { if (tracks.length <= 1) return; tracks.sort((a, b) { final aNum = a.trackNumber ?? 999; final bNum = b.trackNumber ?? 999; return aNum.compareTo(bNum); }); groupedAlbums.add( _GroupedAlbum( albumName: tracks.first.albumName, artistName: tracks.first.albumArtist ?? tracks.first.artistName, coverUrl: tracks.first.coverUrl, sampleFilePath: tracks.first.filePath, tracks: tracks, latestDownload: tracks .map((t) => t.downloadedAt) .reduce((a, b) => a.isAfter(b) ? a : b), ), ); }); groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); final downloadedPathKeys = {}; for (final item in items) { downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); } final dedupedLocalItems = localItems .where((item) { final localPathKeys = buildPathMatchKeys(item.filePath); return !localPathKeys.any(downloadedPathKeys.contains); }) .toList(growable: false); final localAlbumCounts = {}; final localAlbumMap = >{}; for (final item in dedupedLocalItems) { final key = _queueHistoryAlbumKey( item.albumName, item.albumArtist ?? item.artistName, ); localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1; localAlbumMap.putIfAbsent(key, () => []).add(item); } var localAlbumCount = 0; var localSingleTracks = 0; for (final count in localAlbumCounts.values) { if (count > 1) { localAlbumCount++; } else { localSingleTracks++; } } final groupedLocalAlbums = <_GroupedLocalAlbum>[]; localAlbumMap.forEach((_, tracks) { if (tracks.length <= 1) return; tracks.sort((a, b) { final aNum = a.trackNumber ?? 999; final bNum = b.trackNumber ?? 999; return aNum.compareTo(bNum); }); groupedLocalAlbums.add( _GroupedLocalAlbum( albumName: tracks.first.albumName, artistName: tracks.first.albumArtist ?? tracks.first.artistName, coverPath: tracks .firstWhere( (t) => t.coverPath != null && t.coverPath!.isNotEmpty, orElse: () => tracks.first, ) .coverPath, tracks: tracks, latestScanned: tracks .map((t) => t.scannedAt) .reduce((a, b) => a.isAfter(b) ? a : b), ), ); }); groupedLocalAlbums.sort((a, b) => b.latestScanned.compareTo(a.latestScanned)); final stats = _HistoryStats( albumCounts: albumCounts, localAlbumCounts: localAlbumCounts, groupedAlbums: groupedAlbums, groupedLocalAlbums: groupedLocalAlbums, albumCount: albumCount, singleTracks: singleTracks, localAlbumCount: localAlbumCount, localSingleTracks: localSingleTracks, ); _queueHistoryStatsMemo = _QueueHistoryStatsMemoEntry( historyItems: items, localItems: localItems, stats: stats, ); return stats; } List<_GroupedAlbum> _queueFilterGroupedAlbums( List<_GroupedAlbum> albums, _QueueGroupedAlbumFilterRequest request, ) { if (request.filterSource == 'local') return const []; if (request.filterSource == null && request.filterQuality == null && request.filterFormat == null && request.filterMetadata == null && request.searchQuery.isEmpty && request.sortMode == 'latest') { return albums; } final result = <_GroupedAlbum>[]; for (final album in albums) { if (request.searchQuery.isNotEmpty && !album.searchKey.contains(request.searchQuery)) { continue; } if (request.filterQuality != null || request.filterFormat != null || request.filterMetadata != null) { var hasMatchingTrack = false; for (final track in album.tracks) { if (!_queuePassesQualityFilter(request.filterQuality, track.quality)) { continue; } if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { continue; } if (!_queueMatchesMetadataFilter( filterMetadata: request.filterMetadata, albumArtist: track.albumArtist, releaseDate: track.releaseDate, genre: track.genre, )) { continue; } hasMatchingTrack = true; break; } if (!hasMatchingTrack) continue; } result.add(album); } switch (request.sortMode) { case 'oldest': result.sort((a, b) => a.latestDownload.compareTo(b.latestDownload)); case 'artist-asc': result.sort((a, b) { final comparison = _queueCompareOptionalText( a.artistName, b.artistName, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.albumName, b.albumName); }); case 'artist-desc': result.sort((a, b) { final comparison = _queueCompareOptionalText( a.artistName, b.artistName, descending: true, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.albumName, b.albumName); }); case 'a-z': result.sort( (a, b) => a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), ); case 'z-a': result.sort( (a, b) => b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), ); case 'album-asc': result.sort( (a, b) => _queueCompareOptionalText(a.albumName, b.albumName), ); case 'album-desc': result.sort( (a, b) => _queueCompareOptionalText( a.albumName, b.albumName, descending: true, ), ); case 'release-oldest': result.sort((a, b) { final comparison = _queueCompareOptionalDate( _queueGroupedAlbumReleaseDate(a), _queueGroupedAlbumReleaseDate(b), ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.albumName, b.albumName); }); case 'release-newest': result.sort((a, b) { final comparison = _queueCompareOptionalDate( _queueGroupedAlbumReleaseDate(a), _queueGroupedAlbumReleaseDate(b), descending: true, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.albumName, b.albumName); }); case 'genre-asc': result.sort((a, b) { final comparison = _queueCompareOptionalText( _queueGroupedAlbumGenre(a), _queueGroupedAlbumGenre(b), ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.albumName, b.albumName); }); case 'genre-desc': result.sort((a, b) { final comparison = _queueCompareOptionalText( _queueGroupedAlbumGenre(a), _queueGroupedAlbumGenre(b), descending: true, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.albumName, b.albumName); }); default: break; } return result; } List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums( List<_GroupedLocalAlbum> albums, _QueueGroupedAlbumFilterRequest request, ) { if (request.filterSource == 'downloaded') return const []; if (request.filterSource == null && request.filterQuality == null && request.filterFormat == null && request.filterMetadata == null && request.searchQuery.isEmpty && request.sortMode == 'latest') { return albums; } final result = <_GroupedLocalAlbum>[]; for (final album in albums) { if (request.searchQuery.isNotEmpty && !album.searchKey.contains(request.searchQuery)) { continue; } if (request.filterQuality != null || request.filterFormat != null || request.filterMetadata != null) { var hasMatchingTrack = false; for (final track in album.tracks) { if (!_queuePassesQualityFilter( request.filterQuality, _queueLocalQualityLabel(track), )) { continue; } if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { continue; } if (!_queueMatchesMetadataFilter( filterMetadata: request.filterMetadata, albumArtist: track.albumArtist, releaseDate: track.releaseDate, genre: track.genre, )) { continue; } hasMatchingTrack = true; break; } if (!hasMatchingTrack) continue; } result.add(album); } switch (request.sortMode) { case 'oldest': result.sort((a, b) => a.latestScanned.compareTo(b.latestScanned)); case 'artist-asc': result.sort((a, b) { final comparison = _queueCompareOptionalText( a.artistName, b.artistName, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.albumName, b.albumName); }); case 'artist-desc': result.sort((a, b) { final comparison = _queueCompareOptionalText( a.artistName, b.artistName, descending: true, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.albumName, b.albumName); }); case 'a-z': result.sort( (a, b) => a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), ); case 'z-a': result.sort( (a, b) => b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), ); case 'album-asc': result.sort( (a, b) => _queueCompareOptionalText(a.albumName, b.albumName), ); case 'album-desc': result.sort( (a, b) => _queueCompareOptionalText( a.albumName, b.albumName, descending: true, ), ); case 'release-oldest': result.sort((a, b) { final comparison = _queueCompareOptionalDate( _queueGroupedLocalAlbumReleaseDate(a), _queueGroupedLocalAlbumReleaseDate(b), ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.albumName, b.albumName); }); case 'release-newest': result.sort((a, b) { final comparison = _queueCompareOptionalDate( _queueGroupedLocalAlbumReleaseDate(a), _queueGroupedLocalAlbumReleaseDate(b), descending: true, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.albumName, b.albumName); }); case 'genre-asc': result.sort((a, b) { final comparison = _queueCompareOptionalText( _queueGroupedLocalAlbumGenre(a), _queueGroupedLocalAlbumGenre(b), ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.albumName, b.albumName); }); case 'genre-desc': result.sort((a, b) { final comparison = _queueCompareOptionalText( _queueGroupedLocalAlbumGenre(a), _queueGroupedLocalAlbumGenre(b), descending: true, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.albumName, b.albumName); }); default: break; } return result; } final _queueHistoryStatsProvider = Provider<_HistoryStats>((ref) { final historyItems = ref.watch( downloadHistoryProvider.select((s) => s.items), ); final localLibraryEnabled = ref.watch( settingsProvider.select((s) => s.localLibraryEnabled), ); final localItems = localLibraryEnabled ? ref.watch(localLibraryProvider.select((s) => s.items)) : const []; return _buildQueueHistoryStats(historyItems, localItems); }); final _queueFilteredAlbumsProvider = Provider.family< ({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums}), _QueueGroupedAlbumFilterRequest >((ref, request) { final historyStats = ref.watch(_queueHistoryStatsProvider); return ( albums: _queueFilterGroupedAlbums(historyStats.groupedAlbums, request), localAlbums: _queueFilterGroupedLocalAlbums( historyStats.groupedLocalAlbums, request, ), ); }); Map> _filterHistoryInIsolate(Map payload) { final entries = (payload['entries'] as List).cast>(); final albumCounts = Map.from(payload['albumCounts'] as Map); final query = (payload['query'] as String?) ?? ''; final hasQuery = query.isNotEmpty; final allIds = []; final albumIds = []; final singleIds = []; for (final entry in entries) { final id = entry[0] as String; final albumKey = entry[1] as String; if (hasQuery) { final searchKey = entry[2] as String; if (!searchKey.contains(query)) { continue; } } allIds.add(id); final count = albumCounts[albumKey] ?? 0; if (count > 1) { albumIds.add(id); } else if (count == 1) { singleIds.add(id); } } return {'all': allIds, 'albums': albumIds, 'singles': singleIds}; } class QueueTab extends ConsumerStatefulWidget { final PageController? parentPageController; final int parentPageIndex; final int? nextPageIndex; const QueueTab({ super.key, this.parentPageController, this.parentPageIndex = 1, this.nextPageIndex, }); @override ConsumerState createState() => _QueueTabState(); } class _QueueTabState extends ConsumerState { final Map _fileExistsCache = {}; final Map> _fileExistsNotifiers = {}; final ValueNotifier _alwaysMissingFileNotifier = ValueNotifier(false); final Set _pendingChecks = {}; static const int _maxCacheSize = 500; static const int _maxSearchIndexCacheSize = 4000; bool _embeddedCoverRefreshScheduled = false; // Version counter to trigger targeted cover image rebuilds // without rebuilding the entire widget tree via setState. final ValueNotifier _embeddedCoverVersion = ValueNotifier(0); bool _isSelectionMode = false; final Set _selectedIds = {}; OverlayEntry? _selectionOverlayEntry; List _selectionOverlayItems = const []; double _selectionOverlayBottomPadding = 0; bool _isPlaylistSelectionMode = false; final Set _selectedPlaylistIds = {}; OverlayEntry? _playlistSelectionOverlayEntry; List _playlistSelectionOverlayItems = const []; double _playlistSelectionOverlayBottomPadding = 0; PageController? _filterPageController; final List _filterModes = ['all', 'albums', 'singles']; bool _isPageControllerInitialized = false; static const List _months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); String _searchQuery = ''; Timer? _searchDebounce; List? _historyItemsCache; List? _localLibraryItemsCache; _HistoryStats? _historyStatsCache; final Map _searchIndexCache = {}; final Map _localSearchIndexCache = {}; Map> _filteredHistoryCache = const {}; List? _filterItemsCache; String _filterQueryCache = ''; bool _filterRefreshScheduled = false; bool _isFilteringHistory = false; int _filterRequestId = 0; static const int _filterIsolateThreshold = 800; List? _localFilterItemsCache; String _localFilterQueryCache = ''; List _filteredLocalItemsCache = const []; final Map _unifiedItemsCache = {}; List? _cachedUnifiedDownloadedSource; List _cachedUnifiedDownloaded = const []; List? _cachedUnifiedLocalSource; List _cachedUnifiedLocal = const []; List? _cachedDownloadedPathKeysSource; Set _cachedDownloadedPathKeys = const {}; final Map> _localPathMatchKeysCache = {}; List? _cachedLocalSinglesSource; Map? _cachedLocalSinglesAlbumCountsSource; List _cachedLocalSingles = const []; final Map _filterContentDataCache = {}; List? _filterCacheAllHistoryItems; _HistoryStats? _filterCacheHistoryStats; List? _filterCacheLocalLibraryItems; LibraryCollectionsState? _filterCacheCollectionState; String _filterCacheSearchQuery = ''; String? _filterCacheSource; String? _filterCacheQuality; String? _filterCacheFormat; String? _filterCacheMetadata; String _filterCacheSortMode = 'latest'; String? _filterSource; String? _filterQuality; String? _filterFormat; String? _filterMetadata; String _sortMode = 'latest'; double _effectiveTextScale() { final textScale = MediaQuery.textScalerOf(context).scale(1.0); if (textScale < 1.0) return 1.0; if (textScale > 1.4) return 1.4; return textScale; } double _queueCoverSize() { final shortestSide = MediaQuery.sizeOf(context).shortestSide; final scale = (shortestSide / 390).clamp(0.82, 1.0); final textScale = _effectiveTextScale(); return (56 * scale * (1 + ((textScale - 1) * 0.12))).clamp(46.0, 56.0); } @override void initState() { super.initState(); } void _initializePageController() { if (_isPageControllerInitialized) return; _isPageControllerInitialized = true; final currentFilter = ref.read(settingsProvider).historyFilterMode; final initialPage = _filterModes.indexOf(currentFilter).clamp(0, 2); _filterPageController = PageController(initialPage: initialPage); } @override void dispose() { _hideSelectionOverlay(); _hidePlaylistSelectionOverlay(); for (final notifier in _fileExistsNotifiers.values) { notifier.dispose(); } _fileExistsNotifiers.clear(); _alwaysMissingFileNotifier.dispose(); _embeddedCoverVersion.dispose(); _filterPageController?.dispose(); _searchController.dispose(); _searchFocusNode.dispose(); _searchDebounce?.cancel(); super.dispose(); } void _onSearchChanged(String value) { _searchDebounce?.cancel(); final normalized = value.trim().toLowerCase(); _searchDebounce = Timer(const Duration(milliseconds: 180), () { if (!mounted || _searchQuery == normalized) return; setState(() => _searchQuery = normalized); _requestFilterRefresh(); }); } void _clearSearch() { _searchDebounce?.cancel(); if (_searchQuery.isEmpty) return; setState(() => _searchQuery = ''); _requestFilterRefresh(); } void _invalidateFilterContentCache() { _filterContentDataCache.clear(); _filterCacheAllHistoryItems = null; _filterCacheHistoryStats = null; _filterCacheLocalLibraryItems = null; _filterCacheCollectionState = null; } void _prepareFilterContentCache({ required List allHistoryItems, required _HistoryStats historyStats, required List localLibraryItems, required LibraryCollectionsState collectionState, }) { final isCacheValid = identical(_filterCacheAllHistoryItems, allHistoryItems) && identical(_filterCacheHistoryStats, historyStats) && identical(_filterCacheLocalLibraryItems, localLibraryItems) && identical(_filterCacheCollectionState, collectionState) && _filterCacheSearchQuery == _searchQuery && _filterCacheSource == _filterSource && _filterCacheQuality == _filterQuality && _filterCacheFormat == _filterFormat && _filterCacheMetadata == _filterMetadata && _filterCacheSortMode == _sortMode; if (isCacheValid) { return; } _filterContentDataCache.clear(); _filterCacheAllHistoryItems = allHistoryItems; _filterCacheHistoryStats = historyStats; _filterCacheLocalLibraryItems = localLibraryItems; _filterCacheCollectionState = collectionState; _filterCacheSearchQuery = _searchQuery; _filterCacheSource = _filterSource; _filterCacheQuality = _filterQuality; _filterCacheFormat = _filterFormat; _filterCacheMetadata = _filterMetadata; _filterCacheSortMode = _sortMode; } void _ensureHistoryCaches( List items, List localItems, _HistoryStats historyStats, ) { final historyChanged = !identical(items, _historyItemsCache); final localChanged = !identical(localItems, _localLibraryItemsCache); if (!historyChanged && !localChanged) return; _historyItemsCache = items; _localLibraryItemsCache = localItems; _historyStatsCache = historyStats; if (historyChanged) { _searchIndexCache.clear(); _cachedUnifiedDownloadedSource = null; _cachedUnifiedDownloaded = const []; _cachedDownloadedPathKeysSource = null; _cachedDownloadedPathKeys = const {}; } if (localChanged) { _localSearchIndexCache.clear(); _localPathMatchKeysCache.clear(); _localFilterItemsCache = null; _localFilterQueryCache = ''; _filteredLocalItemsCache = const []; _cachedLocalSinglesSource = null; _cachedLocalSinglesAlbumCountsSource = null; _cachedLocalSingles = const []; _cachedUnifiedLocalSource = null; _cachedUnifiedLocal = const []; } _unifiedItemsCache.clear(); _invalidateFilterContentCache(); if (historyChanged) { final validPaths = items .map((item) => _cleanFilePath(item.filePath)) .where((path) => path.isNotEmpty) .toSet(); DownloadedEmbeddedCoverResolver.invalidatePathsNotIn(validPaths); } _requestFilterRefresh(); } String _buildSearchKey(DownloadHistoryItem item) { return '${item.trackName} ${item.artistName} ${item.albumName}' .toLowerCase(); } String _buildLocalSearchKey(LocalLibraryItem item) { return '${item.trackName} ${item.artistName} ${item.albumName}' .toLowerCase(); } String _historySearchKeyForItem(DownloadHistoryItem item) { final cached = _searchIndexCache[item.id]; if (cached != null) return cached; final searchKey = _buildSearchKey(item); _searchIndexCache[item.id] = searchKey; while (_searchIndexCache.length > _maxSearchIndexCacheSize) { _searchIndexCache.remove(_searchIndexCache.keys.first); } return searchKey; } String _localSearchKeyForItem(LocalLibraryItem item) { final cached = _localSearchIndexCache[item.id]; if (cached != null) return cached; final searchKey = _buildLocalSearchKey(item); _localSearchIndexCache[item.id] = searchKey; while (_localSearchIndexCache.length > _maxSearchIndexCacheSize) { _localSearchIndexCache.remove(_localSearchIndexCache.keys.first); } return searchKey; } List _unifiedDownloadedItems( List items, ) { if (identical(items, _cachedUnifiedDownloadedSource)) { return _cachedUnifiedDownloaded; } final unified = items .map(UnifiedLibraryItem.fromDownloadHistory) .toList(growable: false); _cachedUnifiedDownloadedSource = items; _cachedUnifiedDownloaded = unified; return unified; } List _unifiedLocalItems(List items) { if (identical(items, _cachedUnifiedLocalSource)) { return _cachedUnifiedLocal; } final unified = items .map(UnifiedLibraryItem.fromLocalLibrary) .toList(growable: false); _cachedUnifiedLocalSource = items; _cachedUnifiedLocal = unified; return unified; } Set _downloadedPathKeys(List historyItems) { if (identical(historyItems, _cachedDownloadedPathKeysSource)) { return _cachedDownloadedPathKeys; } final keys = {}; for (final item in historyItems) { keys.addAll(buildPathMatchKeys(item.filePath)); } _cachedDownloadedPathKeysSource = historyItems; _cachedDownloadedPathKeys = Set.unmodifiable(keys); return _cachedDownloadedPathKeys; } List _localPathMatchKeys(LocalLibraryItem item) { final cached = _localPathMatchKeysCache[item.id]; if (cached != null) return cached; final keys = buildPathMatchKeys(item.filePath).toList(growable: false); _localPathMatchKeysCache[item.id] = keys; return keys; } List _localSingleItems( List items, Map localAlbumCounts, ) { if (identical(items, _cachedLocalSinglesSource) && identical(localAlbumCounts, _cachedLocalSinglesAlbumCountsSource)) { return _cachedLocalSingles; } final singles = items .where((item) => (localAlbumCounts[item.albumKey] ?? 0) == 1) .toList(growable: false); _cachedLocalSinglesSource = items; _cachedLocalSinglesAlbumCountsSource = localAlbumCounts; _cachedLocalSingles = singles; return singles; } List _filterLocalItems( List items, String query, ) { if (query.isEmpty) return items; if (identical(items, _localFilterItemsCache) && query == _localFilterQueryCache) { return _filteredLocalItemsCache; } final filtered = items .where((item) { final searchKey = _localSearchKeyForItem(item); return searchKey.contains(query); }) .toList(growable: false); _localFilterItemsCache = items; _localFilterQueryCache = query; _filteredLocalItemsCache = filtered; return filtered; } bool _isFilterCacheValid(List items, String query) { return identical(items, _filterItemsCache) && query == _filterQueryCache; } void _requestFilterRefresh() { if (_filterRefreshScheduled) return; _filterRefreshScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _filterRefreshScheduled = false; if (!mounted) return; _scheduleHistoryFilterUpdate(); }); } void _scheduleHistoryFilterUpdate() { final items = _historyItemsCache; if (items == null) return; final query = _searchQuery; if (_isFilterCacheValid(items, query)) return; final albumCounts = _historyStatsCache?.albumCounts ?? const {}; if (items.isEmpty) { setState(() { _filteredHistoryCache = const {}; _filterItemsCache = items; _filterQueryCache = query; _isFilteringHistory = false; }); return; } if (items.length <= _filterIsolateThreshold) { final filteredAll = _applyHistorySearchFilter(items, query); final filteredAlbums = _filterHistoryByAlbumCount( filteredAll, albumCounts, 2, ); final filteredSingles = _filterHistoryByAlbumCount( filteredAll, albumCounts, 1, ); setState(() { _filteredHistoryCache = { 'all': filteredAll, 'albums': filteredAlbums, 'singles': filteredSingles, }; _filterItemsCache = items; _filterQueryCache = query; _isFilteringHistory = false; }); return; } if (!_isFilteringHistory) { setState(() => _isFilteringHistory = true); } final requestId = ++_filterRequestId; final includeSearchKey = query.isNotEmpty; final entries = List>.generate(items.length, (index) { final item = items[index]; final albumKey = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; if (!includeSearchKey) { return [item.id, albumKey]; } final searchKey = _historySearchKeyForItem(item); return [item.id, albumKey, searchKey]; }, growable: false); final payload = { 'entries': entries, 'albumCounts': albumCounts, 'query': query, }; compute(_filterHistoryInIsolate, payload).then((result) { if (!mounted || requestId != _filterRequestId) return; final itemsById = {for (final item in items) item.id: item}; final filtered = >{}; for (final entry in result.entries) { filtered[entry.key] = entry.value .map((id) => itemsById[id]) .whereType() .toList(growable: false); } setState(() { _filteredHistoryCache = filtered; _filterItemsCache = items; _filterQueryCache = query; _isFilteringHistory = false; }); }); } List _resolveHistoryItems({ required String filterMode, required List allHistoryItems, required Map albumCounts, }) { final query = _searchQuery; if (_isFilterCacheValid(allHistoryItems, query)) { final cached = _filteredHistoryCache[filterMode]; if (cached != null) return cached; } if (allHistoryItems.isEmpty) return const []; if (query.isEmpty && filterMode == 'all') return allHistoryItems; if (allHistoryItems.length <= _filterIsolateThreshold) { return _filterHistoryItems( allHistoryItems, filterMode, albumCounts, query, ); } return const []; } List _applyHistorySearchFilter( List items, String searchQuery, ) { if (searchQuery.isEmpty) return items; final query = searchQuery; return items .where((item) { final searchKey = _historySearchKeyForItem(item); return searchKey.contains(query); }) .toList(growable: false); } List _filterHistoryByAlbumCount( List items, Map albumCounts, int targetCount, ) { return items .where((item) { final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; final count = albumCounts[key] ?? 0; return targetCount == 1 ? count == 1 : count >= targetCount; }) .toList(growable: false); } bool _shouldShowFilteringIndicator({ required List allHistoryItems, required String filterMode, }) { if (allHistoryItems.isEmpty) return false; if (_searchQuery.isEmpty && filterMode == 'all') return false; if (allHistoryItems.length <= _filterIsolateThreshold) return false; return !_isFilterCacheValid(allHistoryItems, _searchQuery) || _isFilteringHistory; } void _onFilterPageChanged(int index) { HapticFeedback.selectionClick(); final filterMode = _filterModes[index]; ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode); } void _animateToFilterPage(int index) { _filterPageController?.animateToPage( index, duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, ); } 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 (!_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 (!_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 queueNotifier = ref.read(downloadQueueProvider.notifier); void enqueueAll({String? qualityOverride, String? service}) { final svc = service ?? settings.defaultService; 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( 'Delete $count ${count == 1 ? 'playlist' : 'playlists'}?', ), 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( '$count ${count == 1 ? 'playlist' : 'playlists'} deleted', ), ), ); } 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 ? 'Delete $selectedCount ${selectedCount == 1 ? 'playlist' : 'playlists'}' : 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; WidgetsBinding.instance.addPostFrameCallback((_) { _embeddedCoverRefreshScheduled = false; if (mounted) { // Increment version to trigger ValueListenableBuilder rebuilds // on cover images only, instead of rebuilding the entire widget tree. _embeddedCoverVersion.value++; } }); } Future _scheduleDownloadedEmbeddedCoverRefreshForPath( String? filePath, { int? beforeModTime, bool force = false, }) async { await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( filePath, beforeModTime: beforeModTime, force: force, onChanged: _onEmbeddedCoverChanged, ); } String? _resolveDownloadedEmbeddedCoverPath(String? filePath) { return DownloadedEmbeddedCoverResolver.resolve( filePath, onChanged: _onEmbeddedCoverChanged, ); } ValueListenable _fileExistsListenable(String? filePath) { if (filePath == null) return _alwaysMissingFileNotifier; final cleanPath = _cleanFilePath(filePath); if (cleanPath.isEmpty) return _alwaysMissingFileNotifier; final existingNotifier = _fileExistsNotifiers[cleanPath]; if (existingNotifier != null) { final cached = _fileExistsCache[cleanPath]; if (cached != null && existingNotifier.value != cached) { existingNotifier.value = cached; } else if (cached == null) { _startFileExistsCheck(cleanPath); } return existingNotifier; } if (_fileExistsNotifiers.length >= _maxCacheSize) { final oldestKey = _fileExistsNotifiers.keys.first; _fileExistsNotifiers.remove(oldestKey)?.dispose(); _fileExistsCache.remove(oldestKey); } final notifier = ValueNotifier(_fileExistsCache[cleanPath] ?? true); _fileExistsNotifiers[cleanPath] = notifier; _startFileExistsCheck(cleanPath); return notifier; } void _startFileExistsCheck(String cleanPath) { if (_pendingChecks.contains(cleanPath)) { return; } final cached = _fileExistsCache[cleanPath]; if (cached != null) { final notifier = _fileExistsNotifiers[cleanPath]; if (notifier != null && notifier.value != cached) { notifier.value = cached; } return; } _pendingChecks.add(cleanPath); Future.microtask(() async { final exists = await fileExists(cleanPath); _pendingChecks.remove(cleanPath); _fileExistsCache[cleanPath] = exists; final notifier = _fileExistsNotifiers[cleanPath]; if (notifier != null && notifier.value != exists) { notifier.value = exists; } }); } int get _activeFilterCount { int count = 0; if (_filterSource != null) count++; if (_filterQuality != null) count++; if (_filterFormat != null) count++; if (_filterMetadata != null) count++; return count; } void _resetFilters() { setState(() { _filterSource = null; _filterQuality = null; _filterFormat = null; _filterMetadata = null; _sortMode = 'latest'; _unifiedItemsCache.clear(); _invalidateFilterContentCache(); }); } String _fileExtLower(String filePath) { final dotIndex = filePath.lastIndexOf('.'); if (dotIndex < 0 || dotIndex == filePath.length - 1) { return ''; } return filePath.substring(dotIndex + 1).toLowerCase(); } List _applyAdvancedFilters( List items, ) { List filtered; if (_activeFilterCount == 0) { filtered = items; } else { filtered = items .where((item) { if (_filterSource != null) { if (_filterSource == 'downloaded' && item.source != LibraryItemSource.downloaded) { return false; } if (_filterSource == 'local' && item.source != LibraryItemSource.local) { return false; } } if (_filterQuality != null && item.quality != null) { final quality = item.quality!.toLowerCase(); switch (_filterQuality) { case 'hires': if (!quality.startsWith('24')) return false; case 'cd': if (!quality.startsWith('16')) return false; case 'lossy': if (quality.startsWith('24') || quality.startsWith('16')) { return false; } } } else if (_filterQuality != null && item.quality == null) { if (_filterQuality != 'lossy') return false; } if (_filterFormat != null) { final ext = _fileExtLower(item.filePath); if (ext != _filterFormat) return false; } if (!_queueUnifiedItemMatchesMetadataFilter( item, _filterMetadata, )) { return false; } return true; }) .toList(growable: false); } return _applySorting(filtered); } List _applySorting(List items) { if (_sortMode == 'latest') { return items; } final sorted = List.of(items); switch (_sortMode) { case 'oldest': sorted.sort((a, b) => a.addedAt.compareTo(b.addedAt)); case 'a-z': sorted.sort( (a, b) => a.trackName.toLowerCase().compareTo(b.trackName.toLowerCase()), ); case 'z-a': sorted.sort( (a, b) => b.trackName.toLowerCase().compareTo(a.trackName.toLowerCase()), ); case 'artist-asc': sorted.sort((a, b) { final comparison = _queueCompareOptionalText( a.artistName, b.artistName, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.trackName, b.trackName); }); case 'artist-desc': sorted.sort((a, b) { final comparison = _queueCompareOptionalText( a.artistName, b.artistName, descending: true, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.trackName, b.trackName); }); case 'album-asc': sorted.sort((a, b) { final comparison = _queueCompareOptionalText( a.albumName, b.albumName, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.trackName, b.trackName); }); case 'album-desc': sorted.sort((a, b) { final comparison = _queueCompareOptionalText( a.albumName, b.albumName, descending: true, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.trackName, b.trackName); }); case 'release-oldest': sorted.sort((a, b) { final comparison = _queueCompareOptionalDate( _queueParseReleaseDate(a.releaseDate), _queueParseReleaseDate(b.releaseDate), ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.trackName, b.trackName); }); case 'release-newest': sorted.sort((a, b) { final comparison = _queueCompareOptionalDate( _queueParseReleaseDate(a.releaseDate), _queueParseReleaseDate(b.releaseDate), descending: true, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.trackName, b.trackName); }); case 'genre-asc': sorted.sort((a, b) { final comparison = _queueCompareOptionalText(a.genre, b.genre); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.trackName, b.trackName); }); case 'genre-desc': sorted.sort((a, b) { final comparison = _queueCompareOptionalText( a.genre, b.genre, descending: true, ); if (comparison != 0) { return comparison; } return _queueCompareOptionalText(a.trackName, b.trackName); }); } return sorted; } Set _getAvailableFormats(List items) { final formats = {}; for (final item in items) { final ext = _fileExtLower(item.filePath); if (['flac', 'mp3', 'm4a', 'opus', 'ogg', 'wav', 'aiff'].contains(ext)) { formats.add(ext); } } return formats; } void _showFilterSheet( BuildContext context, List allItems, ) { final colorScheme = Theme.of(context).colorScheme; final availableFormats = _getAvailableFormats(allItems); String? tempSource = _filterSource; String? tempQuality = _filterQuality; String? tempFormat = _filterFormat; String? tempMetadata = _filterMetadata; String tempSortMode = _sortMode; showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surfaceContainerLow, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), builder: (context) => StatefulBuilder( builder: (context, setSheetState) { return SafeArea( child: LayoutBuilder( builder: (context, constraints) { final maxSheetHeight = constraints.maxHeight * 0.9; return ConstrainedBox( constraints: BoxConstraints(maxHeight: maxSheetHeight), child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: SingleChildScrollView( physics: const ClampingScrollPhysics(), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( width: 32, height: 4, margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: colorScheme.outlineVariant, borderRadius: BorderRadius.circular(2), ), ), ), Row( children: [ Text( context.l10n.libraryFilterTitle, style: Theme.of(context).textTheme.titleLarge ?.copyWith(fontWeight: FontWeight.bold), ), const Spacer(), TextButton( onPressed: () { setSheetState(() { tempSource = null; tempQuality = null; tempFormat = null; tempMetadata = null; tempSortMode = 'latest'; }); }, child: Text(context.l10n.libraryFilterReset), ), ], ), const SizedBox(height: 16), Text( context.l10n.libraryFilterSource, style: Theme.of(context).textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Wrap( spacing: 8, children: [ FilterChip( label: Text(context.l10n.libraryFilterAll), selected: tempSource == null, onSelected: (_) => setSheetState(() => tempSource = null), ), FilterChip( label: Text( context.l10n.libraryFilterDownloaded, ), selected: tempSource == 'downloaded', onSelected: (_) => setSheetState( () => tempSource = 'downloaded', ), ), FilterChip( label: Text(context.l10n.libraryFilterLocal), selected: tempSource == 'local', onSelected: (_) => setSheetState(() => tempSource = 'local'), ), ], ), const SizedBox(height: 16), Text( context.l10n.libraryFilterQuality, style: Theme.of(context).textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Wrap( spacing: 8, children: [ FilterChip( label: Text(context.l10n.libraryFilterAll), selected: tempQuality == null, onSelected: (_) => setSheetState(() => tempQuality = null), ), FilterChip( label: Text( context.l10n.libraryFilterQualityHiRes, ), selected: tempQuality == 'hires', onSelected: (_) => setSheetState(() => tempQuality = 'hires'), ), FilterChip( label: Text( context.l10n.libraryFilterQualityCD, ), selected: tempQuality == 'cd', onSelected: (_) => setSheetState(() => tempQuality = 'cd'), ), FilterChip( label: Text( context.l10n.libraryFilterQualityLossy, ), selected: tempQuality == 'lossy', onSelected: (_) => setSheetState(() => tempQuality = 'lossy'), ), ], ), const SizedBox(height: 16), Text( context.l10n.libraryFilterFormat, style: Theme.of(context).textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Wrap( spacing: 8, children: [ FilterChip( label: Text(context.l10n.libraryFilterAll), selected: tempFormat == null, onSelected: (_) => setSheetState(() => tempFormat = null), ), for (final format in availableFormats.toList()..sort()) FilterChip( label: Text(format.toUpperCase()), selected: tempFormat == format, onSelected: (_) => setSheetState(() => tempFormat = format), ), ], ), const SizedBox(height: 16), Text( context.l10n.libraryFilterMetadata, style: Theme.of(context).textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ FilterChip( label: Text(context.l10n.libraryFilterAll), selected: tempMetadata == null, onSelected: (_) => setSheetState(() => tempMetadata = null), ), FilterChip( label: Text( context.l10n.libraryFilterMetadataComplete, ), selected: tempMetadata == 'complete', onSelected: (_) => setSheetState( () => tempMetadata = 'complete', ), ), FilterChip( label: Text( context.l10n.libraryFilterMetadataMissingAny, ), selected: tempMetadata == 'missing-any', onSelected: (_) => setSheetState( () => tempMetadata = 'missing-any', ), ), FilterChip( label: Text( context.l10n.libraryFilterMetadataMissingYear, ), selected: tempMetadata == 'missing-year', onSelected: (_) => setSheetState( () => tempMetadata = 'missing-year', ), ), FilterChip( label: Text( context .l10n .libraryFilterMetadataMissingGenre, ), selected: tempMetadata == 'missing-genre', onSelected: (_) => setSheetState( () => tempMetadata = 'missing-genre', ), ), FilterChip( label: Text( context .l10n .libraryFilterMetadataMissingAlbumArtist, ), selected: tempMetadata == 'missing-album-artist', onSelected: (_) => setSheetState( () => tempMetadata = 'missing-album-artist', ), ), ], ), const SizedBox(height: 16), Text( context.l10n.libraryFilterSort, style: Theme.of(context).textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Wrap( spacing: 8, children: [ FilterChip( label: Text( context.l10n.libraryFilterSortLatest, ), selected: tempSortMode == 'latest', onSelected: (_) => setSheetState( () => tempSortMode = 'latest', ), ), FilterChip( label: Text( context.l10n.libraryFilterSortOldest, ), selected: tempSortMode == 'oldest', onSelected: (_) => setSheetState( () => tempSortMode = 'oldest', ), ), FilterChip( label: Text(context.l10n.searchSortTitleAZ), selected: tempSortMode == 'a-z', onSelected: (_) => setSheetState(() => tempSortMode = 'a-z'), ), FilterChip( label: Text(context.l10n.searchSortTitleZA), selected: tempSortMode == 'z-a', onSelected: (_) => setSheetState(() => tempSortMode = 'z-a'), ), FilterChip( label: Text(context.l10n.searchSortArtistAZ), selected: tempSortMode == 'artist-asc', onSelected: (_) => setSheetState( () => tempSortMode = 'artist-asc', ), ), FilterChip( label: Text(context.l10n.searchSortArtistZA), selected: tempSortMode == 'artist-desc', onSelected: (_) => setSheetState( () => tempSortMode = 'artist-desc', ), ), FilterChip( label: Text( context.l10n.libraryFilterSortAlbumAsc, ), selected: tempSortMode == 'album-asc', onSelected: (_) => setSheetState( () => tempSortMode = 'album-asc', ), ), FilterChip( label: Text( context.l10n.libraryFilterSortAlbumDesc, ), selected: tempSortMode == 'album-desc', onSelected: (_) => setSheetState( () => tempSortMode = 'album-desc', ), ), FilterChip( label: Text(context.l10n.searchSortDateNewest), selected: tempSortMode == 'release-newest', onSelected: (_) => setSheetState( () => tempSortMode = 'release-newest', ), ), FilterChip( label: Text(context.l10n.searchSortDateOldest), selected: tempSortMode == 'release-oldest', onSelected: (_) => setSheetState( () => tempSortMode = 'release-oldest', ), ), FilterChip( label: Text( context.l10n.libraryFilterSortGenreAsc, ), selected: tempSortMode == 'genre-asc', onSelected: (_) => setSheetState( () => tempSortMode = 'genre-asc', ), ), FilterChip( label: Text( context.l10n.libraryFilterSortGenreDesc, ), selected: tempSortMode == 'genre-desc', onSelected: (_) => setSheetState( () => tempSortMode = 'genre-desc', ), ), ], ), const SizedBox(height: 24), SizedBox( width: double.infinity, child: FilledButton( onPressed: () { setState(() { _filterSource = tempSource; _filterQuality = tempQuality; _filterFormat = tempFormat; _filterMetadata = tempMetadata; _sortMode = tempSortMode; _unifiedItemsCache.clear(); _invalidateFilterContentCache(); }); Navigator.pop(context); }, child: Text(context.l10n.libraryFilterApply), ), ), ], ), ), ), ); }, ), ); }, ), ); } 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())), ), ); } } } 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( CachedNetworkImageProvider( url, cacheManager: CoverCacheManager.instance, ), 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, ), ), ); _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, ), ), ).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, ), ), ); } void _navigateToLocalAlbum(_GroupedLocalAlbum album) { _navigateWithUnfocus( slidePageRoute( page: LocalAlbumScreen( albumName: album.albumName, artistName: album.artistName, coverPath: album.coverPath, tracks: album.tracks, ), ), ); } void _openWishlistFolder() { _navigateWithUnfocus( MaterialPageRoute( builder: (_) => const LibraryTracksFolderScreen( mode: LibraryTracksFolderMode.wishlist, ), ), ); } void _openLovedFolder() { _navigateWithUnfocus( MaterialPageRoute( builder: (_) => const LibraryTracksFolderScreen( mode: LibraryTracksFolderMode.loved, ), ), ); } 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); } /// Build a playlist cover thumbnail (custom cover > first track cover > icon fallback). /// 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 ClipRRect( borderRadius: borderRadius, child: CachedNetworkImage( imageUrl: firstCoverUrl, width: size, height: size, fit: BoxFit.cover, memCacheWidth: cacheExtent, cacheManager: CoverCacheManager.instance, 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 ? 'Added $addedCount ${addedCount == 1 ? 'track' : 'tracks'} to $playlistName' '${alreadyCount > 0 ? ' ($alreadyCount already in playlist)' : ''}' : 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(); final hasQueueItems = ref.watch( downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty), ); final allHistoryItems = ref.watch( downloadHistoryProvider.select((s) => s.items), ); final localLibraryEnabled = ref.watch( settingsProvider.select((s) => s.localLibraryEnabled), ); final localLibraryItems = localLibraryEnabled ? ref.watch(localLibraryProvider.select((s) => s.items)) : const []; // Watch with selector on key fields to reduce unnecessary rebuilds. // LibraryCollectionsState doesn't implement == so watching without // selector rebuilds on every provider notification. ref.watch( libraryCollectionsProvider.select( (s) => ( s.wishlistCount, s.lovedCount, s.playlistCount, s.hasPlaylistTracks, s.isLoaded, ), ), ); final collectionState = ref.read(libraryCollectionsProvider); final historyStats = ref.watch(_queueHistoryStatsProvider); final filteredGrouped = ref.watch( _queueFilteredAlbumsProvider( _QueueGroupedAlbumFilterRequest( searchQuery: _searchQuery, filterSource: _filterSource, filterQuality: _filterQuality, filterFormat: _filterFormat, filterMetadata: _filterMetadata, sortMode: _sortMode, ), ), ); _ensureHistoryCaches(allHistoryItems, localLibraryItems, historyStats); final historyViewMode = ref.watch( settingsProvider.select((s) => s.historyViewMode), ); final historyFilterMode = ref.watch( settingsProvider.select((s) => s.historyFilterMode), ); final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); final filteredGroupedAlbums = filteredGrouped.albums; final filteredGroupedLocalAlbums = filteredGrouped.localAlbums; final albumCount = historyStats.totalAlbumCount; final singleCount = historyStats.totalSingleTracks; _prepareFilterContentCache( allHistoryItems: allHistoryItems, historyStats: historyStats, localLibraryItems: localLibraryItems, collectionState: collectionState, ); _FilterContentData getFilterData(String filterMode) { return _filterContentDataCache.putIfAbsent( filterMode, () => _computeFilterContentData( filterMode: filterMode, allHistoryItems: allHistoryItems, filteredGroupedAlbums: filteredGroupedAlbums, filteredGroupedLocalAlbums: filteredGroupedLocalAlbums, albumCounts: historyStats.albumCounts, localAlbumCounts: historyStats.localAlbumCounts, localLibraryItems: localLibraryItems, collectionState: collectionState, ), ); } final bottomPadding = MediaQuery.paddingOf(context).bottom; final selectionItems = getFilterData( historyFilterMode, ).filteredUnifiedItems; if (_isSelectionMode || _isPlaylistSelectionMode) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_isSelectionMode) { _syncSelectionOverlay( items: selectionItems, bottomPadding: bottomPadding, ); } if (_isPlaylistSelectionMode) { _syncPlaylistSelectionOverlay( playlists: collectionState.playlists, bottomPadding: bottomPadding, ); } }); } return PopScope( canPop: !_isSelectionMode && !_isPlaylistSelectionMode, onPopInvokedWithResult: (didPop, result) { if (!didPop) { if (_isPlaylistSelectionMode) { _exitPlaylistSelectionMode(); } else if (_isSelectionMode) { _exitSelectionMode(); } } }, child: Stack( children: [ // ScrollConfiguration disables stretch overscroll to fix _StretchController exception // This is a known Flutter issue with NestedScrollView + Material 3 stretch indicator ScrollConfiguration( behavior: ScrollConfiguration.of( context, ).copyWith(overscroll: false), child: NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ SliverAppBar( expandedHeight: 120 + topPadding, collapsedHeight: kToolbarHeight, floating: false, pinned: true, backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, automaticallyImplyLeading: false, flexibleSpace: LayoutBuilder( builder: (context, constraints) { final maxHeight = 120 + topPadding; final minHeight = kToolbarHeight + topPadding; final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)) .clamp(0.0, 1.0); return FlexibleSpaceBar( expandedTitleScale: 1.0, titlePadding: const EdgeInsets.only( left: 24, bottom: 16, ), title: Text( context.l10n.navLibrary, style: TextStyle( fontSize: 20 + (14 * expandRatio), fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), ), ); }, ), ), if (allHistoryItems.isNotEmpty || hasQueueItems || localLibraryItems.isNotEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: GestureDetector( onTap: () {}, child: TextField( controller: _searchController, focusNode: _searchFocusNode, autofocus: false, canRequestFocus: true, decoration: InputDecoration( hintText: context.l10n.historySearchHint, prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( tooltip: 'Clear', icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); _clearSearch(); FocusScope.of(context).unfocus(); }, ) : null, filled: true, fillColor: colorScheme.surfaceContainerHighest, border: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide( color: colorScheme.outlineVariant, ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide( color: colorScheme.outlineVariant, ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide( color: colorScheme.primary, width: 2, ), ), contentPadding: const EdgeInsets.symmetric( horizontal: 20, vertical: 16, ), ), onChanged: _onSearchChanged, onTapOutside: (_) { FocusScope.of(context).unfocus(); }, ), ), ), ), if (hasQueueItems) _buildQueueHeaderSliver(context, colorScheme), if (hasQueueItems) _buildQueueItemsSliver(context, colorScheme), if (allHistoryItems.isNotEmpty || localLibraryItems.isNotEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Builder( builder: (context) { int filteredAllCount; int filteredAlbumCount; int filteredSingleCount; if (_activeFilterCount == 0 && _searchQuery.isEmpty) { filteredAllCount = allHistoryItems.length + localLibraryItems.length; filteredAlbumCount = albumCount; filteredSingleCount = singleCount; } else { final allData = getFilterData('all'); final albumsData = getFilterData('albums'); final singlesData = getFilterData('singles'); filteredAllCount = allData.totalTrackCount; filteredAlbumCount = albumsData.totalAlbumCount; filteredSingleCount = singlesData.totalTrackCount; } return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ _FilterChip( label: context.l10n.historyFilterAll, count: filteredAllCount, isSelected: historyFilterMode == 'all', onTap: () { _animateToFilterPage(0); }, ), const SizedBox(width: 8), _FilterChip( label: context.l10n.historyFilterAlbums, count: filteredAlbumCount, isSelected: historyFilterMode == 'albums', onTap: () { _animateToFilterPage(1); }, ), const SizedBox(width: 8), _FilterChip( label: context.l10n.historyFilterSingles, count: filteredSingleCount, isSelected: historyFilterMode == 'singles', onTap: () { _animateToFilterPage(2); }, ), ], ), ); }, ), ), ), ], body: PageView.builder( controller: _filterPageController!, physics: const ClampingScrollPhysics(), onPageChanged: _onFilterPageChanged, itemCount: _filterModes.length, itemBuilder: (context, index) { final filterMode = _filterModes[index]; final filterData = getFilterData(filterMode); return _buildFilterContent( context: context, colorScheme: colorScheme, filterMode: filterMode, historyViewMode: historyViewMode, hasQueueItems: hasQueueItems, filterData: filterData, localLibraryItems: localLibraryItems, collectionState: collectionState, ); }, ), ), ), // ScrollConfiguration ], ), ); } List _getUnifiedItems({ required String filterMode, required List historyItems, required List localLibraryItems, required Map localAlbumCounts, }) { if (filterMode == 'albums') return const []; final query = _searchQuery; final cached = _unifiedItemsCache[filterMode]; if (cached != null && identical(cached.historyItems, historyItems) && identical(cached.localItems, localLibraryItems) && identical(cached.localAlbumCounts, localAlbumCounts) && cached.query == query) { return cached.items; } final unifiedDownloaded = _unifiedDownloadedItems(historyItems); List localItemsForMerge; if (filterMode == 'all') { localItemsForMerge = _filterLocalItems(localLibraryItems, query); } else { final localSingles = _localSingleItems( localLibraryItems, localAlbumCounts, ); localItemsForMerge = _filterLocalItems(localSingles, query); } final unifiedLocal = _unifiedLocalItems(localItemsForMerge); final downloadedPathKeys = _downloadedPathKeys(historyItems); final dedupedUnifiedLocal = []; for (final item in unifiedLocal) { final localSource = item.localItem; final localPathKeys = localSource != null ? _localPathMatchKeys(localSource) : buildPathMatchKeys(item.filePath); final overlapsDownloaded = localPathKeys.any(downloadedPathKeys.contains); if (!overlapsDownloaded) { dedupedUnifiedLocal.add(item); } } final merged = [ ...unifiedDownloaded, ...dedupedUnifiedLocal, ]..sort((a, b) => b.addedAt.compareTo(a.addedAt)); _unifiedItemsCache[filterMode] = _UnifiedCacheEntry( historyItems: historyItems, localItems: localLibraryItems, localAlbumCounts: localAlbumCounts, query: query, items: merged, ); return merged; } _FilterContentData _computeFilterContentData({ required String filterMode, required List allHistoryItems, required List<_GroupedAlbum> filteredGroupedAlbums, required List<_GroupedLocalAlbum> filteredGroupedLocalAlbums, required Map albumCounts, required Map localAlbumCounts, required List localLibraryItems, required LibraryCollectionsState collectionState, }) { final historyItems = _resolveHistoryItems( filterMode: filterMode, allHistoryItems: allHistoryItems, albumCounts: albumCounts, ); final showFilteringIndicator = _shouldShowFilteringIndicator( allHistoryItems: allHistoryItems, filterMode: filterMode, ); final unifiedItems = _getUnifiedItems( filterMode: filterMode, historyItems: historyItems, localLibraryItems: localLibraryItems, localAlbumCounts: localAlbumCounts, ); final filtered = _applyAdvancedFilters(unifiedItems); // Remove tracks that are already in any playlist so they don't appear // in the main tracks list. When a track is removed from a playlist (or // the playlist is deleted) it will automatically reappear here because it // will no longer be in the set. final filteredUnifiedItems = !collectionState.hasPlaylistTracks ? filtered : filtered .where( (item) => !collectionState.isTrackInAnyPlaylist(item.collectionKey), ) .toList(growable: false); return _FilterContentData( historyItems: historyItems, unifiedItems: unifiedItems, filteredUnifiedItems: filteredUnifiedItems, filteredGroupedAlbums: filteredGroupedAlbums, filteredGroupedLocalAlbums: filteredGroupedLocalAlbums, showFilteringIndicator: showFilteringIndicator, ); } Widget _buildQueueHeaderSliver( BuildContext context, ColorScheme colorScheme, ) { return Consumer( builder: (context, ref, child) { final queueCount = ref.watch( downloadQueueLookupProvider.select((lookup) => lookup.itemIds.length), ); if (queueCount == 0) { return const SliverToBoxAdapter(child: SizedBox.shrink()); } return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row( children: [ Text( context.l10n.queueDownloadingCount(queueCount), style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const Spacer(), _buildPauseResumeButton(context, ref, colorScheme), const SizedBox(width: 4), _buildClearAllButton(context, ref, colorScheme), ], ), ), ); }, ); } Widget _buildQueueItemsSliver(BuildContext context, ColorScheme colorScheme) { return Consumer( builder: (context, ref, child) { final queueIdsSnapshot = ref.watch( downloadQueueLookupProvider.select( (lookup) => _QueueItemIdsSnapshot(lookup.itemIds), ), ); if (queueIdsSnapshot.ids.isEmpty) { return const SliverToBoxAdapter(child: SizedBox.shrink()); } return SliverList( delegate: SliverChildBuilderDelegate((context, index) { final itemId = queueIdsSnapshot.ids[index]; return _QueueItemSliverRow( key: ValueKey(itemId), itemId: itemId, colorScheme: colorScheme, itemBuilder: _buildQueueItem, ); }, childCount: queueIdsSnapshot.ids.length), ); }, ); } 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: 'Open $title, $count ${count == 1 ? 'item' : 'items'}', 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( '$count ${count == 1 ? 'item' : 'items'}', maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], ), ), ); } /// Returns the visible collection entries, hiding Wishlist/Loved when empty. List<_CollectionEntry> _getVisibleCollectionEntries( LibraryCollectionsState collectionState, ) { final entries = <_CollectionEntry>[]; if (collectionState.wishlistCount > 0) { entries.add(_CollectionEntry.wishlist); } if (collectionState.lovedCount > 0) { entries.add(_CollectionEntry.loved); } 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.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.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 List localLibraryItems, required LibraryCollectionsState collectionState, }) { final historyItems = filterData.historyItems; final showFilteringIndicator = filterData.showFilteringIndicator; final filteredGroupedAlbums = filterData.filteredGroupedAlbums; final filteredGroupedLocalAlbums = filterData.filteredGroupedLocalAlbums; final unifiedItems = filterData.unifiedItems; final filteredUnifiedItems = filterData.filteredUnifiedItems; final totalTrackCount = filterData.totalTrackCount; final totalAlbumCount = filterData.totalAlbumCount; 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); } } return 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 || localLibraryItems.isNotEmpty)) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Row( children: [ const Spacer(), _buildFilterButton(context, unifiedItems), ], ), ), ), if (historyItems.isNotEmpty && hasQueueItems) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( context.l10n.queueDownloadedHeader, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), ), 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 == 'albums' && (filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty)) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 0.75, ), 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: SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 0.75, ), delegate: SliverChildBuilderDelegate( (context, index) { final collectionEntries = _getVisibleCollectionEntries( collectionState, ); final collectionCount = collectionEntries.length; if (index < collectionCount) { return _buildAllTabGridCollectionItem( context: context, colorScheme: colorScheme, entry: collectionEntries[index], collectionState: collectionState, filteredUnifiedItems: filteredUnifiedItems, ); } final trackIndex = index - collectionCount; 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], ), ), child: _buildUnifiedGridItem( context, item, colorScheme, downloadedNavigationItems: downloadedNavigationItems, downloadedNavigationIndex: downloadedNavigationIndexByUnifiedId[item.id], localNavigationItems: localNavigationItems, localNavigationIndex: localNavigationIndexByUnifiedId[item.id], ), ), ); } return const SizedBox.shrink(); }, childCount: _getVisibleCollectionEntries(collectionState).length + filteredUnifiedItems.length, ), ), ) else SliverList( delegate: SliverChildBuilderDelegate( (context, index) { final collectionEntries = _getVisibleCollectionEntries( collectionState, ); final collectionCount = collectionEntries.length; if (index < collectionCount) { return _buildAllTabListCollectionItem( context: context, colorScheme: colorScheme, entry: collectionEntries[index], collectionState: collectionState, filteredUnifiedItems: filteredUnifiedItems, ); } final trackIndex = index - collectionCount; 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], ), ), child: _buildUnifiedLibraryItem( context, item, colorScheme, downloadedNavigationItems: downloadedNavigationItems, downloadedNavigationIndex: downloadedNavigationIndexByUnifiedId[item.id], localNavigationItems: localNavigationItems, localNavigationIndex: localNavigationIndexByUnifiedId[item.id], ), ), ); } return const SizedBox.shrink(); }, childCount: _getVisibleCollectionEntries(collectionState).length + 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 (filteredUnifiedItems.isNotEmpty && filterMode == 'singles') historyViewMode == 'grid' ? SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 0.75, ), delegate: SliverChildBuilderDelegate((context, index) { final item = filteredUnifiedItems[index]; return KeyedSubtree( key: ValueKey(item.id), child: _buildUnifiedGridItem( context, item, colorScheme, downloadedNavigationItems: downloadedNavigationItems, downloadedNavigationIndex: downloadedNavigationIndexByUnifiedId[item.id], localNavigationItems: localNavigationItems, localNavigationIndex: localNavigationIndexByUnifiedId[item.id], ), ); }, childCount: filteredUnifiedItems.length), ), ) : SliverList( delegate: SliverChildBuilderDelegate((context, index) { final item = filteredUnifiedItems[index]; return KeyedSubtree( key: ValueKey(item.id), child: _buildUnifiedLibraryItem( context, item, colorScheme, downloadedNavigationItems: downloadedNavigationItems, downloadedNavigationIndex: downloadedNavigationIndexByUnifiedId[item.id], localNavigationItems: localNavigationItems, localNavigationIndex: localNavigationIndexByUnifiedId[item.id], ), ); }, childCount: filteredUnifiedItems.length), ), if (!hasQueueItems && totalTrackCount == 0 && (filterMode != 'albums' || (filteredGroupedAlbums.isEmpty && filteredGroupedLocalAlbums.isEmpty)) && !showFilteringIndicator) SliverFillRemaining( hasScrollBody: false, child: _buildEmptyState(context, colorScheme, filterMode), ) else SliverToBoxAdapter( child: SizedBox(height: _isSelectionMode ? 100 : 16), ), ], ); } Widget _buildPauseResumeButton( BuildContext context, WidgetRef ref, ColorScheme colorScheme, ) { final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused)); return TextButton.icon( onPressed: () { ref.read(downloadQueueProvider.notifier).togglePause(); }, icon: Icon(isPaused ? Icons.play_arrow : Icons.pause, size: 18), label: Text( isPaused ? context.l10n.actionResume : context.l10n.actionPause, ), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, foregroundColor: isPaused ? colorScheme.primary : colorScheme.onSurfaceVariant, ), ); } Widget _buildClearAllButton( BuildContext context, WidgetRef ref, ColorScheme colorScheme, ) { return TextButton.icon( onPressed: () => _showClearAllDialog(context, ref, colorScheme), icon: const Icon(Icons.clear_all, size: 18), label: Text(context.l10n.queueClearAll), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, foregroundColor: colorScheme.error, ), ); } 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.tracks.length, 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 ? CachedNetworkImage( imageUrl: album.coverUrl!, fit: BoxFit.cover, width: double.infinity, height: double.infinity, memCacheWidth: 300, memCacheHeight: 300, cacheManager: CoverCacheManager.instance, ) : 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.tracks.length, 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: 'Open album $albumName by $artistName, $trackCount tracks', 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; } } if (_hasTextValue(downloadedCoverPath)) { await _safeDeleteTempFile(downloadedCoverPath!); } if (_hasTextValue(extractedCoverPath)) { await _cleanupTempFileAndParentDir(extractedCoverPath!); } if (_hasTextValue(tempPath)) { await _safeDeleteTempFile(tempPath!); } return ffmpegResult != null; } Future _reEnrichQueueLocalTrack( LocalLibraryItem item, { List? updateFields, }) async { final durationMs = (item.duration ?? 0) * 1000; final artistTagMode = ref.read(settingsProvider).artistTagMode; final request = { 'file_path': item.filePath, 'cover_url': '', 'max_quality': true, 'embed_lyrics': true, 'artist_tag_mode': artistTagMode, 'spotify_id': '', 'track_name': item.trackName, 'artist_name': item.artistName, 'album_name': item.albumName, 'album_artist': item.albumArtist ?? item.artistName, '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') { 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, ); final targetQuality = LocalTrackRedownloadService.preferredFlacQualityForService( targetService, ); 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.trackReEnrichSuccess} ($successCount/$total) • Failed: $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)); } } /// Show batch convert bottom sheet for selected tracks Future _showBatchConvertSheet( BuildContext context, List allItems, ) async { final itemsById = {for (final item in allItems) item.id: item}; final sourceFormats = {}; for (final id in _selectedIds) { final item = itemsById[id]; if (item == null) continue; String nameToCheck; if (item.historyItem?.safFileName != null && item.historyItem!.safFileName!.isNotEmpty) { nameToCheck = item.historyItem!.safFileName!.toLowerCase(); } else if (item.localItem?.format != null && item.localItem!.format!.isNotEmpty) { nameToCheck = '.${item.localItem!.format!.toLowerCase()}'; } else { nameToCheck = item.filePath.toLowerCase(); } final ext = nameToCheck.endsWith('.flac') ? 'FLAC' : nameToCheck.endsWith('.m4a') ? 'M4A' : nameToCheck.endsWith('.mp3') ? 'MP3' : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) ? 'Opus' : null; if (ext != null) sourceFormats.add(ext); } final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) { return sourceFormats.any((src) { if (src == target) return false; final isLosslessTarget = target == 'ALAC' || target == 'FLAC'; final isLosslessSource = src == 'FLAC' || src == 'M4A'; if (isLosslessTarget && !isLosslessSource) return false; return true; }); }).toList(); if (formats.isEmpty) return; String selectedFormat = formats.first; bool isLosslessTarget = selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; String selectedBitrate = isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k'); var didStartConversion = false; _hideSelectionOverlay(); _hidePlaylistSelectionOverlay(); await showModalBottomSheet( context: context, useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (sheetContext) { return StatefulBuilder( builder: (context, setSheetState) { final colorScheme = Theme.of(context).colorScheme; final bitrates = ['128k', '192k', '256k', '320k']; return SafeArea( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( width: 40, height: 4, decoration: BoxDecoration( color: colorScheme.onSurfaceVariant.withValues( alpha: 0.4, ), borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 16), Text( context.l10n.selectionBatchConvertConfirmTitle, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 20), Text( context.l10n.trackConvertTargetFormat, style: Theme.of(context).textTheme.titleSmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), Wrap( spacing: 8, children: formats.map((format) { final isSelected = format == selectedFormat; return ChoiceChip( label: Text(format), selected: isSelected, onSelected: (selected) { if (selected) { setSheetState(() { selectedFormat = format; isLosslessTarget = format == 'ALAC' || format == 'FLAC'; if (!isLosslessTarget) { selectedBitrate = format == 'Opus' ? '128k' : '320k'; } }); } }, ); }).toList(), ), if (!isLosslessTarget) ...[ const SizedBox(height: 16), Text( context.l10n.trackConvertBitrate, style: Theme.of(context).textTheme.titleSmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), Wrap( spacing: 8, children: bitrates.map((br) { final isSelected = br == selectedBitrate; return ChoiceChip( label: Text(br), selected: isSelected, onSelected: (selected) { if (selected) { setSheetState(() => selectedBitrate = br); } }, ); }).toList(), ), ], if (isLosslessTarget) ...[ const SizedBox(height: 16), Row( children: [ Icon( Icons.verified, size: 16, color: colorScheme.primary, ), const SizedBox(width: 6), Text( context.l10n.trackConvertLosslessHint, style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: colorScheme.primary), ), ], ), ], const SizedBox(height: 24), SizedBox( width: double.infinity, child: FilledButton( onPressed: () { didStartConversion = true; Navigator.pop(context); _performBatchConversion( allItems: allItems, targetFormat: selectedFormat, bitrate: selectedBitrate, ); }, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), ), child: Text( context.l10n.selectionConvertCount( _selectedIds.length, ), ), ), ), ], ), ), ); }, ); }, ); if (!mounted || 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, }) 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; String nameToCheck; if (item.historyItem?.safFileName != null && item.historyItem!.safFileName!.isNotEmpty) { nameToCheck = item.historyItem!.safFileName!.toLowerCase(); } else if (item.localItem?.format != null && item.localItem!.format!.isNotEmpty) { nameToCheck = '.${item.localItem!.format!.toLowerCase()}'; } else { nameToCheck = item.filePath.toLowerCase(); } final ext = nameToCheck.endsWith('.flac') ? 'FLAC' : nameToCheck.endsWith('.m4a') ? 'M4A' : nameToCheck.endsWith('.mp3') ? 'MP3' : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) ? 'Opus' : null; if (ext == null || ext == targetFormat) continue; final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final isLosslessSource = ext == 'FLAC' || ext == 'M4A'; if (isLosslessTarget && !isLosslessSource) continue; selectedItems.add(item); } if (selectedItems.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), ); } return; } final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( 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 newQuality = (targetFormat.toUpperCase() == 'ALAC' || targetFormat.toUpperCase() == 'FLAC') ? '${targetFormat.toUpperCase()} Lossless' : '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; 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, ); if (coverPath != null) { try { await File(coverPath).delete(); } catch (_) {} } if (newPath == null) { if (safTempPath != null) { try { await File(safTempPath).delete(); } catch (_) {} } continue; } 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; String newExt; String mimeType; switch (targetFormat.toLowerCase()) { case 'opus': newExt = '.opus'; mimeType = 'audio/opus'; break; case 'alac': newExt = '.m4a'; mimeType = 'audio/mp4'; break; case 'flac': newExt = '.flac'; mimeType = 'audio/flac'; break; default: newExt = '.mp3'; mimeType = 'audio/mpeg'; break; } 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; } try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} await historyDb.updateFilePath( hi.id, safUri, newSafFileName: newFileName, newQuality: newQuality, clearAudioSpecs: true, ); } 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; String newExt; String mimeType; switch (targetFormat.toLowerCase()) { case 'opus': newExt = '.opus'; mimeType = 'audio/opus'; break; case 'alac': newExt = '.m4a'; mimeType = 'audio/mp4'; break; case 'flac': newExt = '.flac'; mimeType = 'audio/flac'; break; default: newExt = '.mp3'; mimeType = 'audio/mpeg'; break; } 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; } try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} await LibraryDatabase.instance.replaceWithConvertedItem( item: item.localItem!, newFilePath: safUri, targetFormat: targetFormat, bitrate: bitrate, ); } 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, clearAudioSpecs: true, ); } else if (item.localItem != null) { await LibraryDatabase.instance.replaceWithConvertedItem( item: item.localItem!, newFilePath: newPath, targetFormat: targetFormat, bitrate: bitrate, ); } 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, ), ), ), ); } } 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), Row( children: [ if (localOnlySelection && flacEligibleCount > 0) ...[ Expanded( child: _SelectionActionButton( icon: Icons.download_for_offline_outlined, label: '${context.l10n.queueFlacAction} ($flacEligibleCount)', onPressed: () => _queueSelectedLocalAsFlac(unifiedItems), colorScheme: colorScheme, ), ), const SizedBox(width: 8), ], Expanded( child: _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, ), ), const SizedBox(width: 8), Expanded( child: _SelectionActionButton( icon: Icons.swap_horiz, label: context.l10n.selectionConvertCount(selectedCount), onPressed: selectedCount > 0 ? () => _showBatchConvertSheet(context, unifiedItems) : null, colorScheme: colorScheme, ), ), ], ), 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 ? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}' : 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), child: InkWell( onTap: isCompleted ? () => _navigateToMetadataScreen(item) : null, borderRadius: BorderRadius.circular(12), child: 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, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: colorScheme.onSurfaceVariant), ), if (item.status == DownloadStatus.downloading) ...[ const SizedBox(height: 8), Row( children: [ Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: item.progress > 0 ? item.progress : null, backgroundColor: colorScheme.surfaceContainerHighest, color: colorScheme.primary, minHeight: 6, ), ), ), const SizedBox(width: 8), Text( item.bytesTotal > 0 ? '${(item.progress * 100).toStringAsFixed(0)}%' : (item.bytesReceived > 0 ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB${item.speedMBps > 0 ? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s' : ''}' : (item.progress > 0 ? (item.speedMBps > 0 ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' : '${(item.progress * 100).toStringAsFixed(0)}%') : (item.speedMBps > 0 ? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' : 'Starting...'))), style: Theme.of(context).textTheme.labelSmall ?.copyWith( color: colorScheme.primary, fontWeight: FontWeight.bold, ), ), ], ), ], if (item.status == DownloadStatus.failed) ...[ const SizedBox(height: 4), Text( item.errorMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall ?.copyWith(color: colorScheme.error), ), ], ], ), ), const SizedBox(width: 8), _buildActionButtons(context, item, colorScheme), ], ), ), ), ), ), ); } Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) { final coverSize = _queueCoverSize(); final memCacheSize = (coverSize * 2).round(); return item.track.coverUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(8), child: CachedNetworkImage( imageUrl: item.track.coverUrl!, width: coverSize, height: coverSize, fit: BoxFit.cover, memCacheWidth: memCacheSize, memCacheHeight: memCacheSize, cacheManager: CoverCacheManager.instance, ), ) : Container( width: coverSize, height: coverSize, decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), ); } 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: 'Cancel', 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: 'Stop', style: IconButton.styleFrom( backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), ), ); case DownloadStatus.finalizing: return Semantics( label: 'Finalizing download', 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: 'Play', style: IconButton.styleFrom( backgroundColor: colorScheme.primaryContainer.withValues( alpha: 0.3, ), ), ) else Semantics( label: 'Downloaded file missing', child: ExcludeSemantics( child: Icon( Icons.error_outline, color: colorScheme.error, size: 20, ), ), ), const SizedBox(width: 4), Semantics( label: 'Download completed', 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: 'Retry', 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: 'Remove', 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), ), ); } /// Build cover image widget for unified library item. /// 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), ), ); } 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, errorBuilder: (context, error, stackTrace) => buildPlaceholder(), ), ); } } if (item.coverUrl != null) { return ClipRRect( borderRadius: BorderRadius.circular(8), child: CachedNetworkImage( imageUrl: item.coverUrl!, width: size, height: size, fit: BoxFit.cover, memCacheWidth: cacheSize, memCacheHeight: cacheSize, cacheManager: CoverCacheManager.instance, placeholder: (context, url) => buildPlaceholder(), errorWidget: (context, url, error) => buildPlaceholder(), ), ); } 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, 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, }) { 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: '${item.trackName} by ${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 ? 'Deselect track' : 'Select track', 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, 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.tertiaryContainer : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( item.quality!, style: Theme.of(context).textTheme.labelSmall ?.copyWith( color: item.quality!.startsWith('24') ? colorScheme.onTertiaryContainer : 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: () => _openFile( item.filePath, title: item.trackName, artist: item.artistName, album: item.albumName, coverUrl: item.coverUrl ?? item.localCoverPath ?? '', ), 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, }) { 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: _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.tertiary : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( _getQualityBadgeText(item.quality!), style: Theme.of(context).textTheme.labelSmall ?.copyWith( color: item.quality!.startsWith('24') ? colorScheme.onTertiary : 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: 'Play ${item.trackName} by ${item.artistName}', child: GestureDetector( onTap: () => _openFile( item.filePath, title: item.trackName, artist: item.artistName, album: item.albumName, coverUrl: item.coverUrl ?? item.localCoverPath ?? '', ), 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, 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 _QueueItemSliverRow extends ConsumerWidget { final String itemId; final ColorScheme colorScheme; final Widget Function(BuildContext, DownloadItem, ColorScheme) itemBuilder; const _QueueItemSliverRow({ super.key, required this.itemId, required this.colorScheme, required this.itemBuilder, }); @override Widget build(BuildContext context, WidgetRef ref) { final item = ref.watch( downloadQueueLookupProvider.select((lookup) => lookup.byItemId[itemId]), ); if (item == null) { return const SizedBox.shrink(); } return RepaintBoundary(child: itemBuilder(context, item, colorScheme)); } } enum _CollectionEntryType { wishlist, loved, playlist } class _CollectionEntry { final _CollectionEntryType type; final int playlistIndex; const _CollectionEntry._(this.type, [this.playlistIndex = -1]); static const wishlist = _CollectionEntry._(_CollectionEntryType.wishlist); static const loved = _CollectionEntry._(_CollectionEntryType.loved); static _CollectionEntry playlist(int index) => _CollectionEntry._(_CollectionEntryType.playlist, index); } class _FilterChip extends StatelessWidget { final String label; final int count; final bool isSelected; final VoidCallback onTap; const _FilterChip({ required this.label, required this.count, required this.isSelected, required this.onTap, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return FilterChip( label: Row( mainAxisSize: MainAxisSize.min, children: [ Text(label), const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: isSelected ? colorScheme.primary.withValues(alpha: 0.2) : colorScheme.outline.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(10), ), child: Text( count.toString(), style: TextStyle( fontSize: 11, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, fontWeight: FontWeight.w500, ), ), ), ], ), selected: isSelected, onSelected: (_) => onTap(), showCheckmark: false, ); } } class _SelectionActionButton extends StatelessWidget { final IconData icon; final String label; final VoidCallback? onPressed; final ColorScheme colorScheme; const _SelectionActionButton({ required this.icon, required this.label, required this.onPressed, required this.colorScheme, }); @override Widget build(BuildContext context) { final isDisabled = onPressed == null; return Material( color: isDisabled ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) : colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(14), child: InkWell( onTap: onPressed, borderRadius: BorderRadius.circular(14), child: Padding( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( icon, size: 18, color: isDisabled ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) : colorScheme.onSecondaryContainer, ), const SizedBox(width: 6), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: isDisabled ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) : colorScheme.onSecondaryContainer, ), ), ), ], ), ), ), ); } } class _AnimatedOverlayBottomBar extends StatefulWidget { final Widget child; const _AnimatedOverlayBottomBar({required this.child}); @override State<_AnimatedOverlayBottomBar> createState() => _AnimatedOverlayBottomBarState(); } class _AnimatedOverlayBottomBarState extends State<_AnimatedOverlayBottomBar> with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _slideAnimation; late final Animation _fadeAnimation; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 240), ); final curve = CurvedAnimation( parent: _controller, curve: Curves.easeOutCubic, ); _slideAnimation = Tween( begin: const Offset(0, 0.08), end: Offset.zero, ).animate(curve); _fadeAnimation = Tween(begin: 0, end: 1).animate(curve); _controller.forward(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return FadeTransition( opacity: _fadeAnimation, child: SlideTransition(position: _slideAnimation, child: widget.child), ); } }