3f61079c02
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>
109 lines
4.3 KiB
Dart
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);
|
|
});
|