fix(player): smooth full-player cover + backdrop on track change

Two related "snap in" effects on the now-playing screen:

1. **Album art snapped in after the fade.** AnimatedSwitcher cross-
   fades the new _AlbumArt over 300ms, but FileImage's bytes weren't
   decoded yet — the widget was visually empty during the fade and
   the cover landed abruptly after. precacheImage on the new file://
   artUri pre-decodes the bytes so by the time AnimatedSwitcher
   mounts the new tile, the cover paints synchronously inside it.
   The cross-fade now carries real content end-to-end.

2. **Backdrop color snapped in.** albumColorProvider.family is
   loading for the new id during the track-change moment, so
   dominant fell back to fs.obsidian; AnimatedContainer tweened to
   obsidian and then snapped to the resolved color a beat later.
   _NowPlayingScreenState now holds _lastDominant across builds:
   while extraction for the new id is loading, the gradient stays
   on the previous album's color, then animates straight to the
   new one once it resolves. No intermediate obsidian stop.

Net effect: track changes feel like a single smooth transition
instead of fade-out → blank → snap.
This commit is contained in:
2026-05-14 07:41:27 -04:00
parent 6a08d94255
commit 2ebe6229b7
@@ -45,6 +45,17 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
// dismiss. Reset on each drag start.
double _dragOffset = 0;
/// Last resolved dominant color. Held across track changes so the
/// gradient backdrop tweens smoothly from the previous album's color
/// to the next, instead of dropping through fs.obsidian during the
/// brief moment albumColorProvider for the new id is still loading.
Color? _lastDominant;
/// Dedupe key for the cover precache side-effect. Without this we'd
/// fire precacheImage on every MediaItem rebroadcast (twice per track
/// change, once on artUri arrival).
String? _precachedArtUri;
void _onDragStart(DragStartDetails _) {
_dragOffset = 0;
}
@@ -87,17 +98,45 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
final albumId = (media.extras?['album_id'] as String?) ?? '';
// Dominant-color gradient backdrop seeded from the current track's
// album cover. While the color resolves (or when no cover exists),
// the gradient collapses to a flat fs.obsidian — same look as
// before the polish landed. Tweens to the new color on track change
// via AnimatedContainer.
// album cover. Tweens to the new color on track change via
// AnimatedContainer.
//
// We hold _lastDominant across builds so a track change doesn't
// briefly tween to fs.obsidian while albumColorProvider for the
// new id is loading — that brief stop produced the "background
// coloring snaps in" effect users saw. With a held color, the
// gradient stays on the previous album's color until the new
// extraction resolves, then animates straight to it.
final dominantAsync = ref.watch(albumColorProvider(albumId));
final dominant = dominantAsync.asData?.value ?? fs.obsidian;
final resolved = dominantAsync.asData?.value;
if (resolved != null) _lastDominant = resolved;
final dominant = _lastDominant ?? fs.obsidian;
// 0.55 alpha keeps the color present without overwhelming contrast
// on the title / artist text below. The lower half of the screen
// stays obsidian for control legibility.
final gradientTop = dominant.withValues(alpha: 0.55);
// Precache the cover image bytes the moment a new MediaItem (with
// a file:// artUri) is broadcast. Without this, AnimatedSwitcher's
// cross-fade for the cover would complete before FileImage finished
// decoding the bytes — visible as a "snap in" of the album art
// after the fade. precacheImage decodes in the background; by the
// time the new _AlbumArt mounts inside the switcher, the bytes are
// ready and the cover paints synchronously.
//
// Non-file artUris (the rare moment before AlbumCoverCache has
// written the file) fall back to ServerImage / CachedNetworkImage
// which has its own 120ms fade — no precache benefit there.
final artUri = media.artUri;
final artKey = artUri?.toString();
if (artUri != null &&
artUri.isScheme('file') &&
artKey != _precachedArtUri) {
_precachedArtUri = artKey;
// ignore: unawaited_futures
precacheImage(FileImage(File.fromUri(artUri)), context);
}
return Scaffold(
backgroundColor: fs.obsidian,
// The whole screen accepts vertical drag for dismissal. Buttons