diff --git a/flutter_client/lib/player/now_playing_screen.dart b/flutter_client/lib/player/now_playing_screen.dart index 18ed3e00..4396cff3 100644 --- a/flutter_client/lib/player/now_playing_screen.dart +++ b/flutter_client/lib/player/now_playing_screen.dart @@ -45,16 +45,72 @@ 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; + /// The MediaItem currently displayed on the screen. Held separately + /// from the live mediaItemProvider so we can preload the new track's + /// cover bytes + dominant color BEFORE flipping the visible state. + /// Without this gate the full-player UI swapped to the new track's + /// title/cover slot immediately while the image was still decoding, + /// producing a visible "pop in" once the bytes arrived. + MediaItem? _displayedMedia; - /// 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; + /// Dominant color of [_displayedMedia]'s cover. Tweened by the + /// backdrop AnimatedContainer when it changes. + Color? _displayedDominant; + + /// The id of the most-recent track we kicked a preload for. Used to + /// drop stale preload completions when the user skips rapidly past + /// a track whose cover hadn't finished decoding yet. + String? _pendingPreloadId; + + /// Preload the new track's cover bytes + dominant color, then + /// atomically flip _displayedMedia / _displayedDominant. Until both + /// resolve, the screen continues to render the previous track — + /// the new title/cover/gradient appear together in one frame. + /// + /// Concurrency: if a second track change arrives while the first + /// preload is still in flight, the older preload's + /// _pendingPreloadId check fails and its completion is dropped. + Future _scheduleSwap(MediaItem newMedia) async { + _pendingPreloadId = newMedia.id; + + // 1. Precache the cover image bytes so when _AlbumArt mounts with + // the new media, FileImage paints synchronously. Non-file + // artUris fall through to ServerImage which handles its own + // network load + 120ms fade — they won't snap. + final artUri = newMedia.artUri; + if (artUri != null && artUri.isScheme('file') && context.mounted) { + try { + await precacheImage(FileImage(File.fromUri(artUri)), context); + } catch (_) { + // Decode failed (missing file, corrupt bytes) — proceed + // anyway; _AlbumArt's errorBuilder shows the slate fallback. + } + } + + // 2. Wait for the dominant-color extraction so the gradient is + // populated when we swap. PaletteGenerator runs against the + // same FileImage, typically resolving within ~50ms once the + // decode completes. + Color? newDominant; + final albumId = newMedia.extras?['album_id'] as String?; + if (albumId != null && albumId.isNotEmpty) { + try { + newDominant = await ref.read(albumColorProvider(albumId).future); + } catch (_) { + // Color extraction failed — keep the previous dominant so the + // backdrop doesn't drop to obsidian on the swap. + } + } + + // 3. Atomic flip. Bail if a newer swap superseded us, or the + // screen was disposed mid-preload. + if (!mounted) return; + if (_pendingPreloadId != newMedia.id) return; + setState(() { + _displayedMedia = newMedia; + if (newDominant != null) _displayedDominant = newDominant; + }); + } void _onDragStart(DragStartDetails _) { _dragOffset = 0; @@ -78,10 +134,61 @@ class _NowPlayingScreenState extends ConsumerState { @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; - final media = ref.watch(mediaItemProvider).value; - final playback = ref.watch(playbackStateProvider).value; - if (media == null) { + // Listen for mediaItem changes and schedule a preload-then-swap. + // Build renders _displayedMedia / _displayedDominant; the live + // mediaItemProvider only drives the swap pipeline. + // + // Decision process: + // - On the first non-null mediaItem after mount, seed + // _displayedMedia immediately so the screen has something to + // paint. + // - On a track id change, kick off a preload. The new track's + // cover bytes + dominant color must both resolve before we + // flip the displayed state. The user sees the previous track + // in full until the new one is fully ready, then a clean + // atomic swap (no fade, no placeholder flash). + // - On the same track id but the artUri-bearing rebroadcast + // (audio_handler sends MediaItem twice on track change), + // refresh through the same preload pipeline so the displayed + // cover reflects the freshly-written AlbumCoverCache file. + ref.listen>(mediaItemProvider, (prev, next) { + final newMedia = next.asData?.value; + if (newMedia == null) { + if (_displayedMedia != null) { + setState(() { + _displayedMedia = null; + _displayedDominant = null; + _pendingPreloadId = null; + }); + } + return; + } + if (_displayedMedia == null) { + // First mount with a track playing — seed immediately, then + // kick the preload pipeline to update the dominant color and + // ensure the cover is decoded. + setState(() => _displayedMedia = newMedia); + _scheduleSwap(newMedia); + return; + } + final isNewTrack = newMedia.id != _displayedMedia!.id; + final coverGotPopulated = + newMedia.id == _displayedMedia!.id && + newMedia.artUri != null && + _displayedMedia!.artUri == null; + if (isNewTrack || coverGotPopulated) { + _scheduleSwap(newMedia); + } + }); + + final playback = ref.watch(playbackStateProvider).value; + final displayedMedia = _displayedMedia; + + if (displayedMedia == null) { + // Either nothing playing, or the very first mount before a + // mediaItem has been emitted. The ref.listen above will seed + // _displayedMedia as soon as a non-null MediaItem arrives. return const Scaffold(body: Center(child: Text('Nothing playing.'))); } @@ -90,53 +197,22 @@ class _NowPlayingScreenState extends ConsumerState { // only fires on state transitions and would leave the bar frozen // between them. final pos = ref.watch(positionProvider).value ?? Duration.zero; - final dur = media.duration ?? Duration.zero; + final dur = displayedMedia.duration ?? Duration.zero; final isPlaying = playback?.playing == true; final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all; final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none; final actions = ref.read(playerActionsProvider); - final albumId = (media.extras?['album_id'] as String?) ?? ''; + final albumId = (displayedMedia.extras?['album_id'] as String?) ?? ''; - // Dominant-color gradient backdrop seeded from the current track's - // 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 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. + // Backdrop color: render the dominant we've already committed to + // _displayedDominant. AnimatedContainer tweens between successive + // values, so the only color changes the user sees are the + // atomic-with-cover swaps from _scheduleSwap. 0.55 alpha keeps + // the gradient present without overwhelming the title/artist + // text below. + final dominant = _displayedDominant ?? fs.obsidian; 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 @@ -167,46 +243,36 @@ class _NowPlayingScreenState extends ConsumerState { child: Column( children: [ const Spacer(), - AnimatedSwitcher( - duration: _trackChangeDuration, - child: KeyedSubtree( - key: ValueKey('cover-${media.id}'), - child: _AlbumArt( - media: media, albumId: albumId, fs: fs), - ), - ), + // No AnimatedSwitcher: _displayedMedia only + // advances after the preload pipeline has the + // new cover + color ready, so a track change + // is an atomic swap. Fading would just smear a + // transition the user explicitly asked us not + // to add. + _AlbumArt( + media: displayedMedia, albumId: albumId, fs: fs), const SizedBox(height: 28), - AnimatedSwitcher( - duration: _trackChangeDuration, - child: KeyedSubtree( - key: ValueKey('title-${media.id}'), - child: _TitleRow(media: media, fs: fs), - ), - ), + _TitleRow(media: displayedMedia, fs: fs), const SizedBox(height: 4), - AnimatedSwitcher( - duration: _trackChangeDuration, - child: Column( - key: ValueKey('artist-album-${media.id}'), - mainAxisSize: MainAxisSize.min, - children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + displayedMedia.artist ?? '', + style: TextStyle(color: fs.ash, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if ((displayedMedia.album ?? '').isNotEmpty) ...[ + const SizedBox(height: 2), Text( - media.artist ?? '', - style: TextStyle(color: fs.ash, fontSize: 14), + displayedMedia.album!, + style: TextStyle(color: fs.ash, fontSize: 12), maxLines: 1, overflow: TextOverflow.ellipsis, ), - if ((media.album ?? '').isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - media.album!, - style: TextStyle(color: fs.ash, fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], ], - ), + ], ), const SizedBox(height: 24), _SecondaryControls( @@ -214,7 +280,7 @@ class _NowPlayingScreenState extends ConsumerState { actions: actions, shuffleOn: shuffleOn, repeatMode: repeatMode, - media: media, + media: displayedMedia, ), const SizedBox(height: 8), _SeekRow(position: pos, duration: dur, fs: fs, ref: ref), diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart index d6f31cec..e2d559ea 100644 --- a/flutter_client/lib/player/player_bar.dart +++ b/flutter_client/lib/player/player_bar.dart @@ -28,16 +28,37 @@ import 'player_provider.dart'; /// Tap anywhere on the bar (including art/title) ascends to the full /// player. A vertical drag upward also ascends, so the gesture mirrors /// the drag-down dismissal on the full screen. -class PlayerBar extends ConsumerWidget { +class PlayerBar extends ConsumerStatefulWidget { const PlayerBar({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _PlayerBarState(); +} + +class _PlayerBarState extends ConsumerState { + /// Last non-null artUri we've seen from the mediaItem stream. Held + /// across the audio_handler's two-broadcast track-change pattern + /// (bare MediaItem first, then with artUri once AlbumCoverCache + /// resolves) so the mini bar keeps showing the previous track's + /// cover until the new one is known. Without this hold the bar + /// flickered to the slate placeholder for ~100ms on every track + /// change. + Uri? _lastArtUri; + + @override + Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; final media = ref.watch(mediaItemProvider).value; final playback = ref.watch(playbackStateProvider).value; if (media == null) return const SizedBox.shrink(); + // Capture the latest non-null artUri so _TrackInfo's cover stays + // continuous across track changes. + if (media.artUri != null) _lastArtUri = media.artUri; + final displayMedia = media.artUri == null && _lastArtUri != null + ? media.copyWith(artUri: _lastArtUri) + : media; + // positionProvider is just_audio's positionStream (~200ms cadence) // so the mini bar's seek crawls forward smoothly. PlaybackState // only updates on event transitions and would leave it frozen. @@ -65,7 +86,7 @@ class PlayerBar extends ConsumerWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Expanded(child: _TrackInfo(media: media)), + Expanded(child: _TrackInfo(media: displayMedia)), const SizedBox(width: 8), _PlayControls(playback: playback, ref: ref), ], @@ -96,27 +117,27 @@ class _TrackInfo extends StatelessWidget { // than fade-cutting. Tag is stable per-route (not keyed by media.id) // so the transition works regardless of what's playing. // - // 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) { + // Why no transition / placeholder handling: the audio_handler + // broadcasts MediaItem twice on track change — once with artUri + // null (the new track's bare metadata), then with artUri set once + // AlbumCoverCache resolves. The widget tree above hands us the + // most-recent non-null artUri (`displayArtUri`), so the previous + // track's cover stays visible across that null gap and the new + // cover snaps in the moment its artUri arrives. Rapid change is + // fine here per operator preference; AnimatedSwitcher previously + // smeared the swap and made the slate placeholder visible. + final displayArtUri = media.artUri; + final Widget cover; + if (displayArtUri != 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. - 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()), + cover = Image( + image: displayArtUri.isScheme('file') + ? FileImage(File.fromUri(displayArtUri)) as ImageProvider + : CachedNetworkImageProvider(displayArtUri.toString()), width: 48, height: 48, // Without a fit, Image paints the source at its intrinsic @@ -128,19 +149,8 @@ class _TrackInfo extends StatelessWidget { Container(width: 48, height: 48, color: fs.slate), ); } else { - coverChild = Container( - key: const ValueKey('art-placeholder'), - width: 48, - height: 48, - color: fs.slate, - ); + cover = Container(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 +