mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
fix(library): persist artwork previews and stabilize completion
This commit is contained in:
@@ -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...'**
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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é';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -3904,6 +3904,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadedFileMissing => '다운로드된 파일이 없음';
|
||||
|
||||
@override
|
||||
String get queueCheckingDownloadedFile => 'Checking downloaded file...';
|
||||
|
||||
@override
|
||||
String get queueDownloadCompleted => '다운로드 완료';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user