From 6a08d94255e70469198aab402d4a14e65ce0067f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 14 May 2026 07:37:42 -0400 Subject: [PATCH] fix(player): smooth mini bar cover swap across track change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audio handler broadcasts MediaItem twice on every track change: once with artUri=null (the new track's bare metadata), then again with artUri pointing at the AlbumCoverCache file once the cover lands on disk. The mini player's cover element was rebuilding in place: previous track's image → slate placeholder → new track's image. That flash is the flicker reported on the v2026.05.13.0 build. AnimatedSwitcher around the cover (180ms crossfade) keyed by the artUri value makes the swap a smooth crossfade instead of a visible snap. The Hero parent stays — its tag is stable across track changes so the mini→full bar expansion animation keeps working unchanged. --- flutter_client/lib/player/player_bar.dart | 28 ++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart index 261e8d8d..d6f31cec 100644 --- a/flutter_client/lib/player/player_bar.dart +++ b/flutter_client/lib/player/player_bar.dart @@ -95,14 +95,25 @@ class _TrackInfo extends StatelessWidget { // artwork from this 48dp footprint to the full-screen size rather // than fade-cutting. Tag is stable per-route (not keyed by media.id) // so the transition works regardless of what's playing. - Widget cover; + // + // AnimatedSwitcher around the cover smooths the artUri null→file:// + // swap that fires on every track change: audio_handler broadcasts + // the new MediaItem without artUri immediately, then re-broadcasts + // with artUri once AlbumCoverCache resolves the path. Without the + // crossfade the cover snaps from the previous track's image to a + // slate placeholder to the new image — visible as a flicker. + final Widget coverChild; if (media.artUri != null) { // CachedNetworkImageProvider for HTTPS art URIs so the mini bar // hits the same disk cache the rest of the UI uses (ServerImage, // discover thumbnails). FileImage stays for the file:// branch // populated by AlbumCoverCache — bytes are already on disk and a // second cache layer would only burn duplicate space. - cover = Image( + coverChild = Image( + // Key includes the artUri so AnimatedSwitcher detects a swap; + // without this it treats successive Image widgets as the same + // tree node and skips the transition. + key: ValueKey('art-${media.artUri}'), image: media.artUri!.isScheme('file') ? FileImage(File.fromUri(media.artUri!)) as ImageProvider : CachedNetworkImageProvider(media.artUri.toString()), @@ -117,8 +128,19 @@ class _TrackInfo extends StatelessWidget { Container(width: 48, height: 48, color: fs.slate), ); } else { - cover = Container(width: 48, height: 48, color: fs.slate); + coverChild = Container( + key: const ValueKey('art-placeholder'), + width: 48, + height: 48, + color: fs.slate, + ); } + final cover = AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + child: coverChild, + ); return Row( // Default centering vertically aligns the like/kebab buttons // against the album art (48dp) — visually they span the title +