fb95a462fb
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.
101 lines
3.9 KiB
Dart
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);
|
|
});
|