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:
+43
-6
@@ -1,6 +1,9 @@
|
||||
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';
|
||||
@@ -39,14 +42,48 @@ class Prefetcher {
|
||||
(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 trackId = queue[i].id;
|
||||
if (await mgr.isCached(trackId)) continue;
|
||||
// Fire-and-forget; downloads happen in the background. Errors
|
||||
// inside pin() are caught + cleaned up internally.
|
||||
// ignore: unawaited_futures
|
||||
mgr.pin(trackId, source: CacheSource.autoPrefetch);
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user