diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 3d7b4b6d..b66d4e2f 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -35,7 +35,6 @@ 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/widgets/cached_cover_image.dart'; -import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; @@ -118,6 +117,38 @@ String? _formatDownloadEta(DownloadItem item) { return '~${minutes}m${secs.toString().padLeft(2, '0')}s'; } +DownloadHistoryItem? _historyItemForCompletionBridge( + DownloadItem item, + List historyItems, +) { + final filePath = item.filePath?.trim(); + if (filePath != null && filePath.isNotEmpty) { + for (final historyItem in historyItems) { + if (historyItem.filePath == filePath) return historyItem; + } + } + + for (final historyItem in historyItems) { + if (historyItem.id == item.id) return historyItem; + } + + final trackId = item.track.id.trim(); + if (trackId.isNotEmpty) { + for (final historyItem in historyItems) { + if (historyItem.spotifyId == trackId) return historyItem; + } + } + + final isrc = item.track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + for (final historyItem in historyItems) { + if (historyItem.isrc == isrc) return historyItem; + } + } + + return null; +} + class QueueTab extends ConsumerStatefulWidget { final PageController? parentPageController; final int parentPageIndex; @@ -1078,6 +1109,11 @@ class _QueueTabState extends ConsumerState { final historyTotalCount = ref.watch( downloadHistoryProvider.select((state) => state.totalCount), ); + // Completion-bridge cells need the finalized in-memory history record + // immediately, before the database-backed Library page refresh lands. + final inMemoryHistoryItems = ref.watch( + downloadHistoryProvider.select((state) => state.items), + ); final localLibraryTotalCount = ref.watch( localLibraryProvider.select((state) => state.totalCount), ); @@ -1400,6 +1436,7 @@ class _QueueTabState extends ConsumerState { ? hasMoreLibrary : false, isPageLoading: isLibraryPageLoading, + inMemoryHistoryItems: inMemoryHistoryItems, bottomInset: bottomInset, ); }, diff --git a/lib/screens/queue_tab_collection_items.dart b/lib/screens/queue_tab_collection_items.dart index 564eb140..33e8dc42 100644 --- a/lib/screens/queue_tab_collection_items.dart +++ b/lib/screens/queue_tab_collection_items.dart @@ -131,10 +131,17 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { BuildContext context, DownloadItem item, ColorScheme colorScheme, + DownloadHistoryItem? historyItem, ) { final track = item.track; final radius = BorderRadius.circular(8); - final cover = track.coverUrl != null + final unifiedItem = historyItem == null + ? null + : UnifiedLibraryItem.fromDownloadHistory(historyItem); + final quality = unifiedItem?.quality ?? track.audioQuality; + final cover = unifiedItem != null + ? _buildUnifiedCoverImage(unifiedItem, colorScheme) + : track.coverUrl != null ? CachedCoverImage(imageUrl: track.coverUrl!, borderRadius: radius) : Container( decoration: BoxDecoration( @@ -143,11 +150,18 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { ), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), ); + final heroTag = historyItem != null + ? 'cover_lib_dl_${historyItem.id}' + : 'cover_${item.id}'; + final trackName = historyItem?.trackName ?? track.name; + final artistName = historyItem?.artistName ?? track.artistName; return Semantics( button: true, - label: context.l10n.a11yTrackByArtist(track.name, track.artistName), + label: context.l10n.a11yTrackByArtist(trackName, artistName), child: GestureDetector( - onTap: () => _navigateToMetadataScreen(item), + onTap: () => historyItem != null + ? _navigateToHistoryMetadataScreen(historyItem) + : _navigateToMetadataScreen(item), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -156,14 +170,18 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { child: Stack( fit: StackFit.expand, children: [ - ClipRRect(borderRadius: radius, child: cover), - if (track.hasAudioQuality) + Hero( + tag: heroTag, + child: ClipRRect(borderRadius: radius, child: cover), + ), + if (quality != null && quality.isNotEmpty) Positioned( left: 4, top: 4, - child: AudioQualityBadge( - label: track.audioQuality!, - colorScheme: colorScheme, + child: _buildLibraryQualityBadge( + context, + colorScheme, + quality, ), ), ], @@ -171,7 +189,7 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { ), const SizedBox(height: 6), Text( - track.name, + trackName, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of( @@ -179,7 +197,7 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), ), Text( - track.artistName, + artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall?.copyWith( @@ -196,11 +214,18 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { BuildContext context, DownloadItem item, ColorScheme colorScheme, + DownloadHistoryItem? historyItem, ) { final track = item.track; final coverSize = _queueCoverSize(); final radius = BorderRadius.circular(8); - final cover = track.coverUrl != null + final unifiedItem = historyItem == null + ? null + : UnifiedLibraryItem.fromDownloadHistory(historyItem); + final quality = unifiedItem?.quality ?? track.audioQuality; + final cover = unifiedItem != null + ? _buildUnifiedCoverImage(unifiedItem, colorScheme, coverSize) + : track.coverUrl != null ? CachedCoverImage( imageUrl: track.coverUrl!, width: coverSize, @@ -216,23 +241,30 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { ), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), ); + final heroTag = historyItem != null + ? 'cover_lib_dl_${historyItem.id}' + : 'cover_${item.id}'; + final trackName = historyItem?.trackName ?? track.name; + final artistName = historyItem?.artistName ?? track.artistName; return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), clipBehavior: Clip.antiAlias, child: InkWell( - onTap: () => _navigateToMetadataScreen(item), + onTap: () => historyItem != null + ? _navigateToHistoryMetadataScreen(historyItem) + : _navigateToMetadataScreen(item), child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ - cover, + Hero(tag: heroTag, child: cover), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - track.name, + trackName, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall?.copyWith( @@ -241,13 +273,25 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { ), const SizedBox(height: 2), Text( - track.artistName, + artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), + if (quality != null && quality.isNotEmpty) ...[ + const SizedBox(height: 4), + Align( + alignment: Alignment.centerLeft, + child: _buildLibraryQualityBadge( + context, + colorScheme, + quality, + listStyle: true, + ), + ), + ], ], ), ), @@ -258,6 +302,38 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { ); } + Widget _buildLibraryQualityBadge( + BuildContext context, + ColorScheme colorScheme, + String quality, { + bool listStyle = false, + }) { + final isHighResolution = quality.startsWith('24'); + return Container( + padding: EdgeInsets.symmetric(horizontal: listStyle ? 6 : 4, vertical: 2), + decoration: BoxDecoration( + color: isHighResolution + ? listStyle + ? colorScheme.primaryContainer + : colorScheme.primary + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + listStyle ? quality : _getQualityBadgeText(quality), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: isHighResolution + ? listStyle + ? colorScheme.onPrimaryContainer + : colorScheme.onPrimary + : colorScheme.onSurfaceVariant, + fontSize: listStyle ? 10 : 9, + fontWeight: listStyle ? FontWeight.w500 : FontWeight.w600, + ), + ), + ); + } + Widget _buildCollectionListItem({ required BuildContext context, required ColorScheme colorScheme, diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart index 3d2e544d..eff362f2 100644 --- a/lib/screens/queue_tab_filter_widgets.dart +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -11,6 +11,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { required LibraryCollectionsState collectionState, required bool hasMoreLibrary, required bool isPageLoading, + required List inMemoryHistoryItems, double bottomInset = 0, }) { final historyItems = filterData.historyItems; @@ -45,6 +46,13 @@ extension _QueueTabFilterWidgets on _QueueTabState { final libIdSet = { for (final item in allFilteredUnifiedItems) item.id, }; + final bridgeHistoryById = { + for (final entry in _completionBridge.entries) + entry.key: _historyItemForCompletionBridge( + entry.value, + inMemoryHistoryItems, + ), + }; List bridgeIds = const []; if (filterMode != 'albums' && _completionBridge.isNotEmpty) { final now = DateTime.now(); @@ -52,7 +60,8 @@ extension _QueueTabFilterWidgets on _QueueTabState { final pending = []; final hasActiveDownloads = activeDownloadIds.isNotEmpty; _completionBridge.forEach((id, _) { - final landed = libIdSet.contains('dl_$id'); + final historyId = bridgeHistoryById[id]?.id ?? id; + final landed = libIdSet.contains('dl_$historyId'); final addedAt = _completionBridgeAt[id]; final expired = addedAt == null || now.difference(addedAt).inSeconds >= 6; @@ -87,15 +96,8 @@ extension _QueueTabFilterWidgets on _QueueTabState { .where((id) => !_bridgePrecacheStarted.contains(id)) .toList(growable: false); if (toPrecache.isNotEmpty) { - final historyItems = ref.read(downloadHistoryProvider).items; for (final id in toPrecache) { - DownloadHistoryItem? historyItem; - for (final h in historyItems) { - if (h.id == id) { - historyItem = h; - break; - } - } + final historyItem = bridgeHistoryById[id]; if (historyItem == null) continue; _bridgePrecacheStarted.add(id); final coverUrl = historyItem.coverUrl; @@ -143,7 +145,9 @@ extension _QueueTabFilterWidgets on _QueueTabState { // hide their history rows so they don't appear twice. List filteredUnifiedItems = allFilteredUnifiedItems; if (bridgeIds.isNotEmpty) { - final pinnedHistoryIds = {for (final id in bridgeIds) 'dl_$id'}; + final pinnedHistoryIds = { + for (final id in bridgeIds) 'dl_${bridgeHistoryById[id]?.id ?? id}', + }; filteredUnifiedItems = allFilteredUnifiedItems .where((item) => !pinnedHistoryIds.contains(item.id)) .toList(growable: false); @@ -192,6 +196,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { context, _completionBridge[bridgeId]!, colorScheme, + bridgeHistoryById[bridgeId], ), ); } @@ -213,6 +218,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { context, _completionBridge[bridgeId]!, colorScheme, + bridgeHistoryById[bridgeId], ), ); } diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index f5ba784e..61394e80 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -907,28 +907,11 @@ extension _QueueTabItemWidgets on _QueueTabState { if (item.quality != null && item.quality!.isNotEmpty) ...[ const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: item.quality!.startsWith('24') - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - item.quality!, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: item.quality!.startsWith('24') - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), + _buildLibraryQualityBadge( + context, + colorScheme, + item.quality!, + listStyle: true, ), ], ], @@ -1055,28 +1038,10 @@ extension _QueueTabItemWidgets on _QueueTabState { Positioned( left: 4, top: 4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - decoration: BoxDecoration( - color: item.quality!.startsWith('24') - ? colorScheme.primary - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - _getQualityBadgeText(item.quality!), - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: item.quality!.startsWith('24') - ? colorScheme.onPrimary - : colorScheme.onSurfaceVariant, - fontSize: 9, - fontWeight: FontWeight.w600, - ), - ), + child: _buildLibraryQualityBadge( + context, + colorScheme, + item.quality!, ), ), if (!_isSelectionMode)