diff --git a/lib/screens/queue_library_refresh_policy.dart b/lib/screens/queue_library_refresh_policy.dart index 40b9142b..d4b1613f 100644 --- a/lib/screens/queue_library_refresh_policy.dart +++ b/lib/screens/queue_library_refresh_policy.dart @@ -25,3 +25,17 @@ bool shouldRetainQueueLibraryPageSnapshot({ required bool cachedHasContent, required bool activeDownloadFallbackAvailable, }) => currentIsEmpty && cachedHasContent && activeDownloadFallbackAvailable; + +/// Resolves the playable path for a just-completed download while its pinned +/// completion-bridge card is still visible. The finalized history path wins +/// because conversion or SAF publication may change the original queue path. +String? resolveCompletionBridgePlayablePath({ + String? historyFilePath, + String? completedItemFilePath, +}) { + final historyPath = historyFilePath?.trim(); + if (historyPath != null && historyPath.isNotEmpty) return historyPath; + + final completedPath = completedItemFilePath?.trim(); + return completedPath == null || completedPath.isEmpty ? null : completedPath; +} diff --git a/lib/screens/queue_tab_collection_items.dart b/lib/screens/queue_tab_collection_items.dart index 5ba02eda..1e1e44a1 100644 --- a/lib/screens/queue_tab_collection_items.dart +++ b/lib/screens/queue_tab_collection_items.dart @@ -122,6 +122,10 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { final quality = unifiedItem?.qualityForMode(_libraryQualityLabelMode) ?? track.audioQuality; + final playablePath = resolveCompletionBridgePlayablePath( + historyFilePath: historyItem?.filePath, + completedItemFilePath: item.filePath, + ); final cover = unifiedItem != null ? _buildUnifiedCoverImage(unifiedItem, colorScheme) : track.coverUrl != null @@ -132,6 +136,8 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { : 'cover_${item.id}'; final trackName = historyItem?.trackName ?? track.name; final artistName = historyItem?.artistName ?? track.artistName; + final albumName = historyItem?.albumName ?? track.albumName; + final coverUrl = historyItem?.coverUrl ?? track.coverUrl ?? ''; return TrackGridCard( semanticLabel: context.l10n.a11yTrackByArtist(trackName, artistName), onTap: () => historyItem != null @@ -143,16 +149,72 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { : _navigateToMetadataScreen(item), cover: Hero(tag: heroTag, child: cover), overlays: [ + Positioned( + right: 4, + top: 4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: context.tokens.borderRadiusBadge, + ), + child: Icon( + Icons.download_done, + size: 12, + color: colorScheme.onPrimaryContainer, + ), + ), + ), if (quality != null && quality.isNotEmpty) Positioned( left: 4, - right: 4, + right: 28, top: 4, child: Align( alignment: Alignment.centerLeft, child: _buildLibraryQualityBadge(context, colorScheme, quality), ), ), + if (playablePath != null) + Positioned( + right: 4, + bottom: 4, + child: ValueListenableBuilder( + valueListenable: _fileExistsListenable(playablePath), + builder: (context, fileExists, child) { + if (fileExists) { + return TrackGridPlayButton( + tooltip: context.l10n.a11yPlayTrackByArtist( + trackName, + artistName, + ), + onPressed: () => _openFile( + playablePath, + title: trackName, + artist: artistName, + album: albumName, + coverUrl: coverUrl, + ), + ); + } + return Tooltip( + message: context.l10n.queueDownloadedFileMissing, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.error_outline, + color: colorScheme.error, + size: 14, + ), + ), + ); + }, + ), + ), ], title: trackName, subtitle: Text(artistName), @@ -176,6 +238,10 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { final quality = unifiedItem?.qualityForMode(_libraryQualityLabelMode) ?? track.audioQuality; + final playablePath = resolveCompletionBridgePlayablePath( + historyFilePath: historyItem?.filePath, + completedItemFilePath: item.filePath, + ); final cover = unifiedItem != null ? _buildUnifiedCoverImage(unifiedItem, colorScheme, coverSize) : track.coverUrl != null @@ -199,6 +265,8 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { : 'cover_${item.id}'; final trackName = historyItem?.trackName ?? track.name; final artistName = historyItem?.artistName ?? track.artistName; + final albumName = historyItem?.albumName ?? track.albumName; + final coverUrl = historyItem?.coverUrl ?? track.coverUrl ?? ''; return TrackCard( onTap: () => historyItem != null ? _navigateToHistoryMetadataScreen( @@ -235,6 +303,40 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { ], ], ), + trailing: playablePath == null + ? null + : ValueListenableBuilder( + valueListenable: _fileExistsListenable(playablePath), + builder: (context, fileExists, child) { + if (fileExists) { + return IconButton( + onPressed: () => _openFile( + playablePath, + title: trackName, + artist: artistName, + album: albumName, + coverUrl: coverUrl, + ), + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + tooltip: context.l10n.tooltipPlay, + style: IconButton.styleFrom( + minimumSize: Size.square(context.tokens.minTouchTarget), + backgroundColor: colorScheme.primaryContainer.withValues( + alpha: 0.3, + ), + ), + ); + } + return Tooltip( + message: context.l10n.queueDownloadedFileMissing, + child: Icon( + Icons.error_outline, + color: colorScheme.error, + size: 20, + ), + ); + }, + ), ); } diff --git a/test/queue_library_refresh_policy_test.dart b/test/queue_library_refresh_policy_test.dart index f31dae74..c4c39de7 100644 --- a/test/queue_library_refresh_policy_test.dart +++ b/test/queue_library_refresh_policy_test.dart @@ -1,4 +1,7 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; + import 'package:spotiflac_android/screens/queue_library_refresh_policy.dart'; import 'package:spotiflac_android/services/library_database.dart'; @@ -73,4 +76,51 @@ void main() { isFalse, ); }); + + test('completion bridge prefers the finalized history path', () { + expect( + resolveCompletionBridgePlayablePath( + historyFilePath: ' /music/final.flac ', + completedItemFilePath: '/music/staging.flac', + ), + '/music/final.flac', + ); + expect( + resolveCompletionBridgePlayablePath( + historyFilePath: ' ', + completedItemFilePath: ' /music/completed.flac ', + ), + '/music/completed.flac', + ); + expect( + resolveCompletionBridgePlayablePath( + historyFilePath: null, + completedItemFilePath: '', + ), + isNull, + ); + }); + + test('completion bridge cards retain Play actions during a batch', () { + final source = File( + 'lib/screens/queue_tab_collection_items.dart', + ).readAsStringSync(); + final gridStart = source.indexOf('Widget _buildBridgeGridItem('); + final listStart = source.indexOf('Widget _buildBridgeListItem('); + final badgeStart = source.indexOf('Widget _buildLibraryQualityBadge('); + + expect(gridStart, greaterThanOrEqualTo(0)); + expect(listStart, greaterThan(gridStart)); + expect(badgeStart, greaterThan(listStart)); + + final gridSource = source.substring(gridStart, listStart); + final listSource = source.substring(listStart, badgeStart); + + expect(gridSource, contains('resolveCompletionBridgePlayablePath(')); + expect(gridSource, contains('_fileExistsListenable(playablePath)')); + expect(gridSource, contains('TrackGridPlayButton(')); + expect(listSource, contains('resolveCompletionBridgePlayablePath(')); + expect(listSource, contains('_fileExistsListenable(playablePath)')); + expect(listSource, contains('Icons.play_arrow')); + }); }