fix(library): persist artwork previews and stabilize completion

This commit is contained in:
zarzet
2026-08-27 21:42:38 +07:00
parent fca8e46412
commit 933a3c49e2
28 changed files with 1906 additions and 256 deletions
+7 -1
View File
@@ -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...'**
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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é';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -3904,6 +3904,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get queueDownloadedFileMissing => '다운로드된 파일이 없음';
@override
String get queueCheckingDownloadedFile => 'Checking downloaded file...';
@override
String get queueDownloadCompleted => '다운로드 완료';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+7
View File
@@ -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"
+11 -2
View File
@@ -213,8 +213,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
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,
+11 -2
View File
@@ -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,
+10 -1
View File
@@ -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<void>(page: TrackMetadataScreen(item: historyItem)),
+191 -7
View File
@@ -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<String> resolveCompletionBridgePlayableCandidates({
String? historyFilePath,
String? completedItemFilePath,
}) {
final candidates = <String>[];
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<bool> 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<Duration> _defaultRetryDelays = [
Duration(milliseconds: 350),
Duration(milliseconds: 700),
Duration(milliseconds: 1400),
Duration(milliseconds: 2200),
];
final CompletionBridgePathExists _pathExists;
final List<Duration> _retryDelays;
final int _maxEntries;
final Map<String, _CompletionBridgeProbeEntry> _entries = {};
final ValueNotifier<CompletionBridgePlayableResult> _missingNotifier =
ValueNotifier(const CompletionBridgePlayableResult.missing());
bool _disposed = false;
CompletionBridgePlayableProbeCache({
CompletionBridgePathExists pathExists = fileExists,
List<Duration> retryDelays = _defaultRetryDelays,
int maxEntries = _defaultMaxEntries,
}) : assert(maxEntries > 0),
_pathExists = pathExists,
_retryDelays = List.unmodifiable(retryDelays),
_maxEntries = maxEntries;
ValueListenable<CompletionBridgePlayableResult> 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<void> _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<String> 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<CompletionBridgePlayableResult> {
_CompletionBridgeProbeNotifier()
: super(const CompletionBridgePlayableResult.checking());
bool get hasActiveListeners => hasListeners;
}
+11
View File
@@ -224,6 +224,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
_hideSelectionOverlay();
_hidePlaylistSelectionOverlay();
_fileExistsCache.dispose();
_completionBridgePlayableProbe.dispose();
_embeddedCoverVersion.dispose();
_filterPageController?.dispose();
_searchController.dispose();
@@ -1229,6 +1232,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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.
+52 -10
View File
@@ -179,17 +179,22 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
Positioned(
right: 4,
bottom: 4,
child: ValueListenableBuilder<bool>(
valueListenable: _fileExistsListenable(playablePath),
builder: (context, fileExists, child) {
if (fileExists) {
child: ValueListenableBuilder<CompletionBridgePlayableResult>(
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<bool>(
valueListenable: _fileExistsListenable(playablePath),
builder: (context, fileExists, child) {
if (fileExists) {
: ValueListenableBuilder<CompletionBridgePlayableResult>(
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(
+22 -7
View File
@@ -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;
@@ -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<CacheManagementPage> {
}
Future<void> _clearAppCache() async {
await DownloadedEmbeddedCoverResolver.clearPersistentCache();
final cacheDir = await getApplicationCacheDirectory();
await _clearDirectoryContents(cacheDir.path);
}
+54 -78
View File
@@ -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.
+5 -21
View File
@@ -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<TrackMetadataScreen>
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());
}
}
+44 -61
View File
@@ -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;
}
@@ -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<String?> completer = Completer<String?>();
final LinkedHashSet<VoidCallback> callbacks = LinkedHashSet<VoidCallback>();
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<String?> 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<String, _EmbeddedCoverCacheEntry> _cache =
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
static final Set<String> _pendingExtract = <String>{};
static final Map<String, _PendingEmbeddedCoverExtraction> _pendingExtract =
<String, _PendingEmbeddedCoverExtraction>{};
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<String, int> _cacheGeneration = <String, int>{};
static final Set<String> _pendingRefresh = <String>{};
static final Set<String> _pendingPreviewValidation = <String>{};
static final LinkedHashSet<String> _failedExtract = LinkedHashSet<String>();
static Directory? _persistentCacheDirectoryOverride;
static Future<String>? _persistentCacheRootFuture;
static String? _persistentCacheRootPath;
static Future<void>? _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<String?> 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<void> scheduleRefreshForPath(
String? filePath, {
int? beforeModTime,
@@ -99,18 +207,117 @@ class DownloadedEmbeddedCoverResolver {
onChanged?.call();
}
static void invalidate(String? filePath) {
static Future<void> 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<void> clearPersistentCache() async {
final keys = <String>{..._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<void>.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<void> resetMemoryStateForTesting({
bool preservePersistentFiles = true,
}) async {
final maintenance = _persistentMaintenanceFuture;
if (maintenance != null) await maintenance;
final keys = <String>{..._cache.keys, ..._pendingExtract.keys};
for (final key in keys) {
_cacheGeneration[key] = (_cacheGeneration[key] ?? 0) + 1;
_cancelPendingExtraction(key);
}
while (_activeExtractions > 0) {
await Future<void>.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<void> 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<String?> _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<String?>.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<String?>.value(cached.previewPath);
}
if (!forceRefresh && _failedExtract.contains(cleanPath)) {
return Future<String?>.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<void> _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<String?> _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<Directory> _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<bool> _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<String> _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<void> _touchPersistentPreview(String previewPath) async {
try {
await File(previewPath).setLastModified(DateTime.now());
} catch (_) {}
}
static void _schedulePersistentMaintenance() {
if (_persistentMaintenanceFuture != null) return;
late final Future<void> future;
future = Future<void>(() 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<void> _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 = <File>[];
final stats = <String, FileStat>{};
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<void> _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<void> _cleanupCacheEntry(_EmbeddedCoverCacheEntry entry) async {
if (entry.isPersistent) {
await _cleanupPersistentCoverPath(entry.previewPath);
} else {
await _cleanupTempCoverPath(entry.previewPath);
}
}
static Future<void> _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<void> _cleanupPersistentCoverPath(String? coverPath) async {
if (coverPath == null || coverPath.isEmpty) return;
await _deleteFileIfPresent(File(coverPath));
await _deleteFileIfPresent(File(_persistentSourceSidecarPath(coverPath)));
}
static Future<void> _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<void> _deleteFileIfPresent(File file) async {
try {
if (await file.exists()) await file.delete();
} catch (_) {}
}
static Future<void> _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);
}
}
+15
View File
@@ -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.
@@ -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 = <String>[];
final tempDirectories = <Directory>[];
late Directory persistentCacheDirectory;
setUp(() async {
persistentCacheDirectory = await Directory.systemTemp.createTemp(
'embedded_cover_persistent_test_',
);
DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(
persistentCacheDirectory,
);
});
Future<String> 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<void>();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'extractCoverToFile');
extractionCalls++;
final arguments = call.arguments as Map<Object?, Object?>;
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<Object?, Object?>;
await Future<void>.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<void>();
final firstTwoStarted = Completer<void>();
final allCompleted = Completer<void>();
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<Object?, Object?>;
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<void>.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<void>();
final releaseSecond = Completer<void>();
final firstTwoStarted = Completer<void>();
final thirdStarted = Completer<void>();
final allCompleted = Completer<void>();
final startedPaths = <String>[];
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<Object?, Object?>;
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<Object?, Object?>;
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<Object?, Object?>;
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<Object?, Object?>;
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<Object?, Object?>;
await File(
arguments['output_path']! as String,
).writeAsBytes(const [25, 26, 27, 28]);
return jsonEncode({'success': true});
});
final previews = <String>[];
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<void>();
final release = Completer<void>();
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<Object?, Object?>;
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<void>.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'));
});
}
+129 -7
View File
@@ -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 = <String>[];
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<void>.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<void>.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<void>.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<void>.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<void>.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'));
});
}