Files
minstrel/flutter_client/lib/cache/prefetcher.dart
T
bvandeusen fb95a462fb fix(player): align audio + UI on track change, prewarm covers + palette
Four related fixes to the player flow that together remove the
audio↔UI lag on track change:

1. **Prefetcher pre-warms covers + palette for the next N tracks.**
   The existing prefetcher pinned audio files only. When auto-advance
   landed on the next track, the cover bytes were cold → mediaItem
   broadcast with artUri=null → now-playing screen stalled in
   _scheduleSwap awaiting precacheImage of a file that didn't exist
   yet. Each upcoming queue item now also fires
   AlbumCoverCache.getOrFetch (writes bytes to disk so _toMediaItem's
   peekCached returns the path) and AlbumColorCache.getOrExtract
   (memoizes the dominant color). Both fire-and-forget; idempotent
   if already cached.

2. **AlbumColorCache.peekColor sync getter** so the now-playing
   fast-path can read the memoized color without awaiting a Future.

3. **_scheduleSwap fast path** when cover bytes + palette are
   already cached (the common in-queue auto-advance case): commit
   _displayedMedia / _displayedDominant synchronously in setState
   without awaiting. The async preload remains as the slow-path
   fallback for genuine cold cache. This is what closes the gap
   the user reported: "art is loading and metadata hasn't updated
   but the new song is playing."

4. **setQueueFromTracks: build source before broadcasting queue /
   mediaItem.** Previously we broadcast immediately for snappy UI;
   if _buildAudioSource threw, the UI showed the new track while
   the player held the old source. Now: build first, broadcast
   only on success. Source build is sub-100ms on warm cache so the
   tap response cost is imperceptible. _suppressIndexUpdates is set
   around setAudioSources + broadcast so a transient currentIndex
   emission can't cross-broadcast the OLD queue's entry at the NEW
   index.

5. **playbackEventStream error handler skips past failing tracks.**
   Previously errors only logged. The player would go silent on a
   404 / decoder failure but mediaItem stayed on the failed track —
   user saw "now playing X" with no audio. Now seekToNext on error;
   if at queue tail, pause cleanly so PlaybackState reflects idle.
2026-05-14 16:42:19 -04:00

101 lines
3.9 KiB
Dart

import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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.
if (settings.capBytes > 0) {
await mgr.evict(targetBytes: settings.capBytes);
}
}
}
/// Read this provider once at app start to activate the prefetcher.
/// Constructor wires the listeners.
final prefetcherProvider = Provider<Prefetcher>((ref) {
return Prefetcher(ref);
});