From 933a3c49e23f6f34c58cad3bea756a6cee7202c5 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 27 Aug 2026 21:42:38 +0700 Subject: [PATCH] fix(library): persist artwork previews and stabilize completion --- lib/l10n/app_localizations.dart | 8 +- lib/l10n/app_localizations_de.dart | 3 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_es.dart | 3 + lib/l10n/app_localizations_fr.dart | 3 + lib/l10n/app_localizations_id.dart | 3 + lib/l10n/app_localizations_ja.dart | 3 + lib/l10n/app_localizations_ko.dart | 3 + lib/l10n/app_localizations_pt.dart | 3 + lib/l10n/app_localizations_ru.dart | 3 + lib/l10n/app_localizations_tr.dart | 3 + lib/l10n/app_localizations_uk.dart | 3 + lib/l10n/arb/app_en.arb | 7 + lib/screens/downloaded_album_screen.dart | 13 +- lib/screens/home_tab_recent.dart | 13 +- lib/screens/library_tracks_folder_screen.dart | 11 +- lib/screens/queue_library_refresh_policy.dart | 198 ++++- lib/screens/queue_tab.dart | 11 + lib/screens/queue_tab_collection_items.dart | 62 +- lib/screens/queue_tab_navigation.dart | 29 +- .../settings/cache_management_page.dart | 2 + lib/screens/track_metadata_cards.dart | 132 ++- lib/screens/track_metadata_screen.dart | 26 +- lib/screens/track_metadata_screen_cover.dart | 105 +-- .../downloaded_embedded_cover_resolver.dart | 833 ++++++++++++++++-- lib/widgets/cached_cover_image.dart | 15 + ...wnloaded_embedded_cover_resolver_test.dart | 528 +++++++++++ test/queue_library_refresh_policy_test.dart | 136 ++- 28 files changed, 1906 insertions(+), 256 deletions(-) create mode 100644 test/downloaded_embedded_cover_resolver_test.dart diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0b5601b6..bb8cb4b8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -6660,6 +6660,12 @@ abstract class AppLocalizations { /// **'Downloaded file missing'** String get queueDownloadedFileMissing; + /// Accessibility label while the app checks whether a just-completed download has appeared at its final path yet. Not about authentication or a download session. + /// + /// In en, this message translates to: + /// **'Checking downloaded file...'** + String get queueCheckingDownloadedFile; + /// Accessibility label for completed download state in queue /// /// In en, this message translates to: @@ -7176,7 +7182,7 @@ abstract class AppLocalizations { /// **'Starting...'** String get queueDownloadStarting; - /// No description provided for @queueCheckingDownloadSession. + /// Queue status while the download provider's session is being checked during preparation (preparationStage 'checking_session'). This is about the provider session, not about locating a finished file. /// /// In en, this message translates to: /// **'Checking download session...'** diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 4fddacdc..c6b33ff1 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -4061,6 +4061,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get queueDownloadedFileMissing => 'Heruntergeladene Datei fehlt'; + @override + String get queueCheckingDownloadedFile => 'Checking downloaded file...'; + @override String get queueDownloadCompleted => 'Download abgeschlossen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c5f619c0..9cd8e5b3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4015,6 +4015,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get queueDownloadedFileMissing => 'Downloaded file missing'; + @override + String get queueCheckingDownloadedFile => 'Checking downloaded file...'; + @override String get queueDownloadCompleted => 'Download completed'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index c638277b..66f0d963 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4010,6 +4010,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get queueDownloadedFileMissing => 'Downloaded file missing'; + @override + String get queueCheckingDownloadedFile => 'Checking downloaded file...'; + @override String get queueDownloadCompleted => 'Download completed'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f87cec88..f724f7f6 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4115,6 +4115,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get queueDownloadedFileMissing => 'Fichier téléchargé manquant'; + @override + String get queueCheckingDownloadedFile => 'Checking downloaded file...'; + @override String get queueDownloadCompleted => 'Téléchargement terminé'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index ffb3725c..4009965e 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -4012,6 +4012,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get queueDownloadedFileMissing => 'Downloaded file missing'; + @override + String get queueCheckingDownloadedFile => 'Checking downloaded file...'; + @override String get queueDownloadCompleted => 'Download completed'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 6724583b..75053dea 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -4003,6 +4003,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get queueDownloadedFileMissing => 'Downloaded file missing'; + @override + String get queueCheckingDownloadedFile => 'Checking downloaded file...'; + @override String get queueDownloadCompleted => 'Download completed'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index af3e7053..874f9177 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3904,6 +3904,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get queueDownloadedFileMissing => '다운로드된 파일이 없음'; + @override + String get queueCheckingDownloadedFile => 'Checking downloaded file...'; + @override String get queueDownloadCompleted => '다운로드 완료'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 4a529373..6aa3bef6 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4009,6 +4009,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get queueDownloadedFileMissing => 'Downloaded file missing'; + @override + String get queueCheckingDownloadedFile => 'Checking downloaded file...'; + @override String get queueDownloadCompleted => 'Download completed'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index fdc4cb0e..a93ca62f 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -4046,6 +4046,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get queueDownloadedFileMissing => 'Downloaded file missing'; + @override + String get queueCheckingDownloadedFile => 'Checking downloaded file...'; + @override String get queueDownloadCompleted => 'Download completed'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 1c64866b..8d3962c2 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -4045,6 +4045,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get queueDownloadedFileMissing => 'Downloaded file missing'; + @override + String get queueCheckingDownloadedFile => 'Checking downloaded file...'; + @override String get queueDownloadCompleted => 'Download completed'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 307de286..5a24f8a7 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -4063,6 +4063,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get queueDownloadedFileMissing => 'Downloaded file missing'; + @override + String get queueCheckingDownloadedFile => 'Checking downloaded file...'; + @override String get queueDownloadCompleted => 'Download completed'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index d456d3ad..2539f40a 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5198,6 +5198,10 @@ "@queueDownloadedFileMissing": { "description": "Accessibility label when a downloaded file is missing from disk" }, + "queueCheckingDownloadedFile": "Checking downloaded file...", + "@queueCheckingDownloadedFile": { + "description": "Accessibility label while the app checks whether a just-completed download has appeared at its final path yet. Not about authentication or a download session." + }, "queueDownloadCompleted": "Download completed", "@queueDownloadCompleted": { "description": "Accessibility label for completed download state in queue" @@ -5657,6 +5661,9 @@ "@queueDownloadStarting": { "description": "Queue status before download progress is available" }, + "@queueCheckingDownloadSession": { + "description": "Queue status while the download provider's session is being checked during preparation (preparationStage 'checking_session'). This is about the provider session, not about locating a finished file." + }, "a11ySelectTrack": "Select track", "@a11ySelectTrack": { "description": "Accessibility label for selecting a track" diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 145d7aa7..99e49df2 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -213,8 +213,17 @@ class _DownloadedAlbumScreenState extends ConsumerState required int navigationIndex, }) async { final navigator = Navigator.of(context); - precacheCoverImage(context, item.coverUrl); - final backdropReady = precacheMetadataBackdrop(context, item.coverUrl); + final embeddedCoverPath = + await DownloadedEmbeddedCoverResolver.resolveOrExtract( + item.filePath, + onChanged: _onEmbeddedCoverChanged, + ); + if (!mounted) return; + final artworkSource = embeddedCoverPath ?? item.coverUrl; + if (embeddedCoverPath == null) { + precacheCoverImage(context, item.coverUrl); + } + final backdropReady = precacheMetadataBackdrop(context, artworkSource); final beforeModTime = await DownloadedEmbeddedCoverResolver.readFileModTimeMillis( item.filePath, diff --git a/lib/screens/home_tab_recent.dart b/lib/screens/home_tab_recent.dart index 599fc30f..29d2d040 100644 --- a/lib/screens/home_tab_recent.dart +++ b/lib/screens/home_tab_recent.dart @@ -357,8 +357,17 @@ extension _HomeTabRecentUI on _HomeTabState { int? navigationIndex, }) async { final navigator = Navigator.of(context); - precacheCoverImage(context, item.coverUrl); - final backdropReady = precacheMetadataBackdrop(context, item.coverUrl); + final embeddedCoverPath = + await DownloadedEmbeddedCoverResolver.resolveOrExtract( + item.filePath, + onChanged: _onEmbeddedCoverChanged, + ); + if (!mounted) return; + final artworkSource = embeddedCoverPath ?? item.coverUrl; + if (embeddedCoverPath == null) { + precacheCoverImage(context, item.coverUrl); + } + final backdropReady = precacheMetadataBackdrop(context, artworkSource); final beforeModTime = await DownloadedEmbeddedCoverResolver.readFileModTimeMillis( item.filePath, diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 9d4c85db..63e9d063 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -14,6 +14,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/m3u_playlist_service.dart'; +import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -1081,7 +1082,15 @@ class _CollectionTrackTile extends ConsumerWidget { if (!context.mounted) return; if (historyItem != null) { - await precacheMetadataBackdrop(context, historyItem.coverUrl); + final embeddedCoverPath = + await DownloadedEmbeddedCoverResolver.resolveOrExtract( + historyItem.filePath, + ); + if (!context.mounted) return; + await precacheMetadataBackdrop( + context, + embeddedCoverPath ?? historyItem.coverUrl, + ); if (!context.mounted) return; await Navigator.of(context).push( slidePageRoute(page: TrackMetadataScreen(item: historyItem)), diff --git a/lib/screens/queue_library_refresh_policy.dart b/lib/screens/queue_library_refresh_policy.dart index d4b1613f..86bf0cdd 100644 --- a/lib/screens/queue_library_refresh_policy.dart +++ b/lib/screens/queue_library_refresh_policy.dart @@ -1,4 +1,8 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; bool queueLibraryCountsHaveContent(QueueLibraryCounts counts) => counts.allTrackCount > 0 || @@ -26,16 +30,196 @@ bool shouldRetainQueueLibraryPageSnapshot({ 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. +/// Returns distinct final-path candidates for a just-completed download. +/// History is authoritative after conversion/SAF publication, while the +/// completed queue item remains a safe fallback if the matched history row is +/// stale or has not been adopted into the in-memory index yet. +List resolveCompletionBridgePlayableCandidates({ + String? historyFilePath, + String? completedItemFilePath, +}) { + final candidates = []; + for (final rawPath in [historyFilePath, completedItemFilePath]) { + final path = rawPath?.trim(); + if (path != null && path.isNotEmpty && !candidates.contains(path)) { + candidates.add(path); + } + } + return candidates; +} + +/// Synchronous preferred candidate retained for non-probing callers. String? resolveCompletionBridgePlayablePath({ String? historyFilePath, String? completedItemFilePath, }) { - final historyPath = historyFilePath?.trim(); - if (historyPath != null && historyPath.isNotEmpty) return historyPath; + final candidates = resolveCompletionBridgePlayableCandidates( + historyFilePath: historyFilePath, + completedItemFilePath: completedItemFilePath, + ); + return candidates.isEmpty ? null : candidates.first; +} - final completedPath = completedItemFilePath?.trim(); - return completedPath == null || completedPath.isEmpty ? null : completedPath; +enum CompletionBridgePlayableStatus { checking, playable, missing } + +@immutable +class CompletionBridgePlayableResult { + final CompletionBridgePlayableStatus status; + final String? path; + + const CompletionBridgePlayableResult._(this.status, this.path); + + const CompletionBridgePlayableResult.checking() + : this._(CompletionBridgePlayableStatus.checking, null); + + const CompletionBridgePlayableResult.playable(String path) + : this._(CompletionBridgePlayableStatus.playable, path); + + const CompletionBridgePlayableResult.missing() + : this._(CompletionBridgePlayableStatus.missing, null); +} + +typedef CompletionBridgePathExists = Future Function(String path); + +/// Probes both completion-path candidates and keeps a bridge in a neutral +/// checking state while delayed SAF publication becomes visible. A definitive +/// missing result is emitted only after the retry window is exhausted. +class CompletionBridgePlayableProbeCache { + static const int _defaultMaxEntries = 500; + static const List _defaultRetryDelays = [ + Duration(milliseconds: 350), + Duration(milliseconds: 700), + Duration(milliseconds: 1400), + Duration(milliseconds: 2200), + ]; + + final CompletionBridgePathExists _pathExists; + final List _retryDelays; + final int _maxEntries; + final Map _entries = {}; + final ValueNotifier _missingNotifier = + ValueNotifier(const CompletionBridgePlayableResult.missing()); + bool _disposed = false; + + CompletionBridgePlayableProbeCache({ + CompletionBridgePathExists pathExists = fileExists, + List retryDelays = _defaultRetryDelays, + int maxEntries = _defaultMaxEntries, + }) : assert(maxEntries > 0), + _pathExists = pathExists, + _retryDelays = List.unmodifiable(retryDelays), + _maxEntries = maxEntries; + + ValueListenable listenable({ + String? historyFilePath, + String? completedItemFilePath, + }) { + final candidates = resolveCompletionBridgePlayableCandidates( + historyFilePath: historyFilePath, + completedItemFilePath: completedItemFilePath, + ); + if (candidates.isEmpty || _disposed) return _missingNotifier; + + final key = candidates.join('\u0000'); + final existing = _entries[key]; + if (existing != null) return existing.notifier; + + while (_entries.length >= _maxEntries) { + String? evictionKey; + for (final candidate in _entries.entries) { + if (!candidate.value.notifier.hasActiveListeners) { + evictionKey = candidate.key; + break; + } + } + if (evictionKey == null) break; + _entries.remove(evictionKey)?.dispose(); + } + + final entry = _CompletionBridgeProbeEntry(candidates); + _entries[key] = entry; + unawaited(_probe(entry, attempt: 0)); + return entry.notifier; + } + + /// Rechecks cached probes that mention [filePath], including a previously + /// missing result from an earlier completion of the same destination. + void refreshForPath(String? filePath) { + final path = filePath?.trim(); + if (path == null || path.isEmpty || _disposed) return; + for (final entry in _entries.values) { + if (!entry.candidates.contains(path)) continue; + entry.timer?.cancel(); + entry.timer = null; + entry.generation++; + entry.notifier.value = const CompletionBridgePlayableResult.checking(); + unawaited(_probe(entry, attempt: 0)); + } + } + + Future _probe( + _CompletionBridgeProbeEntry entry, { + required int attempt, + }) async { + final generation = entry.generation; + for (final path in entry.candidates) { + var exists = false; + try { + exists = await _pathExists(path); + } catch (_) {} + if (_disposed || generation != entry.generation) return; + if (exists) { + entry.notifier.value = CompletionBridgePlayableResult.playable(path); + return; + } + } + + if (attempt >= _retryDelays.length) { + entry.notifier.value = const CompletionBridgePlayableResult.missing(); + return; + } + + entry.timer = Timer(_retryDelays[attempt], () { + entry.timer = null; + if (_disposed || generation != entry.generation) return; + unawaited(_probe(entry, attempt: attempt + 1)); + }); + } + + void dispose() { + if (_disposed) return; + _disposed = true; + for (final entry in _entries.values) { + entry.dispose(); + } + _entries.clear(); + _missingNotifier.dispose(); + } +} + +class _CompletionBridgeProbeEntry { + final List candidates; + final _CompletionBridgeProbeNotifier notifier = + _CompletionBridgeProbeNotifier(); + Timer? timer; + int generation = 0; + bool disposed = false; + + _CompletionBridgeProbeEntry(this.candidates); + + void dispose() { + if (disposed) return; + disposed = true; + generation++; + timer?.cancel(); + notifier.dispose(); + } +} + +class _CompletionBridgeProbeNotifier + extends ValueNotifier { + _CompletionBridgeProbeNotifier() + : super(const CompletionBridgePlayableResult.checking()); + + bool get hasActiveListeners => hasListeners; } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 43381703..8039e6a4 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -224,6 +224,8 @@ class _QueueTabState extends ConsumerState { static const int _libraryPageSize = 300; final _FileExistsListenableCache _fileExistsCache = _FileExistsListenableCache(); + final CompletionBridgePlayableProbeCache _completionBridgePlayableProbe = + CompletionBridgePlayableProbeCache(); static const double _libraryGridMinExtent = 92; static const double _libraryGridDefaultExtent = 126; static const double _libraryGridMaxExtent = 190; @@ -391,6 +393,7 @@ class _QueueTabState extends ConsumerState { _hideSelectionOverlay(); _hidePlaylistSelectionOverlay(); _fileExistsCache.dispose(); + _completionBridgePlayableProbe.dispose(); _embeddedCoverVersion.dispose(); _filterPageController?.dispose(); _searchController.dispose(); @@ -1229,6 +1232,7 @@ class _QueueTabState extends ConsumerState { final nowCompleted = nextItem != null && nextItem.status == DownloadStatus.completed; if (wasActive && nowCompleted) { + _completionBridgePlayableProbe.refreshForPath(nextItem.filePath); _completionBridge[id] = nextItem; _completionBridgeAt[id] = DateTime.now(); } @@ -1238,6 +1242,13 @@ class _QueueTabState extends ConsumerState { downloadHistoryProvider.select((state) => state.loadedIndexVersion), (previous, next) { if (previous == null || previous == next) return; + final historyItems = ref.read(downloadHistoryProvider).items; + for (final bridgeItem in _completionBridge.values) { + _completionBridgePlayableProbe.refreshForPath(bridgeItem.filePath); + _completionBridgePlayableProbe.refreshForPath( + _historyItemForCompletionBridge(bridgeItem, historyItems)?.filePath, + ); + } // The family provider already reruns for the new revision. Retain its // last successful page while SQLite is loading so metadata backfills // and download completions cannot flash the Library as empty. diff --git a/lib/screens/queue_tab_collection_items.dart b/lib/screens/queue_tab_collection_items.dart index 1e1e44a1..3b47204e 100644 --- a/lib/screens/queue_tab_collection_items.dart +++ b/lib/screens/queue_tab_collection_items.dart @@ -179,17 +179,22 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { Positioned( right: 4, bottom: 4, - child: ValueListenableBuilder( - valueListenable: _fileExistsListenable(playablePath), - builder: (context, fileExists, child) { - if (fileExists) { + child: ValueListenableBuilder( + valueListenable: _completionBridgePlayableProbe.listenable( + historyFilePath: historyItem?.filePath, + completedItemFilePath: item.filePath, + ), + builder: (context, result, child) { + final resolvedPath = result.path; + if (result.status == CompletionBridgePlayableStatus.playable && + resolvedPath != null) { return TrackGridPlayButton( tooltip: context.l10n.a11yPlayTrackByArtist( trackName, artistName, ), onPressed: () => _openFile( - playablePath, + resolvedPath, title: trackName, artist: artistName, album: albumName, @@ -197,6 +202,22 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { ), ); } + if (result.status == CompletionBridgePlayableStatus.checking) { + return Container( + width: 28, + height: 28, + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + semanticsLabel: context.l10n.queueCheckingDownloadedFile, + ), + ); + } return Tooltip( message: context.l10n.queueDownloadedFileMissing, child: Container( @@ -305,13 +326,18 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { ), trailing: playablePath == null ? null - : ValueListenableBuilder( - valueListenable: _fileExistsListenable(playablePath), - builder: (context, fileExists, child) { - if (fileExists) { + : ValueListenableBuilder( + valueListenable: _completionBridgePlayableProbe.listenable( + historyFilePath: historyItem?.filePath, + completedItemFilePath: item.filePath, + ), + builder: (context, result, child) { + final resolvedPath = result.path; + if (result.status == CompletionBridgePlayableStatus.playable && + resolvedPath != null) { return IconButton( onPressed: () => _openFile( - playablePath, + resolvedPath, title: trackName, artist: artistName, album: albumName, @@ -327,6 +353,22 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { ), ); } + if (result.status == CompletionBridgePlayableStatus.checking) { + return SizedBox.square( + dimension: context.tokens.minTouchTarget, + child: Center( + child: SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + semanticsLabel: + context.l10n.queueCheckingDownloadedFile, + ), + ), + ), + ); + } return Tooltip( message: context.l10n.queueDownloadedFileMissing, child: Icon( diff --git a/lib/screens/queue_tab_navigation.dart b/lib/screens/queue_tab_navigation.dart index 3d8e74aa..83164e30 100644 --- a/lib/screens/queue_tab_navigation.dart +++ b/lib/screens/queue_tab_navigation.dart @@ -116,11 +116,17 @@ extension _QueueTabNavigation on _QueueTabState { ); final navigator = Navigator.of(context); - precacheCoverImage(context, historyItem.coverUrl); - final backdropReady = precacheMetadataBackdrop( - context, - historyItem.coverUrl, - ); + final embeddedCoverPath = + await DownloadedEmbeddedCoverResolver.resolveOrExtract( + historyItem.filePath, + onChanged: _onEmbeddedCoverChanged, + ); + if (!mounted) return; + final artworkSource = embeddedCoverPath ?? historyItem.coverUrl; + if (embeddedCoverPath == null) { + precacheCoverImage(context, historyItem.coverUrl); + } + final backdropReady = precacheMetadataBackdrop(context, artworkSource); _searchFocusNode.unfocus(); final beforeModTime = await _readFileModTimeMillis(historyItem.filePath); await backdropReady; @@ -149,8 +155,17 @@ extension _QueueTabNavigation on _QueueTabState { int? navigationIndex, }) async { final navigator = Navigator.of(context); - precacheCoverImage(context, item.coverUrl); - final backdropReady = precacheMetadataBackdrop(context, item.coverUrl); + final embeddedCoverPath = + await DownloadedEmbeddedCoverResolver.resolveOrExtract( + item.filePath, + onChanged: _onEmbeddedCoverChanged, + ); + if (!mounted) return; + final artworkSource = embeddedCoverPath ?? item.coverUrl; + if (embeddedCoverPath == null) { + precacheCoverImage(context, item.coverUrl); + } + final backdropReady = precacheMetadataBackdrop(context, artworkSource); _searchFocusNode.unfocus(); final beforeModTime = await _readFileModTimeMillis(item.filePath); await backdropReady; diff --git a/lib/screens/settings/cache_management_page.dart b/lib/screens/settings/cache_management_page.dart index ec6a1408..a4766361 100644 --- a/lib/screens/settings/cache_management_page.dart +++ b/lib/screens/settings/cache_management_page.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -187,6 +188,7 @@ class _CacheManagementPageState extends ConsumerState { } Future _clearAppCache() async { + await DownloadedEmbeddedCoverResolver.clearPersistentCache(); final cacheDir = await getApplicationCacheDirectory(); await _clearDirectoryContents(cacheDir.path); } diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index b79bb7a7..8427cbd9 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -96,86 +96,62 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { ) { final coverCacheWidth = coverCacheWidthForViewport(context); final backdropCacheWidth = metadataBackdropCacheExtent(context); - final backdropRemoteUrl = _isLocalItem - ? null - : normalizeRemoteHttpUrl(_downloadItem!.coverUrl); + // Downloaded-item entry points must await resolveOrExtract before pushing + // this route. That precondition keeps the Hero, foreground, and backdrop + // on one source from the first frame; this synchronous lookup also covers + // cached rebuilds and in-screen swipe navigation. + final sharedEmbeddedCoverPath = _hasPath(_embeddedCoverPreviewPath) + ? _embeddedCoverPreviewPath + : DownloadedEmbeddedCoverResolver.resolve(_filePath); + final artworkSource = resolveMetadataArtworkSource( + embeddedCoverPath: sharedEmbeddedCoverPath, + localCoverPath: _localCoverPath, + remoteCoverUrl: _coverUrl, + ); - Widget coverImage() => _hasPath(_embeddedCoverPreviewPath) - ? Image.file( - File(_embeddedCoverPreviewPath!), - fit: BoxFit.cover, - cacheWidth: coverCacheWidth, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - errorBuilder: (_, _, _) => Container(color: colorScheme.surface), - ) - : _coverUrl != null - ? CachedCoverImage( - imageUrl: _coverUrl!, - fit: BoxFit.cover, - memCacheWidth: coverCacheWidth, - placeholder: (_, _) => Container(color: colorScheme.surface), - errorWidget: (_, _, _) => Container(color: colorScheme.surface), - ) - : _localCoverPath != null && _localCoverPath!.isNotEmpty - ? Image.file( - File(_localCoverPath!), - fit: BoxFit.cover, - cacheWidth: coverCacheWidth, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - errorBuilder: (_, _, _) => Container(color: colorScheme.surface), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - size: 80, - color: colorScheme.onSurfaceVariant, - ), - ); + Widget artworkImage({required int cacheWidth, int? cacheHeight}) { + final source = artworkSource; + if (source == null) { + return Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ); + } - // Keep the backdrop on the stable Library artwork source instead of - // switching to the asynchronously extracted embedded preview mid-route. - // The smaller resize key is prewarmed before navigation and is sufficient - // behind a strong blur, while the foreground Hero keeps full resolution. - Widget backdropImage() => backdropRemoteUrl != null - ? CachedCoverImage( - imageUrl: backdropRemoteUrl, - fit: BoxFit.cover, - memCacheWidth: backdropCacheWidth, - memCacheHeight: backdropCacheWidth, - placeholder: (_, _) => Container(color: colorScheme.surface), - errorWidget: (_, _, _) => Container(color: colorScheme.surface), - ) - : _localCoverPath != null && _localCoverPath!.isNotEmpty - ? Image.file( - File(_localCoverPath!), - fit: BoxFit.cover, - cacheWidth: backdropCacheWidth, - cacheHeight: backdropCacheWidth, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - errorBuilder: (_, _, _) => Container(color: colorScheme.surface), - ) - : _hasPath(_embeddedCoverPreviewPath) - ? Image.file( - File(_embeddedCoverPreviewPath!), - fit: BoxFit.cover, - cacheWidth: backdropCacheWidth, - cacheHeight: backdropCacheWidth, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - errorBuilder: (_, _, _) => Container(color: colorScheme.surface), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - size: 80, - color: colorScheme.onSurfaceVariant, - ), - ); + if (source.startsWith('http://') || source.startsWith('https://')) { + return CachedCoverImage( + imageUrl: source, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + memCacheHeight: cacheHeight, + placeholder: (_, _) => Container(color: colorScheme.surface), + errorWidget: (_, _, _) => Container(color: colorScheme.surface), + ); + } + + final filePath = source.startsWith('file://') + ? Uri.parse(source).toFilePath() + : source; + return Image.file( + File(filePath), + fit: BoxFit.cover, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + errorBuilder: (_, _, _) => Container(color: colorScheme.surface), + ); + } + + Widget coverImage() => artworkImage(cacheWidth: coverCacheWidth); + Widget backdropImage() => artworkImage( + cacheWidth: backdropCacheWidth, + cacheHeight: backdropCacheWidth, + ); // Centered square cover over a blurred backdrop (same layout as the album // header) so the Hero flight from a list thumbnail keeps its square shape. diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 8bc77ac7..d8625fe8 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/replaygain_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -243,27 +244,10 @@ class _TrackMetadataScreenState extends ConsumerState filePath == cleanFilePath && exists && !_hasPath(_embeddedCoverPreviewPath)) { - final cachedCover = await _getCachedEmbeddedCoverPreviewIfValid( - _coverCacheKey, - filePath, - ); - if (mounted && - generation == _metadataLoadGeneration && - filePath == cleanFilePath && - cachedCover != null) { - setState(() { - _embeddedCoverPreviewPath = cachedCover.previewPath; - _embeddedCoverDimensions = cachedCover.dimensions; - }); - } else if (mounted && - generation == _metadataLoadGeneration && - filePath == cleanFilePath) { - // The information card reports the artwork embedded in the audio - // file, not a potentially resized Library thumbnail or remote cover. - // Extraction is cached, so revisiting the same track does not repeat - // the work. - unawaited(_refreshEmbeddedCoverPreview()); - } + // The information card reports artwork embedded in the audio file, not + // a resized Library thumbnail or remote cover. The shared resolver owns + // extraction; this screen only caches validation data and dimensions. + unawaited(_refreshEmbeddedCoverPreview()); } } diff --git a/lib/screens/track_metadata_screen_cover.dart b/lib/screens/track_metadata_screen_cover.dart index c47112e2..116ed759 100644 --- a/lib/screens/track_metadata_screen_cover.dart +++ b/lib/screens/track_metadata_screen_cover.dart @@ -1,14 +1,15 @@ // ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member part of 'track_metadata_screen.dart'; -// Embedded-cover preview: disk cache keyed by file validation token, -// plus the FFmpeg-backed refresh that extracts the preview image. +// Embedded-cover preview: screen-local validation/dimension metadata keyed by +// track, while DownloadedEmbeddedCoverResolver owns extraction and temp files. extension _TrackMetadataCover on _TrackMetadataScreenState { bool _isCacheTrackedPath(String? path) { if (!_hasPath(path)) return false; return _TrackMetadataScreenState._embeddedCoverPreviewCache.values.any( - (entry) => entry.previewPath == path, - ); + (entry) => entry.previewPath == path, + ) || + DownloadedEmbeddedCoverResolver.isManagedPreviewPath(path); } bool _isVolatileSafTempPath(String path) { @@ -109,77 +110,59 @@ extension _TrackMetadataCover on _TrackMetadataScreenState { final generation = _metadataLoadGeneration; final cacheKey = _coverCacheKey; final sourcePath = cleanFilePath; - if (!force) { - final cachedCover = await _getCachedEmbeddedCoverPreviewIfValid( - cacheKey, - sourcePath, - ); - if (cachedCover != null) { - if (mounted && - generation == _metadataLoadGeneration && - sourcePath == cleanFilePath && - (_embeddedCoverPreviewPath != cachedCover.previewPath || - _embeddedCoverDimensions != cachedCover.dimensions)) { - setState(() { - _embeddedCoverPreviewPath = cachedCover.previewPath; - _embeddedCoverDimensions = cachedCover.dimensions; - }); - } - return; + final oldPreviewPath = _embeddedCoverPreviewPath; + + if (!_fileExists) { + await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); + await DownloadedEmbeddedCoverResolver.invalidate(sourcePath); + if (mounted && + generation == _metadataLoadGeneration && + sourcePath == cleanFilePath) { + setState(() { + _embeddedCoverPreviewPath = null; + _embeddedCoverDimensions = null; + }); } + return; + } + + if (force) { + await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); + await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( + sourcePath, + force: true, + ); } String? newPreviewPath; ({int width, int height})? newDimensions; try { - if (!_fileExists) { - await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); - await _cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath); - if (mounted && - generation == _metadataLoadGeneration && - sourcePath == cleanFilePath) { - setState(() { - _embeddedCoverPreviewPath = null; - _embeddedCoverDimensions = null; - }); - } - return; - } - if (force) { - await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); - } - final tempDir = await Directory.systemTemp.createTemp( - 'track_cover_preview_', - ); - final outputPath = - '${tempDir.path}${Platform.pathSeparator}cover_preview.jpg'; - final result = await PlatformBridge.extractCoverToFile( + newPreviewPath = await DownloadedEmbeddedCoverResolver.resolveOrExtract( sourcePath, - outputPath, ); - if (result['error'] == null && await File(outputPath).exists()) { - newPreviewPath = outputPath; - newDimensions = await FFmpegService.probeImageDimensions(outputPath); - await _cacheEmbeddedCoverPreview( - cacheKey, - sourcePath, - outputPath, - newDimensions, - ); - } else { - try { - await tempDir.delete(recursive: true); - } catch (_) {} + if (newPreviewPath != null) { + final cachedCover = force + ? null + : await _getCachedEmbeddedCoverPreviewIfValid(cacheKey, sourcePath); + if (cachedCover != null && cachedCover.previewPath == newPreviewPath) { + newDimensions = cachedCover.dimensions; + } else { + newDimensions = await FFmpegService.probeImageDimensions( + newPreviewPath, + ); + await _cacheEmbeddedCoverPreview( + cacheKey, + sourcePath, + newPreviewPath, + newDimensions, + ); + } } } catch (_) {} - final oldPreviewPath = _embeddedCoverPreviewPath; if (!mounted || generation != _metadataLoadGeneration || sourcePath != cleanFilePath) { - if (newPreviewPath != null) { - await _cleanupTempFileAndParentIfNotCached(newPreviewPath); - } return; } diff --git a/lib/services/downloaded_embedded_cover_resolver.dart b/lib/services/downloaded_embedded_cover_resolver.dart index e5a4ceac..a70b2dc2 100644 --- a/lib/services/downloaded_embedded_cover_resolver.dart +++ b/lib/services/downloaded_embedded_cover_resolver.dart @@ -1,35 +1,88 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:convert'; import 'dart:io'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; class _EmbeddedCoverCacheEntry { final String previewPath; final int? sourceModTimeMillis; + final bool isPersistent; const _EmbeddedCoverCacheEntry({ required this.previewPath, + required this.isPersistent, this.sourceModTimeMillis, }); } +class _PendingEmbeddedCoverExtraction { + final String cleanPath; + final bool forceRefresh; + final int? knownModTime; + final int generation; + final Completer completer = Completer(); + final LinkedHashSet callbacks = LinkedHashSet(); + bool isForeground; + bool started = false; + bool cancelled = false; + + _PendingEmbeddedCoverExtraction({ + required this.cleanPath, + required this.forceRefresh, + required this.knownModTime, + required this.generation, + required this.isForeground, + VoidCallback? onChanged, + }) { + if (onChanged != null) callbacks.add(onChanged); + } + + Future get future => completer.future; +} + /// Shared resolver for embedded cover previews from downloaded/local files. -/// It keeps a bounded in-memory cache and only refreshes extraction -/// when the source file changed. +/// +/// The in-memory LRU is backed by a source-versioned persistent cache, while +/// all misses still pass through the bounded, foreground-prioritized scheduler +/// so Library scrolling cannot fan out native I/O. class DownloadedEmbeddedCoverResolver { static const int _maxCacheEntries = 180; static const int _maxFailedExtractEntries = 360; + static const int _maxConcurrentExtractions = 2; + static const int _maxQueuedBackgroundExtractions = 24; + static const String _persistentCacheDirectoryName = 'embedded_cover_previews'; + static const String _persistentCacheVersion = 'v1'; + static const int _maxPersistentCacheEntries = 384; + static const int _maxPersistentCacheBytes = 160 << 20; + static const int _persistentCacheSweepTargetBytes = 128 << 20; static final LinkedHashMap _cache = LinkedHashMap(); - static final Set _pendingExtract = {}; + static final Map _pendingExtract = + {}; + static final Queue<_PendingEmbeddedCoverExtraction> + _foregroundExtractionQueue = Queue<_PendingEmbeddedCoverExtraction>(); + static final Queue<_PendingEmbeddedCoverExtraction> + _backgroundExtractionQueue = Queue<_PendingEmbeddedCoverExtraction>(); + static int _activeExtractions = 0; + static bool _drainScheduled = false; + static final Map _cacheGeneration = {}; static final Set _pendingRefresh = {}; static final Set _pendingPreviewValidation = {}; static final LinkedHashSet _failedExtract = LinkedHashSet(); + static Directory? _persistentCacheDirectoryOverride; + static Future? _persistentCacheRootFuture; + static String? _persistentCacheRootPath; + static Future? _persistentMaintenanceFuture; + static int _stagingSequence = 0; + static String cleanFilePath(String? filePath) { if (filePath == null) return ''; if (filePath.startsWith('EXISTS:')) { @@ -45,7 +98,8 @@ class DownloadedEmbeddedCoverResolver { if (isContentUri(cleanPath)) { try { final modTimes = await PlatformBridge.getSafFileModTimes([cleanPath]); - return modTimes[cleanPath]; + final modTime = modTimes[cleanPath]; + return modTime != null && modTime > 0 ? modTime : null; } catch (_) { return null; } @@ -53,7 +107,9 @@ class DownloadedEmbeddedCoverResolver { try { final stat = await File(cleanPath).stat(); - return stat.modified.millisecondsSinceEpoch; + if (stat.type == FileSystemEntityType.notFound) return null; + final modTime = stat.modified.millisecondsSinceEpoch; + return modTime > 0 ? modTime : null; } catch (_) { return null; } @@ -64,7 +120,14 @@ class DownloadedEmbeddedCoverResolver { if (cleanPath.isEmpty) return null; if (_pendingRefresh.remove(cleanPath)) { - _ensureCover(cleanPath, forceRefresh: true, onChanged: onChanged); + unawaited( + _ensureCover( + cleanPath, + forceRefresh: true, + onChanged: onChanged, + isForeground: true, + ), + ); } final cached = _cache[cleanPath]; @@ -74,9 +137,54 @@ class DownloadedEmbeddedCoverResolver { return cached.previewPath; } + // A downloaded file is the source of truth for its artwork. Start the + // extraction on a cold cache so Library does not remain stuck on the + // metadata provider's online cover until Metadata Screen is opened. + unawaited(_ensureCover(cleanPath, onChanged: onChanged)); return null; } + /// Returns the cached embedded cover, waiting for one shared extraction when + /// needed. Navigation uses this to start its Hero and blurred backdrop from + /// the same file artwork instead of swapping from the online fallback after + /// the route is already visible. + static Future resolveOrExtract( + String? filePath, { + VoidCallback? onChanged, + }) async { + final cleanPath = cleanFilePath(filePath); + if (cleanPath.isEmpty) return null; + + if (_pendingRefresh.remove(cleanPath)) { + return _ensureCover( + cleanPath, + forceRefresh: true, + onChanged: onChanged, + isForeground: true, + ); + } + + final cached = _cache[cleanPath]; + if (cached != null) { + _touch(cleanPath, cached); + _validateCachedPreviewAsync(cleanPath, cached, onChanged: onChanged); + return cached.previewPath; + } + + return _ensureCover(cleanPath, onChanged: onChanged, isForeground: true); + } + + /// Whether [previewPath] belongs to this resolver. Persistent previews stay + /// owned even after memory-LRU eviction, so borrowers must never delete a + /// file or recursively delete its shared parent directory. + static bool isManagedPreviewPath(String? previewPath) { + if (previewPath == null || previewPath.isEmpty) return false; + if (_cache.values.any((entry) => entry.previewPath == previewPath)) { + return true; + } + return _isPathInsidePersistentRoot(previewPath); + } + static Future scheduleRefreshForPath( String? filePath, { int? beforeModTime, @@ -99,18 +207,117 @@ class DownloadedEmbeddedCoverResolver { onChanged?.call(); } - static void invalidate(String? filePath) { + static Future invalidate(String? filePath) async { final cleanPath = cleanFilePath(filePath); if (cleanPath.isEmpty) return; + _cacheGeneration[cleanPath] = (_cacheGeneration[cleanPath] ?? 0) + 1; final cached = _cache.remove(cleanPath); - _pendingExtract.remove(cleanPath); + _cancelPendingExtraction(cleanPath); + _cacheGeneration.remove(cleanPath); _pendingRefresh.remove(cleanPath); _pendingPreviewValidation.remove(cleanPath); _failedExtract.remove(cleanPath); if (cached != null) { - _scheduleTempCoverCleanup(cached.previewPath); + await _cleanupCacheEntry(cached); } + await _deletePersistentVariantsForSource(cleanPath); + } + + /// Clears resolver state together with its App Cache-backed disk entries. + /// Running native work is generation-cancelled and awaited before the disk + /// directory is wiped, so a completed clear leaves no late staging output. + static Future clearPersistentCache() async { + final keys = {..._cache.keys, ..._pendingExtract.keys}; + for (final key in keys) { + _cacheGeneration[key] = (_cacheGeneration[key] ?? 0) + 1; + _cancelPendingExtraction(key); + _cacheGeneration.remove(key); + } + while (_activeExtractions > 0) { + await Future.delayed(const Duration(milliseconds: 1)); + } + _cacheGeneration.clear(); + + final entries = _cache.values.toList(growable: false); + _cache.clear(); + _pendingRefresh.clear(); + _pendingPreviewValidation.clear(); + _failedExtract.clear(); + for (final entry in entries) { + if (!entry.isPersistent) await _cleanupTempCoverPath(entry.previewPath); + } + + final maintenance = _persistentMaintenanceFuture; + if (maintenance != null) await maintenance; + final directory = await _getPersistentCacheDirectory(); + await _clearDirectoryContents(directory); + } + + @visibleForTesting + static void setPersistentCacheDirectoryForTesting(Directory? directory) { + if (_activeExtractions != 0 || _pendingExtract.isNotEmpty) { + throw StateError('Cannot replace persistent cache while work is active'); + } + _persistentCacheDirectoryOverride = directory; + _persistentCacheRootFuture = null; + _persistentCacheRootPath = directory == null + ? null + : _normalizedAbsolutePath(directory.path); + } + + /// Simulates a process restart without deleting persistent preview files. + @visibleForTesting + static Future resetMemoryStateForTesting({ + bool preservePersistentFiles = true, + }) async { + final maintenance = _persistentMaintenanceFuture; + if (maintenance != null) await maintenance; + + final keys = {..._cache.keys, ..._pendingExtract.keys}; + for (final key in keys) { + _cacheGeneration[key] = (_cacheGeneration[key] ?? 0) + 1; + _cancelPendingExtraction(key); + } + while (_activeExtractions > 0) { + await Future.delayed(const Duration(milliseconds: 1)); + } + + final entries = _cache.values.toList(growable: false); + _cache.clear(); + _pendingExtract.clear(); + _foregroundExtractionQueue.clear(); + _backgroundExtractionQueue.clear(); + _pendingRefresh.clear(); + _pendingPreviewValidation.clear(); + _failedExtract.clear(); + _cacheGeneration.clear(); + _drainScheduled = false; + for (final entry in entries) { + if (!entry.isPersistent) await _cleanupTempCoverPath(entry.previewPath); + } + + if (!preservePersistentFiles) { + final directory = await _getPersistentCacheDirectory(); + await _clearDirectoryContents(directory); + } + } + + @visibleForTesting + static Future runPersistentCacheMaintenanceForTesting({ + int maxEntries = _maxPersistentCacheEntries, + int maxBytes = _maxPersistentCacheBytes, + int targetBytes = _persistentCacheSweepTargetBytes, + }) async { + final scheduled = _persistentMaintenanceFuture; + if (scheduled != null) await scheduled; + final directory = await _getPersistentCacheDirectory(); + await _runPersistentCacheMaintenance( + directory, + maxEntries: maxEntries, + maxBytes: maxBytes, + targetBytes: targetBytes, + ); } static void _touch(String cleanPath, _EmbeddedCoverCacheEntry entry) { @@ -123,10 +330,12 @@ class DownloadedEmbeddedCoverResolver { while (_cache.length > _maxCacheEntries) { final oldestKey = _cache.keys.first; final removed = _cache.remove(oldestKey); - if (removed != null) { + if (removed != null && !removed.isPersistent) { _scheduleTempCoverCleanup(removed.previewPath); } - _pendingExtract.remove(oldestKey); + _cacheGeneration[oldestKey] = (_cacheGeneration[oldestKey] ?? 0) + 1; + _cancelPendingExtraction(oldestKey); + _cacheGeneration.remove(oldestKey); _pendingRefresh.remove(oldestKey); _pendingPreviewValidation.remove(oldestKey); _failedExtract.remove(oldestKey); @@ -152,14 +361,28 @@ class DownloadedEmbeddedCoverResolver { Future.microtask(() async { try { final exists = await fileExists(entry.previewPath); + final latest = _cache[cleanPath]; + if (!identical(latest, entry)) return; + if (!exists) { - final latest = _cache[cleanPath]; - if (latest != null && latest.previewPath == entry.previewPath) { - _cache.remove(cleanPath); - _failedExtract.remove(cleanPath); - onChanged?.call(); + _cache.remove(cleanPath); + _failedExtract.remove(cleanPath); + await _cleanupCacheEntry(entry); + onChanged?.call(); + return; + } + + final cachedModTime = entry.sourceModTimeMillis; + if (cachedModTime != null) { + final currentModTime = await readFileModTimeMillis(cleanPath); + if (currentModTime != null && currentModTime != cachedModTime) { + await _ensureCover( + cleanPath, + forceRefresh: true, + knownModTime: currentModTime, + onChanged: onChanged, + ); } - _scheduleTempCoverCleanup(entry.previewPath); } } finally { _pendingPreviewValidation.remove(cleanPath); @@ -167,76 +390,568 @@ class DownloadedEmbeddedCoverResolver { }); } - static void _ensureCover( + static Future _ensureCover( String cleanPath, { bool forceRefresh = false, int? knownModTime, VoidCallback? onChanged, + bool isForeground = false, }) { - if (cleanPath.isEmpty) return; - if (_pendingExtract.contains(cleanPath)) return; - if (!forceRefresh && _cache.containsKey(cleanPath)) return; - if (!forceRefresh && _failedExtract.contains(cleanPath)) return; + if (cleanPath.isEmpty) return Future.value(); - _pendingExtract.add(cleanPath); - Future.microtask(() async { - String? outputPath; - try { - final modTime = knownModTime ?? await readFileModTimeMillis(cleanPath); + final inFlight = _pendingExtract[cleanPath]; + if (inFlight != null) { + if (onChanged != null) inFlight.callbacks.add(onChanged); + if (isForeground && !inFlight.started && !inFlight.isForeground) { + inFlight.isForeground = true; + if (_backgroundExtractionQueue.remove(inFlight)) { + _foregroundExtractionQueue.addLast(inFlight); + } + _scheduleExtractionDrain(); + } + return inFlight.future; + } + + final cached = _cache[cleanPath]; + if (!forceRefresh && cached != null) { + return Future.value(cached.previewPath); + } + if (!forceRefresh && _failedExtract.contains(cleanPath)) { + return Future.value(); + } + + final job = _PendingEmbeddedCoverExtraction( + cleanPath: cleanPath, + forceRefresh: forceRefresh, + knownModTime: knownModTime, + generation: _cacheGeneration[cleanPath] ?? 0, + isForeground: isForeground, + onChanged: onChanged, + ); + _pendingExtract[cleanPath] = job; + if (isForeground) { + _foregroundExtractionQueue.addLast(job); + } else { + _backgroundExtractionQueue.addLast(job); + _trimBackgroundExtractionQueue(); + } + _scheduleExtractionDrain(); + return job.future; + } + + static void _trimBackgroundExtractionQueue() { + while (_backgroundExtractionQueue.length > + _maxQueuedBackgroundExtractions) { + final staleJob = _backgroundExtractionQueue.removeFirst(); + staleJob.cancelled = true; + if (identical(_pendingExtract[staleJob.cleanPath], staleJob)) { + _pendingExtract.remove(staleJob.cleanPath); + } + if (!staleJob.completer.isCompleted) { + staleJob.completer.complete(null); + } + } + } + + static void _scheduleExtractionDrain() { + if (_drainScheduled) return; + _drainScheduled = true; + scheduleMicrotask(() { + _drainScheduled = false; + _drainExtractionQueue(); + }); + } + + static void _drainExtractionQueue() { + while (_activeExtractions < _maxConcurrentExtractions) { + final _PendingEmbeddedCoverExtraction? job; + if (_foregroundExtractionQueue.isNotEmpty) { + job = _foregroundExtractionQueue.removeFirst(); + } else if (_backgroundExtractionQueue.isNotEmpty) { + job = _backgroundExtractionQueue.removeFirst(); + } else { + return; + } + + if (job.cancelled || !identical(_pendingExtract[job.cleanPath], job)) { + if (!job.completer.isCompleted) job.completer.complete(null); + continue; + } + + job.started = true; + _activeExtractions++; + unawaited(_executeExtractionJob(job)); + } + } + + static Future _executeExtractionJob( + _PendingEmbeddedCoverExtraction job, + ) async { + String? result; + try { + result = await _extractCover(job); + } finally { + if (!job.completer.isCompleted) job.completer.complete(result); + if (identical(_pendingExtract[job.cleanPath], job)) { + _pendingExtract.remove(job.cleanPath); + } + _activeExtractions--; + _scheduleExtractionDrain(); + } + } + + static bool _isCurrentExtraction(_PendingEmbeddedCoverExtraction job) { + return !job.cancelled && + (_cacheGeneration[job.cleanPath] ?? 0) == job.generation; + } + + static Future _extractCover( + _PendingEmbeddedCoverExtraction job, + ) async { + final cleanPath = job.cleanPath; + String? outputPath; + var outputIsPersistent = false; + try { + if (!_isCurrentExtraction(job)) return null; + final candidateModTime = + job.knownModTime ?? await readFileModTimeMillis(cleanPath); + final modTime = candidateModTime != null && candidateModTime > 0 + ? candidateModTime + : null; + if (!_isCurrentExtraction(job)) return null; + + String? persistentPath; + if (modTime != null) { + final directory = await _getPersistentCacheDirectory(); + persistentPath = p.join( + directory.path, + _persistentFileName(cleanPath, modTime), + ); + if (!job.forceRefresh && + await _isReusablePersistentPreview(persistentPath, cleanPath)) { + if (!_isCurrentExtraction(job)) return null; + await _touchPersistentPreview(persistentPath); + final next = _EmbeddedCoverCacheEntry( + previewPath: persistentPath, + sourceModTimeMillis: modTime, + isPersistent: true, + ); + _touch(cleanPath, next); + _failedExtract.remove(cleanPath); + _trimCacheIfNeeded(); + _notifyCoverChanged(job); + _schedulePersistentMaintenance(); + return persistentPath; + } + } + + if (persistentPath != null) { + final directory = File(persistentPath).parent; + outputPath = p.join( + directory.path, + '.${p.basenameWithoutExtension(persistentPath)}.stage_' + '${DateTime.now().microsecondsSinceEpoch}_${_stagingSequence++}.jpg', + ); + } else { final tempDir = await Directory.systemTemp.createTemp( 'download_cover_preview_', ); - outputPath = - '${tempDir.path}${Platform.pathSeparator}cover_preview.jpg'; - final result = await PlatformBridge.extractCoverToFile( - cleanPath, - outputPath, - ); + outputPath = p.join(tempDir.path, 'cover_preview.jpg'); + } - final hasCover = - result['error'] == null && await File(outputPath).exists(); - if (!hasCover) { - _rememberFailedExtract(cleanPath); - _scheduleTempCoverCleanup(outputPath); - return; - } + final result = await PlatformBridge.extractCoverToFile( + cleanPath, + outputPath, + ); - final previous = _cache[cleanPath]; - final next = _EmbeddedCoverCacheEntry( - previewPath: outputPath, - sourceModTimeMillis: modTime, - ); - _touch(cleanPath, next); - _failedExtract.remove(cleanPath); - _trimCacheIfNeeded(); + if (!_isCurrentExtraction(job)) { + await _cleanupCoverPath(outputPath); + return null; + } - if (previous != null && previous.previewPath != outputPath) { - _scheduleTempCoverCleanup(previous.previewPath); - } - onChanged?.call(); - } catch (_) { + final outputFile = File(outputPath); + final hasCover = + result['error'] == null && + await outputFile.exists() && + await outputFile.length() > 0; + if (!hasCover) { _rememberFailedExtract(cleanPath); - _scheduleTempCoverCleanup(outputPath); + if (job.forceRefresh) { + final previous = _cache.remove(cleanPath); + if (previous != null) await _cleanupCacheEntry(previous); + await _deletePersistentVariantsForSource(cleanPath); + _notifyCoverChanged(job); + } + await _cleanupCoverPath(outputPath); + return null; + } + + if (!_isCurrentExtraction(job)) { + await _cleanupCoverPath(outputPath); + return null; + } + + var finalPath = outputPath; + if (persistentPath != null) { + finalPath = await _publishPersistentPreview( + stagingPath: outputPath, + targetPath: persistentPath, + cleanPath: cleanPath, + ); + outputPath = finalPath; + outputIsPersistent = true; + } + + if (!_isCurrentExtraction(job)) { + await _cleanupCoverPath(finalPath); + return null; + } + + final previous = _cache[cleanPath]; + final next = _EmbeddedCoverCacheEntry( + previewPath: finalPath, + sourceModTimeMillis: modTime, + isPersistent: outputIsPersistent, + ); + _touch(cleanPath, next); + _failedExtract.remove(cleanPath); + _trimCacheIfNeeded(); + + if (previous != null && previous.previewPath != finalPath) { + await _cleanupCacheEntry(previous); + } + if (outputIsPersistent) { + await _deletePersistentVariantsForSource( + cleanPath, + exceptPath: finalPath, + ); + _schedulePersistentMaintenance(); + } + _notifyCoverChanged(job); + return finalPath; + } catch (_) { + if (_isCurrentExtraction(job)) { + _rememberFailedExtract(cleanPath); + if (job.forceRefresh) { + final previous = _cache.remove(cleanPath); + if (previous != null) await _cleanupCacheEntry(previous); + await _deletePersistentVariantsForSource(cleanPath); + _notifyCoverChanged(job); + } + } + await _cleanupCoverPath(outputPath); + return null; + } + } + + static void _notifyCoverChanged(_PendingEmbeddedCoverExtraction job) { + for (final callback in job.callbacks.toList(growable: false)) { + try { + callback(); + } catch (_) {} + } + } + + static void _cancelPendingExtraction(String cleanPath) { + final job = _pendingExtract.remove(cleanPath); + if (job == null) return; + job.cancelled = true; + if (!job.started) { + _foregroundExtractionQueue.remove(job); + _backgroundExtractionQueue.remove(job); + } + if (!job.completer.isCompleted) job.completer.complete(null); + } + + static Future _getPersistentCacheDirectory() async { + final override = _persistentCacheDirectoryOverride; + final String rootPath; + if (override != null) { + rootPath = override.path; + } else { + final future = _persistentCacheRootFuture ??= () async { + final appCache = await getApplicationCacheDirectory(); + return p.join(appCache.path, _persistentCacheDirectoryName); + }(); + rootPath = await future; + } + + final directory = Directory(rootPath); + _persistentCacheRootPath = _normalizedAbsolutePath(rootPath); + if (!await directory.exists()) await directory.create(recursive: true); + return directory; + } + + static String _persistentFileName(String cleanPath, int modTime) { + return '${_persistentCacheVersion}_${_stablePathHash(cleanPath)}_' + '${modTime.toRadixString(16)}.jpg'; + } + + /// Two independently mixed 63-bit FNV-1a lanes keep filenames compact. + /// The exact source path is also stored in a sidecar and verified on every + /// disk hit, so even a theoretical hash collision cannot serve wrong art. + static String _stablePathHash(String value) { + const mask = 0x7fffffffffffffff; + const prime = 0x100000001b3; + var first = 0x4bf29ce484222325; + var second = 0x6c62272e07bb0142; + for (final byte in utf8.encode(value)) { + first ^= byte; + first = (first * prime) & mask; + second ^= (byte + 0x9d) & 0xff; + second = ((second * prime) + 0x9e3779b9) & mask; + } + return '${first.toRadixString(16).padLeft(16, '0')}' + '${second.toRadixString(16).padLeft(16, '0')}'; + } + + static String _persistentSourceSidecarPath(String previewPath) => + '$previewPath.source'; + + static Future _isReusablePersistentPreview( + String previewPath, + String cleanPath, + ) async { + final preview = File(previewPath); + final sidecar = File(_persistentSourceSidecarPath(previewPath)); + try { + if (!await preview.exists() || await preview.length() <= 0) return false; + if (!await sidecar.exists()) return false; + return await sidecar.readAsString() == cleanPath; + } catch (_) { + return false; + } + } + + static Future _publishPersistentPreview({ + required String stagingPath, + required String targetPath, + required String cleanPath, + }) async { + final target = File(targetPath); + final sidecar = File(_persistentSourceSidecarPath(targetPath)); + final sidecarStaging = File( + '${sidecar.path}.stage_${DateTime.now().microsecondsSinceEpoch}_' + '${_stagingSequence++}', + ); + try { + await sidecarStaging.writeAsString(cleanPath, flush: true); + if (await sidecar.exists()) await sidecar.delete(); + await sidecarStaging.rename(sidecar.path); + + if (await target.exists()) await target.delete(); + await File(stagingPath).rename(targetPath); + return targetPath; + } finally { + await _deleteFileIfPresent(sidecarStaging); + } + } + + static Future _touchPersistentPreview(String previewPath) async { + try { + await File(previewPath).setLastModified(DateTime.now()); + } catch (_) {} + } + + static void _schedulePersistentMaintenance() { + if (_persistentMaintenanceFuture != null) return; + late final Future future; + future = Future(() async { + try { + final directory = await _getPersistentCacheDirectory(); + await _runPersistentCacheMaintenance( + directory, + maxEntries: _maxPersistentCacheEntries, + maxBytes: _maxPersistentCacheBytes, + targetBytes: _persistentCacheSweepTargetBytes, + ); } finally { - _pendingExtract.remove(cleanPath); + if (identical(_persistentMaintenanceFuture, future)) { + _persistentMaintenanceFuture = null; + } } }); + _persistentMaintenanceFuture = future; + unawaited(future); + } + + static Future _runPersistentCacheMaintenance( + Directory directory, { + required int maxEntries, + required int maxBytes, + required int targetBytes, + }) async { + if (maxEntries < 0 || maxBytes < 0 || targetBytes < 0) { + throw ArgumentError('Persistent cache limits must not be negative'); + } + if (!await directory.exists()) return; + + final activePaths = _cache.values + .where((entry) => entry.isPersistent) + .map((entry) => _normalizedAbsolutePath(entry.previewPath)) + .toSet(); + final files = []; + final stats = {}; + var totalSize = 0; + final now = DateTime.now(); + + await for (final entity in directory.list(followLinks: false)) { + if (entity is! File) continue; + final name = p.basename(entity.path); + try { + final stat = await entity.stat(); + final isAbandonedImageStage = + name.startsWith('.$_persistentCacheVersion') && + name.contains('.stage_'); + final isAbandonedSidecarStage = name.contains('.jpg.source.stage_'); + if (isAbandonedImageStage || isAbandonedSidecarStage) { + if (now.difference(stat.modified) > const Duration(hours: 1)) { + await entity.delete(); + } + continue; + } + if (name.endsWith('.jpg.source')) { + final previewPath = entity.path.substring( + 0, + entity.path.length - '.source'.length, + ); + if (!await File(previewPath).exists()) await entity.delete(); + continue; + } + if (!name.startsWith('${_persistentCacheVersion}_') || + !name.endsWith('.jpg')) { + continue; + } + files.add(entity); + stats[entity.path] = stat; + totalSize += stat.size; + } catch (_) {} + } + + if (files.length <= maxEntries && totalSize <= maxBytes) return; + files.sort( + (a, b) => stats[a.path]!.modified.compareTo(stats[b.path]!.modified), + ); + var remainingEntries = files.length; + final byteGoal = totalSize > maxBytes ? targetBytes : maxBytes; + for (final file in files) { + if (remainingEntries <= maxEntries && totalSize <= byteGoal) break; + if (activePaths.contains(_normalizedAbsolutePath(file.path))) continue; + final stat = stats[file.path]!; + try { + await file.delete(); + await _deleteFileIfPresent( + File(_persistentSourceSidecarPath(file.path)), + ); + remainingEntries--; + totalSize -= stat.size; + } catch (_) {} + } + } + + static Future _deletePersistentVariantsForSource( + String cleanPath, { + String? exceptPath, + }) async { + final directory = await _getPersistentCacheDirectory(); + final prefix = '${_persistentCacheVersion}_${_stablePathHash(cleanPath)}_'; + final normalizedExcept = exceptPath == null + ? null + : _normalizedAbsolutePath(exceptPath); + try { + await for (final entity in directory.list(followLinks: false)) { + if (entity is! File) continue; + final name = p.basename(entity.path); + if (!name.startsWith(prefix) || !name.endsWith('.jpg')) continue; + if (normalizedExcept != null && + _normalizedAbsolutePath(entity.path) == normalizedExcept) { + continue; + } + + final sidecar = File(_persistentSourceSidecarPath(entity.path)); + if (await sidecar.exists()) { + try { + if (await sidecar.readAsString() != cleanPath) continue; + } catch (_) { + continue; + } + } + await _deleteFileIfPresent(entity); + await _deleteFileIfPresent(sidecar); + } + } catch (_) {} + } + + static bool _isPathInsidePersistentRoot(String candidatePath) { + final root = _persistentCacheRootPath; + if (root == null) return false; + try { + final candidate = _normalizedAbsolutePath(candidatePath); + return p.equals(candidate, root) || p.isWithin(root, candidate); + } catch (_) { + return false; + } + } + + static String _normalizedAbsolutePath(String path) => + p.normalize(p.absolute(path)); + + static Future _cleanupCacheEntry(_EmbeddedCoverCacheEntry entry) async { + if (entry.isPersistent) { + await _cleanupPersistentCoverPath(entry.previewPath); + } else { + await _cleanupTempCoverPath(entry.previewPath); + } + } + + static Future _cleanupCoverPath(String? coverPath) async { + if (coverPath == null || coverPath.isEmpty) return; + if (_isPathInsidePersistentRoot(coverPath)) { + await _cleanupPersistentCoverPath(coverPath); + } else { + await _cleanupTempCoverPath(coverPath); + } } static void _scheduleTempCoverCleanup(String? coverPath) { unawaited(_cleanupTempCoverPath(coverPath)); } + static Future _cleanupPersistentCoverPath(String? coverPath) async { + if (coverPath == null || coverPath.isEmpty) return; + await _deleteFileIfPresent(File(coverPath)); + await _deleteFileIfPresent(File(_persistentSourceSidecarPath(coverPath))); + } + static Future _cleanupTempCoverPath(String? coverPath) async { if (coverPath == null || coverPath.isEmpty) return; try { final file = File(coverPath); - try { - await file.delete(); - } catch (_) {} + await _deleteFileIfPresent(file); try { await file.parent.delete(recursive: true); } catch (_) {} } catch (_) {} } + + static Future _deleteFileIfPresent(File file) async { + try { + if (await file.exists()) await file.delete(); + } catch (_) {} + } + + static Future _clearDirectoryContents(Directory directory) async { + if (!await directory.exists()) { + await directory.create(recursive: true); + return; + } + try { + await for (final entity in directory.list(followLinks: false)) { + try { + await entity.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + if (!await directory.exists()) await directory.create(recursive: true); + } } diff --git a/lib/widgets/cached_cover_image.dart b/lib/widgets/cached_cover_image.dart index 48a69e9a..32ebd23d 100644 --- a/lib/widgets/cached_cover_image.dart +++ b/lib/widgets/cached_cover_image.dart @@ -180,6 +180,21 @@ CachedNetworkImageProvider cachedCoverImageProvider(String url) { ); } +/// Chooses one artwork source for both the Metadata foreground cover and its +/// blurred backdrop. Embedded file artwork is authoritative for downloaded +/// tracks; local scan artwork is next, with remote metadata only as fallback. +String? resolveMetadataArtworkSource({ + String? embeddedCoverPath, + String? localCoverPath, + String? remoteCoverUrl, +}) { + for (final candidate in [embeddedCoverPath, localCoverPath, remoteCoverUrl]) { + final normalized = candidate?.trim(); + if (normalized != null && normalized.isNotEmpty) return normalized; + } + return null; +} + /// Decode size shared by Track Metadata's blurred backdrop and its prewarm. /// The backdrop is heavily blurred, so a modest square bitmap is sufficient /// and avoids allocating a second full-viewport image beside the Hero cover. diff --git a/test/downloaded_embedded_cover_resolver_test.dart b/test/downloaded_embedded_cover_resolver_test.dart new file mode 100644 index 00000000..853952d0 --- /dev/null +++ b/test/downloaded_embedded_cover_resolver_test.dart @@ -0,0 +1,528 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const backendChannel = MethodChannel('com.zarz.spotiflac/backend'); + final sourcePaths = []; + final tempDirectories = []; + + late Directory persistentCacheDirectory; + + setUp(() async { + persistentCacheDirectory = await Directory.systemTemp.createTemp( + 'embedded_cover_persistent_test_', + ); + DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting( + persistentCacheDirectory, + ); + }); + + Future createAudioFixture(String name) async { + final directory = await Directory.systemTemp.createTemp( + 'embedded_cover_resolver_test_', + ); + tempDirectories.add(directory); + final source = File('${directory.path}${Platform.pathSeparator}$name.flac'); + await source.writeAsBytes(const [0, 1, 2, 3]); + sourcePaths.add(source.path); + return source.path; + } + + tearDown(() async { + await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting( + preservePersistentFiles: false, + ); + DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, null); + sourcePaths.clear(); + for (final directory in tempDirectories) { + if (await directory.exists()) { + await directory.delete(recursive: true); + } + } + tempDirectories.clear(); + if (await persistentCacheDirectory.exists()) { + await persistentCacheDirectory.delete(recursive: true); + } + }); + + test('metadata artwork prefers embedded, then local, then remote', () { + expect( + resolveMetadataArtworkSource( + embeddedCoverPath: ' C:/covers/embedded.jpg ', + localCoverPath: 'C:/covers/local.jpg', + remoteCoverUrl: 'https://example.com/online.jpg', + ), + 'C:/covers/embedded.jpg', + ); + expect( + resolveMetadataArtworkSource( + embeddedCoverPath: ' ', + localCoverPath: ' C:/covers/local.jpg ', + remoteCoverUrl: 'https://example.com/online.jpg', + ), + 'C:/covers/local.jpg', + ); + expect( + resolveMetadataArtworkSource( + remoteCoverUrl: ' https://example.com/online.jpg ', + ), + 'https://example.com/online.jpg', + ); + expect(resolveMetadataArtworkSource(), isNull); + }); + + test( + 'cold resolve starts extraction and reports the cached preview', + () async { + final sourcePath = await createAudioFixture('cold-cache'); + var extractionCalls = 0; + final changed = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + expect(call.method, 'extractCoverToFile'); + extractionCalls++; + final arguments = call.arguments as Map; + await File( + arguments['output_path']! as String, + ).writeAsBytes(const [4, 5, 6]); + return jsonEncode({'success': true}); + }); + + final initial = DownloadedEmbeddedCoverResolver.resolve( + sourcePath, + onChanged: () { + if (!changed.isCompleted) changed.complete(); + }, + ); + + expect(initial, isNull); + await changed.future.timeout(const Duration(seconds: 2)); + final cached = DownloadedEmbeddedCoverResolver.resolve(sourcePath); + expect(extractionCalls, 1); + expect(cached, isNotNull); + expect(await File(cached!).exists(), isTrue); + expect( + DownloadedEmbeddedCoverResolver.isManagedPreviewPath(cached), + isTrue, + ); + }, + ); + + test('concurrent resolveOrExtract calls share one extraction', () async { + final sourcePath = await createAudioFixture('concurrent'); + var extractionCalls = 0; + var callbacks = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + expect(call.method, 'extractCoverToFile'); + extractionCalls++; + final arguments = call.arguments as Map; + await Future.delayed(const Duration(milliseconds: 20)); + await File( + arguments['output_path']! as String, + ).writeAsBytes(const [7, 8, 9]); + return jsonEncode({'success': true}); + }); + + final results = await Future.wait([ + DownloadedEmbeddedCoverResolver.resolveOrExtract( + sourcePath, + onChanged: () => callbacks++, + ), + DownloadedEmbeddedCoverResolver.resolveOrExtract( + sourcePath, + onChanged: () => callbacks++, + ), + DownloadedEmbeddedCoverResolver.resolveOrExtract( + sourcePath, + onChanged: () => callbacks++, + ), + ]); + + expect(extractionCalls, 1); + expect(callbacks, 3); + expect(results.toSet(), hasLength(1)); + expect(results.first, isNotNull); + expect(await File(results.first!).exists(), isTrue); + }); + + test( + 'background cover extraction is limited to two concurrent jobs', + () async { + final paths = await Future.wait([ + for (var index = 0; index < 5; index++) + createAudioFixture('bounded-$index'), + ]); + final release = Completer(); + final firstTwoStarted = Completer(); + final allCompleted = Completer(); + var calls = 0; + var active = 0; + var maxActive = 0; + var completed = 0; + addTearDown(() { + if (!release.isCompleted) release.complete(); + }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + expect(call.method, 'extractCoverToFile'); + calls++; + active++; + if (active > maxActive) maxActive = active; + if (calls == 2 && !firstTwoStarted.isCompleted) { + firstTwoStarted.complete(); + } + try { + await release.future; + final arguments = call.arguments as Map; + await File( + arguments['output_path']! as String, + ).writeAsBytes(const [13, 14, 15]); + return jsonEncode({'success': true}); + } finally { + active--; + } + }); + + void onChanged() { + completed++; + if (completed == paths.length && !allCompleted.isCompleted) { + allCompleted.complete(); + } + } + + for (final path in paths) { + expect( + DownloadedEmbeddedCoverResolver.resolve(path, onChanged: onChanged), + isNull, + ); + } + + await firstTwoStarted.future.timeout(const Duration(seconds: 2)); + await Future.delayed(const Duration(milliseconds: 20)); + expect(calls, 2); + expect(maxActive, 2); + + release.complete(); + await allCompleted.future.timeout(const Duration(seconds: 2)); + expect(calls, paths.length); + expect(maxActive, 2); + }, + ); + + test('foreground extraction is promoted ahead of background jobs', () async { + final paths = await Future.wait([ + for (var index = 0; index < 4; index++) + createAudioFixture('priority-$index'), + ]); + final releaseFirst = Completer(); + final releaseSecond = Completer(); + final firstTwoStarted = Completer(); + final thirdStarted = Completer(); + final allCompleted = Completer(); + final startedPaths = []; + var completed = 0; + addTearDown(() { + if (!releaseFirst.isCompleted) releaseFirst.complete(); + if (!releaseSecond.isCompleted) releaseSecond.complete(); + }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + expect(call.method, 'extractCoverToFile'); + final arguments = call.arguments as Map; + final audioPath = arguments['audio_path']! as String; + startedPaths.add(audioPath); + final callIndex = startedPaths.length - 1; + if (startedPaths.length == 2 && !firstTwoStarted.isCompleted) { + firstTwoStarted.complete(); + } + if (startedPaths.length == 3 && !thirdStarted.isCompleted) { + thirdStarted.complete(); + } + if (callIndex == 0) { + await releaseFirst.future; + } else if (callIndex == 1) { + await releaseSecond.future; + } + await File( + arguments['output_path']! as String, + ).writeAsBytes(const [16, 17, 18]); + return jsonEncode({'success': true}); + }); + + void onChanged() { + completed++; + if (completed == paths.length && !allCompleted.isCompleted) { + allCompleted.complete(); + } + } + + DownloadedEmbeddedCoverResolver.resolve(paths[0], onChanged: onChanged); + DownloadedEmbeddedCoverResolver.resolve(paths[1], onChanged: onChanged); + await firstTwoStarted.future.timeout(const Duration(seconds: 2)); + + DownloadedEmbeddedCoverResolver.resolve(paths[2], onChanged: onChanged); + DownloadedEmbeddedCoverResolver.resolve(paths[3], onChanged: onChanged); + final foreground = DownloadedEmbeddedCoverResolver.resolveOrExtract( + paths[3], + ); + + releaseFirst.complete(); + await thirdStarted.future.timeout(const Duration(seconds: 2)); + expect(startedPaths[2], paths[3]); + + releaseSecond.complete(); + expect(await foreground, isNotNull); + await allCompleted.future.timeout(const Duration(seconds: 2)); + expect(startedPaths, hasLength(paths.length)); + }); + + test('force refresh drops a stale cover when artwork is removed', () async { + final sourcePath = await createAudioFixture('removed-cover'); + var extractionCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + expect(call.method, 'extractCoverToFile'); + extractionCalls++; + if (extractionCalls == 1) { + final arguments = call.arguments as Map; + await File( + arguments['output_path']! as String, + ).writeAsBytes(const [10, 11, 12]); + return jsonEncode({'success': true}); + } + return jsonEncode({'error': 'embedded cover not found'}); + }); + + final original = await DownloadedEmbeddedCoverResolver.resolveOrExtract( + sourcePath, + ); + expect(original, isNotNull); + expect( + DownloadedEmbeddedCoverResolver.isManagedPreviewPath(original), + isTrue, + ); + + await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( + sourcePath, + force: true, + ); + final refreshed = await DownloadedEmbeddedCoverResolver.resolveOrExtract( + sourcePath, + ); + + expect(extractionCalls, 2); + expect(refreshed, isNull); + expect(await File(original!).exists(), isFalse); + expect( + DownloadedEmbeddedCoverResolver.isManagedPreviewPath(original), + isTrue, + ); + expect(DownloadedEmbeddedCoverResolver.resolve(sourcePath), isNull); + }); + + test( + 'persistent preview survives memory reset and revalidates source changes', + () async { + final sourcePath = await createAudioFixture('persistent-restart'); + var extractionCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + expect(call.method, 'extractCoverToFile'); + extractionCalls++; + final arguments = call.arguments as Map; + await File( + arguments['output_path']! as String, + ).writeAsBytes([extractionCalls, 20, 21]); + return jsonEncode({'success': true}); + }); + + final first = await DownloadedEmbeddedCoverResolver.resolveOrExtract( + sourcePath, + ); + expect(first, isNotNull); + expect(extractionCalls, 1); + + await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting(); + expect( + DownloadedEmbeddedCoverResolver.isManagedPreviewPath(first), + isTrue, + ); + final reused = await DownloadedEmbeddedCoverResolver.resolveOrExtract( + sourcePath, + ); + expect(reused, first); + expect(extractionCalls, 1); + + await File(sourcePath).writeAsBytes(const [9, 8, 7, 6, 5]); + await File( + sourcePath, + ).setLastModified(DateTime.now().add(const Duration(seconds: 2))); + await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting(); + + final refreshed = await DownloadedEmbeddedCoverResolver.resolveOrExtract( + sourcePath, + ); + expect(extractionCalls, 2); + expect(refreshed, isNotNull); + expect(refreshed, isNot(first)); + expect(await File(refreshed!).exists(), isTrue); + expect(await File(first!).exists(), isFalse); + }, + ); + + test('invalidate removes persistent variants after memory reset', () async { + final sourcePath = await createAudioFixture('persistent-invalidate'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + final arguments = call.arguments as Map; + await File( + arguments['output_path']! as String, + ).writeAsBytes(const [22, 23, 24]); + return jsonEncode({'success': true}); + }); + + final preview = await DownloadedEmbeddedCoverResolver.resolveOrExtract( + sourcePath, + ); + expect(preview, isNotNull); + await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting(); + await DownloadedEmbeddedCoverResolver.invalidate(sourcePath); + + expect(await File(preview!).exists(), isFalse); + final remainingPreviews = await persistentCacheDirectory + .list() + .where((entity) => entity is File && entity.path.endsWith('.jpg')) + .toList(); + expect(remainingPreviews, isEmpty); + }); + + test('persistent maintenance enforces entry and byte caps', () async { + final paths = await Future.wait([ + for (var index = 0; index < 3; index++) + createAudioFixture('persistent-cap-$index'), + ]); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + final arguments = call.arguments as Map; + await File( + arguments['output_path']! as String, + ).writeAsBytes(const [25, 26, 27, 28]); + return jsonEncode({'success': true}); + }); + + final previews = []; + for (final path in paths) { + previews.add( + (await DownloadedEmbeddedCoverResolver.resolveOrExtract(path))!, + ); + } + await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting(); + for (var index = 0; index < previews.length; index++) { + await File(previews[index]).setLastModified(DateTime(2024, 1, index + 1)); + } + + await DownloadedEmbeddedCoverResolver.runPersistentCacheMaintenanceForTesting( + maxEntries: 2, + maxBytes: 100, + targetBytes: 100, + ); + expect(await File(previews[0]).exists(), isFalse); + expect(await File(previews[1]).exists(), isTrue); + expect(await File(previews[2]).exists(), isTrue); + expect( + DownloadedEmbeddedCoverResolver.isManagedPreviewPath(previews[1]), + isTrue, + ); + + await DownloadedEmbeddedCoverResolver.runPersistentCacheMaintenanceForTesting( + maxEntries: 10, + maxBytes: 5, + targetBytes: 4, + ); + final survivors = await Future.wait([ + for (final preview in previews) File(preview).exists(), + ]); + expect(survivors.where((exists) => exists), hasLength(1)); + }); + + test( + 'persistent cache clear waits for cancelled native extraction cleanup', + () async { + final sourcePath = await createAudioFixture('clear-in-flight'); + final started = Completer(); + final release = Completer(); + addTearDown(() { + if (!release.isCompleted) release.complete(); + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + if (!started.isCompleted) started.complete(); + await release.future; + final arguments = call.arguments as Map; + await File( + arguments['output_path']! as String, + ).writeAsBytes(const [29, 30, 31]); + return jsonEncode({'success': true}); + }); + + final resolution = DownloadedEmbeddedCoverResolver.resolveOrExtract( + sourcePath, + ); + await started.future.timeout(const Duration(seconds: 2)); + var clearCompleted = false; + final clear = DownloadedEmbeddedCoverResolver.clearPersistentCache().then( + (_) => clearCompleted = true, + ); + await Future.delayed(const Duration(milliseconds: 10)); + expect(clearCompleted, isFalse); + + release.complete(); + expect(await resolution, isNull); + await clear.timeout(const Duration(seconds: 2)); + expect(clearCompleted, isTrue); + expect(await persistentCacheDirectory.list().toList(), isEmpty); + }, + ); + + test('metadata cover pipeline enforces shared resolver ownership', () { + final coverSource = File( + 'lib/screens/track_metadata_screen_cover.dart', + ).readAsStringSync(); + final cardsSource = File( + 'lib/screens/track_metadata_cards.dart', + ).readAsStringSync(); + + expect( + coverSource, + contains('DownloadedEmbeddedCoverResolver.isManagedPreviewPath(path)'), + ); + expect( + coverSource, + contains('DownloadedEmbeddedCoverResolver.resolveOrExtract('), + ); + expect(coverSource, isNot(contains('PlatformBridge.extractCoverToFile'))); + expect( + cardsSource, + contains('Downloaded-item entry points must await resolveOrExtract'), + ); + expect( + cardsSource, + contains( + 'Widget coverImage() => artworkImage(cacheWidth: coverCacheWidth)', + ), + ); + expect(cardsSource, contains('cacheHeight: backdropCacheWidth')); + }); +} diff --git a/test/queue_library_refresh_policy_test.dart b/test/queue_library_refresh_policy_test.dart index c4c39de7..45458966 100644 --- a/test/queue_library_refresh_policy_test.dart +++ b/test/queue_library_refresh_policy_test.dart @@ -77,13 +77,20 @@ void main() { ); }); - test('completion bridge prefers the finalized history path', () { + test('completion bridge prefers distinct finalized path candidates', () { expect( - resolveCompletionBridgePlayablePath( + resolveCompletionBridgePlayableCandidates( historyFilePath: ' /music/final.flac ', - completedItemFilePath: '/music/staging.flac', + completedItemFilePath: '/music/completed.flac', ), - '/music/final.flac', + ['/music/final.flac', '/music/completed.flac'], + ); + expect( + resolveCompletionBridgePlayableCandidates( + historyFilePath: ' /music/final.flac ', + completedItemFilePath: '/music/final.flac', + ), + ['/music/final.flac'], ); expect( resolveCompletionBridgePlayablePath( @@ -101,7 +108,116 @@ void main() { ); }); - test('completion bridge cards retain Play actions during a batch', () { + test( + 'completion probe falls back from stale history to completed path', + () async { + final checkedPaths = []; + final probe = CompletionBridgePlayableProbeCache( + pathExists: (path) async { + checkedPaths.add(path); + return path == '/music/completed.flac'; + }, + retryDelays: const [], + ); + addTearDown(probe.dispose); + + final result = probe.listenable( + historyFilePath: '/music/stale.flac', + completedItemFilePath: '/music/completed.flac', + ); + expect(result.value.status, CompletionBridgePlayableStatus.checking); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(checkedPaths, ['/music/stale.flac', '/music/completed.flac']); + expect(result.value.status, CompletionBridgePlayableStatus.playable); + expect(result.value.path, '/music/completed.flac'); + }, + ); + + test( + 'completion probe retries publication before reporting missing', + () async { + var checks = 0; + final probe = CompletionBridgePlayableProbeCache( + pathExists: (_) async => ++checks >= 3, + retryDelays: const [Duration.zero, Duration.zero], + ); + addTearDown(probe.dispose); + + final result = probe.listenable( + completedItemFilePath: 'content://downloads/final.flac', + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(checks, 3); + expect(result.value.status, CompletionBridgePlayableStatus.playable); + expect(result.value.path, 'content://downloads/final.flac'); + }, + ); + + test('completion probe can refresh a cached missing destination', () async { + var exists = false; + final probe = CompletionBridgePlayableProbeCache( + pathExists: (_) async => exists, + retryDelays: const [], + ); + addTearDown(probe.dispose); + + final result = probe.listenable( + completedItemFilePath: '/music/reused.flac', + ); + await Future.delayed(const Duration(milliseconds: 10)); + expect(result.value.status, CompletionBridgePlayableStatus.missing); + + exists = true; + probe.refreshForPath('/music/reused.flac'); + expect(result.value.status, CompletionBridgePlayableStatus.checking); + await Future.delayed(const Duration(milliseconds: 10)); + expect(result.value.status, CompletionBridgePlayableStatus.playable); + expect(result.value.path, '/music/reused.flac'); + }); + + test( + 'completion probe does not evict an actively listened notifier', + () async { + final probe = CompletionBridgePlayableProbeCache( + pathExists: (_) async => true, + retryDelays: const [], + maxEntries: 2, + ); + addTearDown(probe.dispose); + + final first = probe.listenable( + completedItemFilePath: '/music/first.flac', + ); + void listener() {} + + first.addListener(listener); + final second = probe.listenable( + completedItemFilePath: '/music/second.flac', + ); + probe.listenable(completedItemFilePath: '/music/third.flac'); + await Future.delayed(const Duration(milliseconds: 10)); + + expect( + identical( + first, + probe.listenable(completedItemFilePath: '/music/first.flac'), + ), + isTrue, + ); + expect( + identical( + second, + probe.listenable(completedItemFilePath: '/music/second.flac'), + ), + isFalse, + ); + expect(() => first.removeListener(listener), returnsNormally); + }, + ); + + test('completion bridge cards probe before showing Play or missing', () { final source = File( 'lib/screens/queue_tab_collection_items.dart', ).readAsStringSync(); @@ -117,10 +233,16 @@ void main() { final listSource = source.substring(listStart, badgeStart); expect(gridSource, contains('resolveCompletionBridgePlayablePath(')); - expect(gridSource, contains('_fileExistsListenable(playablePath)')); + expect(gridSource, contains('_completionBridgePlayableProbe.listenable(')); + expect(gridSource, contains('CompletionBridgePlayableStatus.checking')); + expect(gridSource, contains('semanticsLabel:')); + expect(gridSource, contains('queueCheckingDownloadedFile')); expect(gridSource, contains('TrackGridPlayButton(')); expect(listSource, contains('resolveCompletionBridgePlayablePath(')); - expect(listSource, contains('_fileExistsListenable(playablePath)')); + expect(listSource, contains('_completionBridgePlayableProbe.listenable(')); + expect(listSource, contains('CompletionBridgePlayableStatus.checking')); + expect(listSource, contains('semanticsLabel:')); + expect(listSource, contains('queueCheckingDownloadedFile')); expect(listSource, contains('Icons.play_arrow')); }); }