import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:spotiflac_android/utils/audio_format_utils.dart'; import 'package:spotiflac_android/utils/audio_quality_badge_policy.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'; bool queueLibraryCountsHaveContent(QueueLibraryCounts counts) => counts.allTrackCount > 0 || counts.albumCount > 0 || counts.singleTrackCount > 0; QueueLibraryCounts resolveQueueLibraryCountsSnapshot({ required QueueLibraryCounts current, QueueLibraryCounts? cached, QueueLibraryCounts? activeDownloadFallback, }) { if (queueLibraryCountsHaveContent(current) || activeDownloadFallback == null) { return current; } if (cached != null && queueLibraryCountsHaveContent(cached)) { return cached; } return activeDownloadFallback; } bool shouldRetainQueueLibraryPageSnapshot({ required bool currentIsEmpty, required bool cachedHasContent, required bool activeDownloadFallbackAvailable, }) => currentIsEmpty && cachedHasContent && activeDownloadFallbackAvailable; /// Keeps a completed queue card visible until the matching Library row has /// actually landed. A completed item with an in-memory History row must not /// expire merely because the rest of its album batch was cancelled: the /// paged Library query can still be one refresh behind that persisted row. bool shouldRetainCompletionBridge({ required bool isRequeued, required bool hasActiveDownloads, required bool libraryRowLanded, required bool hasHistoryItem, required bool expired, }) { if (isRequeued || libraryRowLanded) return false; if (hasActiveDownloads || hasHistoryItem) return true; return !expired; } /// Builds a mode-aware label while a just-completed item is waiting for its /// History row. This avoids pinning the card to the track's old quality text /// when the user changes the Library label setting during an album download. String? buildCompletionBridgeFallbackQualityLabel({ required String mode, required String? completedItemFilePath, required String? storedQuality, }) { return buildLibraryAudioQualityLabel( mode: mode, format: audioFormatForPath(completedItemFilePath), storedQuality: storedQuality, ); } /// Returns distinct final-path candidates for a just-completed download. /// History is authoritative after conversion/SAF publication, while the /// completed queue item remains a safe fallback if the matched history row is /// stale or has not been adopted into the in-memory index yet. List resolveCompletionBridgePlayableCandidates({ String? historyFilePath, String? completedItemFilePath, }) { final candidates = []; for (final rawPath in [historyFilePath, completedItemFilePath]) { final path = rawPath?.trim(); if (path != null && path.isNotEmpty && !candidates.contains(path)) { candidates.add(path); } } return candidates; } /// Synchronous preferred candidate retained for non-probing callers. String? resolveCompletionBridgePlayablePath({ String? historyFilePath, String? completedItemFilePath, }) { final candidates = resolveCompletionBridgePlayableCandidates( historyFilePath: historyFilePath, completedItemFilePath: completedItemFilePath, ); return candidates.isEmpty ? null : candidates.first; } enum CompletionBridgePlayableStatus { checking, playable, missing } @immutable class CompletionBridgePlayableResult { final CompletionBridgePlayableStatus status; final String? path; const CompletionBridgePlayableResult._(this.status, this.path); const CompletionBridgePlayableResult.checking() : this._(CompletionBridgePlayableStatus.checking, null); const CompletionBridgePlayableResult.playable(String path) : this._(CompletionBridgePlayableStatus.playable, path); const CompletionBridgePlayableResult.missing() : this._(CompletionBridgePlayableStatus.missing, null); } typedef CompletionBridgePathExists = Future Function(String path); 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 /// missing result is emitted only after the retry window is exhausted. class CompletionBridgePlayableProbeCache { static const int _defaultMaxEntries = 500; static const List _defaultRetryDelays = [ Duration(milliseconds: 350), Duration(milliseconds: 700), Duration(milliseconds: 1400), Duration(milliseconds: 2200), ]; final CompletionBridgePathExists _pathExists; final ValueChanged? _onPlayable; final List _retryDelays; final int _maxEntries; final Map _entries = {}; final ValueNotifier _missingNotifier = ValueNotifier(const CompletionBridgePlayableResult.missing()); bool _disposed = false; 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; ValueListenable listenable({ String? historyFilePath, String? completedItemFilePath, }) { final candidates = resolveCompletionBridgePlayableCandidates( historyFilePath: historyFilePath, completedItemFilePath: completedItemFilePath, ); if (candidates.isEmpty || _disposed) return _missingNotifier; final key = candidates.join('\u0000'); final existing = _entries[key]; if (existing != null) return existing.notifier; while (_entries.length >= _maxEntries) { String? evictionKey; for (final candidate in _entries.entries) { if (!candidate.value.notifier.hasActiveListeners) { evictionKey = candidate.key; break; } } if (evictionKey == null) break; _entries.remove(evictionKey)?.dispose(); } final entry = _CompletionBridgeProbeEntry(candidates); _entries[key] = entry; unawaited(_probe(entry, attempt: 0)); return entry.notifier; } /// Rechecks cached probes that mention [filePath], including a previously /// missing result from an earlier completion of the same destination. void refreshForPath(String? filePath) { final path = filePath?.trim(); if (path == null || path.isEmpty || _disposed) return; for (final entry in _entries.values) { if (!entry.candidates.contains(path)) continue; entry.timer?.cancel(); entry.timer = null; entry.generation++; entry.notifier.value = const CompletionBridgePlayableResult.checking(); unawaited(_probe(entry, attempt: 0)); } } Future _probe( _CompletionBridgeProbeEntry entry, { required int attempt, }) async { final generation = entry.generation; for (final path in entry.candidates) { var exists = false; try { exists = await _pathExists(path); } catch (_) {} if (_disposed || generation != entry.generation) return; if (exists) { _onPlayable?.call(path); entry.notifier.value = CompletionBridgePlayableResult.playable(path); return; } } if (attempt >= _retryDelays.length) { entry.notifier.value = const CompletionBridgePlayableResult.missing(); return; } entry.timer = Timer(_retryDelays[attempt], () { entry.timer = null; if (_disposed || generation != entry.generation) return; unawaited(_probe(entry, attempt: attempt + 1)); }); } void dispose() { if (_disposed) return; _disposed = true; for (final entry in _entries.values) { entry.dispose(); } _entries.clear(); _missingNotifier.dispose(); } } class _CompletionBridgeProbeEntry { final List candidates; final _CompletionBridgeProbeNotifier notifier = _CompletionBridgeProbeNotifier(); Timer? timer; int generation = 0; bool disposed = false; _CompletionBridgeProbeEntry(this.candidates); void dispose() { if (disposed) return; disposed = true; generation++; timer?.cancel(); notifier.dispose(); } } class _CompletionBridgeProbeNotifier extends ValueNotifier { _CompletionBridgeProbeNotifier() : super(const CompletionBridgePlayableResult.checking()); bool get hasActiveListeners => hasListeners; }