import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../likes/likes_provider.dart' show likedIdsProvider; import '../player/album_color_extractor.dart'; import '../player/player_provider.dart'; import 'audio_cache_manager.dart'; import 'cache_settings_provider.dart'; import 'db.dart'; /// Listens to the player's currently-playing track. When it changes, /// computes the next-N tracks ahead in the queue and pins them via /// AudioCacheManager (source: autoPrefetch). After pinning, runs an /// eviction pass against the operator-set cap. /// /// The window N comes from cacheSettingsProvider.prefetchWindow /// (default 5, configurable in Settings). class Prefetcher { Prefetcher(this._ref) { // The mediaItem stream changes whenever the active track changes // (skipNext/skipPrev or natural progression). Settings changes (e.g. // operator bumps prefetch window) also trigger a reconcile. _ref.listen>(mediaItemProvider, (_, __) => _reconcile()); _ref.listen>(cacheSettingsProvider, (_, __) => _reconcile()); } final Ref _ref; Future _reconcile() async { final settings = _ref.read(cacheSettingsProvider).value; if (settings == null) return; final queue = _ref.read(queueProvider).value ?? const []; final current = _ref.read(mediaItemProvider).value; if (queue.isEmpty || current == null) return; final currentIdx = queue.indexWhere((m) => m.id == current.id); if (currentIdx < 0) return; final endIdx = (currentIdx + settings.prefetchWindow).clamp(0, queue.length - 1); final mgr = _ref.read(audioCacheManagerProvider); final coverCache = _ref.read(albumCoverCacheProvider); final colorCache = _ref.read(albumColorCacheProvider); // Walk the window. For each upcoming track we want THREE things // ready when the player transitions into it: // // 1. Audio file on disk (otherwise playback stalls on stream // load — the original prefetcher concern). // 2. Cover bytes on disk under AlbumCoverCache (otherwise // _toMediaItem's peekCached returns null, mediaItem // broadcasts with artUri=null, and the now-playing screen // stalls in _scheduleSwap awaiting precacheImage of a file // that doesn't exist yet). // 3. Palette color extracted and memoized in AlbumColorCache // (otherwise the gradient backdrop has to wait for // PaletteGenerator to run after the cover lands — // visible as the cover snapping in before the gradient). // // Each call is idempotent (cache-aware): if already cached, it's // a no-op. Everything fire-and-forget so the reconcile completes // quickly even on a fresh queue. for (var i = currentIdx; i <= endIdx; i++) { final media = queue[i]; final trackId = media.id; final albumId = media.extras?['album_id'] as String?; if (!await mgr.isCached(trackId)) { // ignore: unawaited_futures mgr.pin(trackId, source: CacheSource.autoPrefetch); } if (albumId != null && albumId.isNotEmpty) { // Cover bytes: getOrFetch returns the file path; the side // effect (writing to disk) is what we care about. // ignore: unawaited_futures coverCache.getOrFetch(albumId); // Palette: getOrExtract chains off coverCache.getOrFetch so // it'll wait for the cover before sampling — safe to call // in parallel here. // ignore: unawaited_futures colorCache.getOrExtract(albumId); } } // Eviction pass after pinning new files. Per-bucket (#427 S2): // liked-ness decides the bucket, so a track that's both liked // and recently played is protected by the Liked cap, not the // rolling LRU. final liked = _ref.read(likedIdsProvider).value?.tracks ?? const {}; await mgr.evictBuckets( likedCap: settings.likedCapBytes, rollingCap: settings.rollingCapBytes, liked: liked, ); } } /// Read this provider once at app start to activate the prefetcher. /// Constructor wires the listeners. final prefetcherProvider = Provider((ref) { return Prefetcher(ref); });