mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-03 16:50:40 +02:00
fix(library): recover transient file availability
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
|
||||
@@ -80,6 +81,207 @@ class CompletionBridgePlayableResult {
|
||||
}
|
||||
|
||||
typedef CompletionBridgePathExists = Future<bool> Function(String path);
|
||||
typedef LibraryFilePathNormalizer = String Function(String? path);
|
||||
|
||||
/// Keeps the per-card Library file check cheap without making a transient
|
||||
/// storage miss permanent for the lifetime of the tab.
|
||||
///
|
||||
/// Android document providers can briefly return `false` immediately after a
|
||||
/// file is published. Fresh paths therefore stay optimistically playable
|
||||
/// during a bounded retry window. Confirmed misses are cached, but become
|
||||
/// eligible for a new probe after [missingRecheckAfter] when their card is
|
||||
/// rebuilt (for example after returning from the Metadata screen).
|
||||
class LibraryFileAvailabilityCache {
|
||||
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 LibraryFilePathNormalizer _normalizePath;
|
||||
final List<Duration> _retryDelays;
|
||||
final Duration _missingRecheckAfter;
|
||||
final int _maxEntries;
|
||||
final Map<String, bool> _cache = {};
|
||||
final Map<String, DateTime> _checkedAt = {};
|
||||
final Map<String, _LibraryFileAvailabilityNotifier> _notifiers = {};
|
||||
final Map<String, Timer> _retryTimers = {};
|
||||
final Map<String, int> _generations = {};
|
||||
final Set<String> _probingPaths = {};
|
||||
final ValueNotifier<bool> _alwaysMissingNotifier = ValueNotifier(false);
|
||||
bool _disposed = false;
|
||||
|
||||
LibraryFileAvailabilityCache({
|
||||
CompletionBridgePathExists pathExists = fileExists,
|
||||
LibraryFilePathNormalizer normalizePath =
|
||||
DownloadedEmbeddedCoverResolver.cleanFilePath,
|
||||
List<Duration> retryDelays = _defaultRetryDelays,
|
||||
Duration missingRecheckAfter = const Duration(seconds: 5),
|
||||
int maxEntries = _defaultMaxEntries,
|
||||
}) : assert(maxEntries > 0),
|
||||
_pathExists = pathExists,
|
||||
_normalizePath = normalizePath,
|
||||
_retryDelays = List.unmodifiable(retryDelays),
|
||||
_missingRecheckAfter = missingRecheckAfter,
|
||||
_maxEntries = maxEntries;
|
||||
|
||||
ValueListenable<bool> listenable(String? filePath) {
|
||||
final path = _normalizePath(filePath);
|
||||
if (path.isEmpty || _disposed) return _alwaysMissingNotifier;
|
||||
|
||||
final existingNotifier = _notifiers[path];
|
||||
if (existingNotifier != null) {
|
||||
final cached = _cache[path];
|
||||
if (cached != null && existingNotifier.value != cached) {
|
||||
existingNotifier.value = cached;
|
||||
}
|
||||
if (cached == null) {
|
||||
_beginProbe(path);
|
||||
} else if (!cached && _confirmedMissIsStale(path)) {
|
||||
// Keep showing the confirmed state while the background recheck runs;
|
||||
// it will flip to playable as soon as storage reports the file.
|
||||
_beginProbe(path, force: true);
|
||||
}
|
||||
return existingNotifier;
|
||||
}
|
||||
|
||||
_evictUnusedEntriesIfNeeded();
|
||||
final notifier = _LibraryFileAvailabilityNotifier(_cache[path] ?? true);
|
||||
_notifiers[path] = notifier;
|
||||
_beginProbe(path);
|
||||
return notifier;
|
||||
}
|
||||
|
||||
/// Starts a fresh optimistic check for a path whose storage state changed,
|
||||
/// such as the destination of a newly completed download.
|
||||
void refreshForPath(String? filePath) {
|
||||
final path = _normalizePath(filePath);
|
||||
if (path.isEmpty || _disposed) return;
|
||||
_beginProbe(path, force: true, optimistic: true);
|
||||
}
|
||||
|
||||
/// Publishes a successful check performed by another component. This is
|
||||
/// used by the completion bridge so its verified final path immediately
|
||||
/// heals a stale missing icon on the normal Library card.
|
||||
void markExists(String? filePath) {
|
||||
final path = _normalizePath(filePath);
|
||||
if (path.isEmpty || _disposed) return;
|
||||
|
||||
_generations[path] = (_generations[path] ?? 0) + 1;
|
||||
_retryTimers.remove(path)?.cancel();
|
||||
_probingPaths.remove(path);
|
||||
_cache[path] = true;
|
||||
_checkedAt[path] = DateTime.now();
|
||||
final notifier = _notifiers[path];
|
||||
if (notifier != null && !notifier.value) {
|
||||
notifier.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool _confirmedMissIsStale(String path) {
|
||||
final checkedAt = _checkedAt[path];
|
||||
return checkedAt == null ||
|
||||
DateTime.now().difference(checkedAt) >= _missingRecheckAfter;
|
||||
}
|
||||
|
||||
void _beginProbe(String path, {bool force = false, bool optimistic = false}) {
|
||||
if (_disposed || (!force && _probingPaths.contains(path))) return;
|
||||
|
||||
final generation = (_generations[path] ?? 0) + 1;
|
||||
_generations[path] = generation;
|
||||
_retryTimers.remove(path)?.cancel();
|
||||
_probingPaths.add(path);
|
||||
_cache.remove(path);
|
||||
_checkedAt.remove(path);
|
||||
if (optimistic) {
|
||||
final notifier = _notifiers[path];
|
||||
if (notifier != null && !notifier.value) notifier.value = true;
|
||||
}
|
||||
unawaited(_probe(path, generation: generation, attempt: 0));
|
||||
}
|
||||
|
||||
Future<void> _probe(
|
||||
String path, {
|
||||
required int generation,
|
||||
required int attempt,
|
||||
}) async {
|
||||
var exists = false;
|
||||
try {
|
||||
exists = await _pathExists(path);
|
||||
} catch (_) {}
|
||||
if (_disposed || _generations[path] != generation) return;
|
||||
|
||||
if (exists) {
|
||||
markExists(path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt < _retryDelays.length) {
|
||||
_retryTimers[path] = Timer(_retryDelays[attempt], () {
|
||||
_retryTimers.remove(path);
|
||||
if (_disposed || _generations[path] != generation) return;
|
||||
unawaited(_probe(path, generation: generation, attempt: attempt + 1));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_probingPaths.remove(path);
|
||||
_cache[path] = false;
|
||||
_checkedAt[path] = DateTime.now();
|
||||
final notifier = _notifiers[path];
|
||||
if (notifier != null && notifier.value) notifier.value = false;
|
||||
}
|
||||
|
||||
void _evictUnusedEntriesIfNeeded() {
|
||||
while (_notifiers.length >= _maxEntries) {
|
||||
String? evictionPath;
|
||||
for (final entry in _notifiers.entries) {
|
||||
if (!entry.value.hasActiveListeners) {
|
||||
evictionPath = entry.key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (evictionPath == null) return;
|
||||
_removeEntry(evictionPath);
|
||||
}
|
||||
}
|
||||
|
||||
void _removeEntry(String path) {
|
||||
_retryTimers.remove(path)?.cancel();
|
||||
_probingPaths.remove(path);
|
||||
_cache.remove(path);
|
||||
_checkedAt.remove(path);
|
||||
_generations.remove(path);
|
||||
_notifiers.remove(path)?.dispose();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
for (final timer in _retryTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
for (final notifier in _notifiers.values) {
|
||||
notifier.dispose();
|
||||
}
|
||||
_retryTimers.clear();
|
||||
_notifiers.clear();
|
||||
_probingPaths.clear();
|
||||
_cache.clear();
|
||||
_checkedAt.clear();
|
||||
_generations.clear();
|
||||
_alwaysMissingNotifier.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _LibraryFileAvailabilityNotifier extends ValueNotifier<bool> {
|
||||
_LibraryFileAvailabilityNotifier(super.value);
|
||||
|
||||
bool get hasActiveListeners => hasListeners;
|
||||
}
|
||||
|
||||
/// Probes both completion-path candidates and keeps a bridge in a neutral
|
||||
/// checking state while delayed SAF publication becomes visible. A definitive
|
||||
@@ -94,6 +296,7 @@ class CompletionBridgePlayableProbeCache {
|
||||
];
|
||||
|
||||
final CompletionBridgePathExists _pathExists;
|
||||
final ValueChanged<String>? _onPlayable;
|
||||
final List<Duration> _retryDelays;
|
||||
final int _maxEntries;
|
||||
final Map<String, _CompletionBridgeProbeEntry> _entries = {};
|
||||
@@ -103,10 +306,12 @@ class CompletionBridgePlayableProbeCache {
|
||||
|
||||
CompletionBridgePlayableProbeCache({
|
||||
CompletionBridgePathExists pathExists = fileExists,
|
||||
ValueChanged<String>? onPlayable,
|
||||
List<Duration> retryDelays = _defaultRetryDelays,
|
||||
int maxEntries = _defaultMaxEntries,
|
||||
}) : assert(maxEntries > 0),
|
||||
_pathExists = pathExists,
|
||||
_onPlayable = onPlayable,
|
||||
_retryDelays = List.unmodifiable(retryDelays),
|
||||
_maxEntries = maxEntries;
|
||||
|
||||
@@ -169,6 +374,7 @@ class CompletionBridgePlayableProbeCache {
|
||||
} catch (_) {}
|
||||
if (_disposed || generation != entry.generation) return;
|
||||
if (exists) {
|
||||
_onPlayable?.call(path);
|
||||
entry.notifier.value = CompletionBridgePlayableResult.playable(path);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -222,10 +222,9 @@ class QueueTab extends ConsumerStatefulWidget {
|
||||
|
||||
class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
static const int _libraryPageSize = 300;
|
||||
final _FileExistsListenableCache _fileExistsCache =
|
||||
_FileExistsListenableCache();
|
||||
final CompletionBridgePlayableProbeCache _completionBridgePlayableProbe =
|
||||
CompletionBridgePlayableProbeCache();
|
||||
final LibraryFileAvailabilityCache _fileExistsCache =
|
||||
LibraryFileAvailabilityCache();
|
||||
late final CompletionBridgePlayableProbeCache _completionBridgePlayableProbe;
|
||||
static const double _libraryGridMinExtent = 92;
|
||||
static const double _libraryGridDefaultExtent = 126;
|
||||
static const double _libraryGridMaxExtent = 190;
|
||||
@@ -340,6 +339,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_completionBridgePlayableProbe = CompletionBridgePlayableProbeCache(
|
||||
onPlayable: _fileExistsCache.markExists,
|
||||
);
|
||||
}
|
||||
|
||||
void _initializePageController() {
|
||||
@@ -1232,6 +1234,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final nowCompleted =
|
||||
nextItem != null && nextItem.status == DownloadStatus.completed;
|
||||
if (wasActive && nowCompleted) {
|
||||
_fileExistsCache.refreshForPath(nextItem.filePath);
|
||||
_completionBridgePlayableProbe.refreshForPath(nextItem.filePath);
|
||||
_completionBridge[id] = nextItem;
|
||||
_completionBridgeAt[id] = DateTime.now();
|
||||
@@ -1244,10 +1247,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
if (previous == null || previous == next) return;
|
||||
final historyItems = ref.read(downloadHistoryProvider).items;
|
||||
for (final bridgeItem in _completionBridge.values) {
|
||||
_fileExistsCache.refreshForPath(bridgeItem.filePath);
|
||||
_completionBridgePlayableProbe.refreshForPath(bridgeItem.filePath);
|
||||
_completionBridgePlayableProbe.refreshForPath(
|
||||
_historyItemForCompletionBridge(bridgeItem, historyItems)?.filePath,
|
||||
);
|
||||
final historyPath = _historyItemForCompletionBridge(
|
||||
bridgeItem,
|
||||
historyItems,
|
||||
)?.filePath;
|
||||
_fileExistsCache.refreshForPath(historyPath);
|
||||
_completionBridgePlayableProbe.refreshForPath(historyPath);
|
||||
}
|
||||
// The family provider already reruns for the new revision. Retain its
|
||||
// last successful page while SQLite is loading so metadata backfills
|
||||
|
||||
@@ -509,97 +509,3 @@ class _QueueItemIdsSnapshot {
|
||||
@override
|
||||
int get hashCode => Object.hashAll(ids);
|
||||
}
|
||||
|
||||
class _FileExistsListenableCache {
|
||||
static const int _maxCacheSize = 500;
|
||||
|
||||
final Map<String, bool> _cache = {};
|
||||
final Map<String, int> _missCounts = {};
|
||||
final Map<String, ValueNotifier<bool>> _notifiers = {};
|
||||
final ValueNotifier<bool> _alwaysMissingNotifier = ValueNotifier(false);
|
||||
final Set<String> _pendingChecks = {};
|
||||
|
||||
ValueListenable<bool> listenable(String? filePath) {
|
||||
final cleanPath = DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
|
||||
if (cleanPath.isEmpty) return _alwaysMissingNotifier;
|
||||
|
||||
final existingNotifier = _notifiers[cleanPath];
|
||||
if (existingNotifier != null) {
|
||||
final cached = _cache[cleanPath];
|
||||
if (cached != null && existingNotifier.value != cached) {
|
||||
existingNotifier.value = cached;
|
||||
} else if (cached == null) {
|
||||
_startCheck(cleanPath);
|
||||
}
|
||||
return existingNotifier;
|
||||
}
|
||||
|
||||
if (_notifiers.length >= _maxCacheSize) {
|
||||
final oldestKey = _notifiers.keys.first;
|
||||
_notifiers.remove(oldestKey)?.dispose();
|
||||
_cache.remove(oldestKey);
|
||||
}
|
||||
|
||||
final notifier = ValueNotifier<bool>(_cache[cleanPath] ?? true);
|
||||
_notifiers[cleanPath] = notifier;
|
||||
_startCheck(cleanPath);
|
||||
return notifier;
|
||||
}
|
||||
|
||||
void _startCheck(String cleanPath) {
|
||||
if (_pendingChecks.contains(cleanPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final cached = _cache[cleanPath];
|
||||
if (cached != null) {
|
||||
final notifier = _notifiers[cleanPath];
|
||||
if (notifier != null && notifier.value != cached) {
|
||||
notifier.value = cached;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingChecks.add(cleanPath);
|
||||
Future.microtask(() async {
|
||||
bool exists;
|
||||
try {
|
||||
exists = await fileExists(cleanPath);
|
||||
} catch (_) {
|
||||
_pendingChecks.remove(cleanPath);
|
||||
Timer(const Duration(milliseconds: 700), () => _startCheck(cleanPath));
|
||||
return;
|
||||
}
|
||||
_pendingChecks.remove(cleanPath);
|
||||
if (exists) {
|
||||
_missCounts.remove(cleanPath);
|
||||
_cache[cleanPath] = true;
|
||||
} else {
|
||||
final misses = (_missCounts[cleanPath] ?? 0) + 1;
|
||||
_missCounts[cleanPath] = misses;
|
||||
if (misses < 2) {
|
||||
Timer(
|
||||
const Duration(milliseconds: 700),
|
||||
() => _startCheck(cleanPath),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_cache[cleanPath] = false;
|
||||
}
|
||||
final notifier = _notifiers[cleanPath];
|
||||
final value = _cache[cleanPath] ?? true;
|
||||
if (notifier != null && notifier.value != value) {
|
||||
notifier.value = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final notifier in _notifiers.values) {
|
||||
notifier.dispose();
|
||||
}
|
||||
_notifiers.clear();
|
||||
_missCounts.clear();
|
||||
_alwaysMissingNotifier.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,99 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('library file cache hides transient publication misses', () async {
|
||||
var checks = 0;
|
||||
final cache = LibraryFileAvailabilityCache(
|
||||
pathExists: (_) async => ++checks >= 3,
|
||||
normalizePath: (path) => path ?? '',
|
||||
retryDelays: const [Duration.zero, Duration.zero],
|
||||
);
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
final availability = cache.listenable('/music/new.flac');
|
||||
final publishedValues = <bool>[];
|
||||
availability.addListener(() => publishedValues.add(availability.value));
|
||||
expect(availability.value, isTrue);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
expect(checks, 3);
|
||||
expect(availability.value, isTrue);
|
||||
expect(publishedValues, isNot(contains(false)));
|
||||
});
|
||||
|
||||
test('library file cache rechecks a stale confirmed miss', () async {
|
||||
var exists = false;
|
||||
final cache = LibraryFileAvailabilityCache(
|
||||
pathExists: (_) async => exists,
|
||||
normalizePath: (path) => path ?? '',
|
||||
retryDelays: const [],
|
||||
missingRecheckAfter: Duration.zero,
|
||||
);
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
final availability = cache.listenable('/music/late.flac');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(availability.value, isFalse);
|
||||
|
||||
exists = true;
|
||||
expect(identical(cache.listenable('/music/late.flac'), availability), true);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
expect(availability.value, isTrue);
|
||||
});
|
||||
|
||||
test('completion probe heals the normal library file cache', () async {
|
||||
final availabilityCache = LibraryFileAvailabilityCache(
|
||||
pathExists: (_) async => false,
|
||||
normalizePath: (path) => path ?? '',
|
||||
retryDelays: const [],
|
||||
);
|
||||
addTearDown(availabilityCache.dispose);
|
||||
|
||||
final availability = availabilityCache.listenable(
|
||||
'content://downloads/final.flac',
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(availability.value, isFalse);
|
||||
|
||||
final completionProbe = CompletionBridgePlayableProbeCache(
|
||||
pathExists: (_) async => true,
|
||||
onPlayable: availabilityCache.markExists,
|
||||
retryDelays: const [],
|
||||
);
|
||||
addTearDown(completionProbe.dispose);
|
||||
completionProbe.listenable(
|
||||
completedItemFilePath: 'content://downloads/final.flac',
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
expect(availability.value, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'download completion explicitly refreshes cached missing paths',
|
||||
() async {
|
||||
var exists = false;
|
||||
final cache = LibraryFileAvailabilityCache(
|
||||
pathExists: (_) async => exists,
|
||||
normalizePath: (path) => path ?? '',
|
||||
retryDelays: const [],
|
||||
);
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
final availability = cache.listenable('/music/reused.flac');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(availability.value, isFalse);
|
||||
|
||||
exists = true;
|
||||
cache.refreshForPath('/music/reused.flac');
|
||||
expect(availability.value, isTrue);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(availability.value, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'completion probe retries publication before reporting missing',
|
||||
() async {
|
||||
|
||||
Reference in New Issue
Block a user