From a744514e10fe69bb5b34359516bd2cb9c5de0c6a Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 28 Aug 2026 01:04:04 +0700 Subject: [PATCH] fix(library): recover transient file availability --- lib/screens/queue_library_refresh_policy.dart | 206 ++++++++++++++++++ lib/screens/queue_tab.dart | 21 +- lib/screens/queue_tab_helpers.dart | 94 -------- test/queue_library_refresh_policy_test.dart | 93 ++++++++ 4 files changed, 313 insertions(+), 101 deletions(-) diff --git a/lib/screens/queue_library_refresh_policy.dart b/lib/screens/queue_library_refresh_policy.dart index 86bf0cdd..45b308cd 100644 --- a/lib/screens/queue_library_refresh_policy.dart +++ b/lib/screens/queue_library_refresh_policy.dart @@ -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 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 _defaultRetryDelays = [ + Duration(milliseconds: 350), + Duration(milliseconds: 700), + Duration(milliseconds: 1400), + Duration(milliseconds: 2200), + ]; + + final CompletionBridgePathExists _pathExists; + final LibraryFilePathNormalizer _normalizePath; + final List _retryDelays; + final Duration _missingRecheckAfter; + final int _maxEntries; + final Map _cache = {}; + final Map _checkedAt = {}; + final Map _notifiers = {}; + final Map _retryTimers = {}; + final Map _generations = {}; + final Set _probingPaths = {}; + final ValueNotifier _alwaysMissingNotifier = ValueNotifier(false); + bool _disposed = false; + + LibraryFileAvailabilityCache({ + CompletionBridgePathExists pathExists = fileExists, + LibraryFilePathNormalizer normalizePath = + DownloadedEmbeddedCoverResolver.cleanFilePath, + List 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 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 _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 { + _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? _onPlayable; final List _retryDelays; final int _maxEntries; final Map _entries = {}; @@ -103,10 +306,12 @@ class CompletionBridgePlayableProbeCache { CompletionBridgePlayableProbeCache({ CompletionBridgePathExists pathExists = fileExists, + ValueChanged? onPlayable, List 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; } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 8039e6a4..8738f0e7 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -222,10 +222,9 @@ class QueueTab extends ConsumerStatefulWidget { class _QueueTabState extends ConsumerState { 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 { @override void initState() { super.initState(); + _completionBridgePlayableProbe = CompletionBridgePlayableProbeCache( + onPlayable: _fileExistsCache.markExists, + ); } void _initializePageController() { @@ -1232,6 +1234,7 @@ class _QueueTabState extends ConsumerState { 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 { 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 diff --git a/lib/screens/queue_tab_helpers.dart b/lib/screens/queue_tab_helpers.dart index 44260b0d..d141d5f3 100644 --- a/lib/screens/queue_tab_helpers.dart +++ b/lib/screens/queue_tab_helpers.dart @@ -509,97 +509,3 @@ class _QueueItemIdsSnapshot { @override int get hashCode => Object.hashAll(ids); } - -class _FileExistsListenableCache { - static const int _maxCacheSize = 500; - - final Map _cache = {}; - final Map _missCounts = {}; - final Map> _notifiers = {}; - final ValueNotifier _alwaysMissingNotifier = ValueNotifier(false); - final Set _pendingChecks = {}; - - ValueListenable 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(_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(); - } -} diff --git a/test/queue_library_refresh_policy_test.dart b/test/queue_library_refresh_policy_test.dart index 45458966..d3bfedb8 100644 --- a/test/queue_library_refresh_policy_test.dart +++ b/test/queue_library_refresh_policy_test.dart @@ -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 = []; + availability.addListener(() => publishedValues.add(availability.value)); + expect(availability.value, isTrue); + + await Future.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.delayed(const Duration(milliseconds: 10)); + expect(availability.value, isFalse); + + exists = true; + expect(identical(cache.listenable('/music/late.flac'), availability), true); + await Future.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.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.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.delayed(const Duration(milliseconds: 10)); + expect(availability.value, isFalse); + + exists = true; + cache.refreshForPath('/music/reused.flac'); + expect(availability.value, isTrue); + await Future.delayed(const Duration(milliseconds: 10)); + expect(availability.value, isTrue); + }, + ); + test( 'completion probe retries publication before reporting missing', () async {