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.
This commit is contained in:
2026-05-14 16:42:19 -04:00
parent 67bacac84b
commit fb95a462fb
4 changed files with 156 additions and 26 deletions
@@ -106,11 +106,39 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
Future<void> _scheduleSwap(MediaItem newMedia) async {
_pendingPreloadId = newMedia.id;
// Fast path: when Prefetcher has already warmed both the cover
// bytes (file:// artUri populated via _toMediaItem's peekCached)
// AND the palette color (AlbumColorCache.peekColor returns
// non-null), we can commit synchronously. The slow async path
// exists for genuine cold-cache moments (first play of an album
// that sync hasn't seen yet); the fast path is what makes the
// common in-queue auto-advance flip the visual in lockstep with
// the audio transition instead of lagging a tick behind.
final artUri = newMedia.artUri;
final albumId = newMedia.extras?['album_id'] as String?;
if (artUri != null &&
artUri.isScheme('file') &&
albumId != null &&
albumId.isNotEmpty) {
final cachedColor = ref.read(albumColorCacheProvider).peekColor(albumId);
if (cachedColor != null) {
if (!mounted) return;
if (_pendingPreloadId != newMedia.id) return;
setState(() {
_displayedMedia = newMedia;
_displayedDominant = cachedColor;
});
return;
}
}
// Slow path: cover and/or color are not yet cached. Hold the
// current displayed state, preload, then atomic-commit.
//
// 1. Precache the cover image bytes so when _AlbumArt mounts with
// the new media, FileImage paints synchronously. Non-file
// artUris fall through to ServerImage which handles its own
// network load + 120ms fade — they won't snap.
final artUri = newMedia.artUri;
if (artUri != null && artUri.isScheme('file') && context.mounted) {
try {
await precacheImage(FileImage(File.fromUri(artUri)), context);
@@ -125,7 +153,6 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
// same FileImage, typically resolving within ~50ms once the
// decode completes.
Color? newDominant;
final albumId = newMedia.extras?['album_id'] as String?;
if (albumId != null && albumId.isNotEmpty) {
try {
newDominant = await ref.read(albumColorProvider(albumId).future);