Files
bvandeusen 3f61079c02 feat(offline): #427 S2(+S3) — two-bucket cache model
Replaces the single 5GB capBytes with independent Liked + Rolling
budgets (5GB each default). Bucket = liked-ness, NOT CacheSource:
a cached track currently in the liked set is charged to / evicted
under Liked; everything else is Rolling. Storage-only dedup — it
never filters playback (S4's offline lists query the whole index).

- db.dart: schema 8→9, AudioCacheIndex.lastPlayedAt (real play
  recency for S4 + rolling LRU; migration backfills to cachedAt).
  drift codegen run.
- cache_settings: likedCapBytes + rollingCapBytes (+ setters); old
  cache_cap_bytes key dropped, defaults reapply (not data loss).
- audio_cache_manager: touch(); bucketUsage() counts orphan
  partials (LockCaching files never indexed) as Rolling so the cap
  truly bounds disk; evictBuckets() drains non-liked LRU then
  sweeps orphans, Liked only by its own (large) cap — normal use
  never evicts the user's liked library.
- prefetcher → evictBuckets with the cached liked set.
- storage_section: two cap selectors + per-bucket usage (folds in
  S3 to avoid a broken intermediate).
- Explicit Download dropped: removed album + playlist Download
  buttons, autoPlaylist pins, now-unused imports.
- Tests updated/compiled (drift-cohort tests are CI-skipped).

High blast radius (eviction deletes files) — liked-protective by
design; needs operator device-check before "done".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:07:45 -04:00

109 lines
4.3 KiB
Dart

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<AsyncValue<MediaItem?>>(mediaItemProvider, (_, __) => _reconcile());
_ref.listen<AsyncValue<CacheSettings>>(cacheSettingsProvider, (_, __) => _reconcile());
}
final Ref _ref;
Future<void> _reconcile() async {
final settings = _ref.read(cacheSettingsProvider).value;
if (settings == null) return;
final queue = _ref.read(queueProvider).value ?? const <MediaItem>[];
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 <String>{};
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<Prefetcher>((ref) {
return Prefetcher(ref);
});