diff --git a/flutter_client/lib/player/now_playing_screen.dart b/flutter_client/lib/player/now_playing_screen.dart index 1b0038ee..18ed3e00 100644 --- a/flutter_client/lib/player/now_playing_screen.dart +++ b/flutter_client/lib/player/now_playing_screen.dart @@ -45,6 +45,17 @@ class _NowPlayingScreenState extends ConsumerState { // 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 { 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