fix(player): preload-then-swap cover transitions on both player surfaces

Previous fixes layered AnimatedSwitcher fades on top of a race: the
audio_handler broadcasts MediaItem twice on every track change
(bare metadata first, then with artUri once AlbumCoverCache resolves)
and the image bytes themselves decode asynchronously after the
widget mounts. The fades just smeared the resulting placeholder
flash without addressing the underlying ordering.

Rewrite the decision process around "load first, then swap":

**Mini player** (rapid change is acceptable per operator preference):
- Drop AnimatedSwitcher entirely
- PlayerBar becomes stateful, holds the most-recent non-null artUri
- Builds the child MediaItem with artUri = currentArtUri ?? _lastArtUri,
  so the previous cover stays visible across the null-artUri gap and
  the new cover snaps in the moment its artUri arrives

**Full player** (operator wants the image fully loaded before any
visible change):
- Introduce _displayedMedia + _displayedDominant state
- ref.listen on mediaItemProvider schedules a preload for each new
  track id (and for the artUri-bearing rebroadcast on the same id)
- _scheduleSwap awaits precacheImage on the file:// artUri AND
  awaits albumColorProvider's future for the dominant color
- Only then setState flips _displayedMedia + _displayedDominant in
  one frame — cover, title, gradient all advance atomically
- Drop the per-element AnimatedSwitcher wrappers; the backdrop
  AnimatedContainer still tweens between successive dominant
  colors so the gradient transition is smooth, not snap

Concurrency: rapid skips drop stale preload completions via
_pendingPreloadId. Decode/color failures fall through to the
previous dominant + the ServerImage/error-builder fallbacks.
This commit is contained in:
2026-05-14 12:07:50 -04:00
parent 8cb9a8b797
commit 86d67f6fc6
2 changed files with 193 additions and 117 deletions
+152 -86
View File
@@ -45,16 +45,72 @@ 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;
/// 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<void> _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<NowPlayingScreen> {
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<AsyncValue<MediaItem?>>(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<NowPlayingScreen> {
// 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<NowPlayingScreen> {
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<NowPlayingScreen> {
actions: actions,
shuffleOn: shuffleOn,
repeatMode: repeatMode,
media: media,
media: displayedMedia,
),
const SizedBox(height: 8),
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
+41 -31
View File
@@ -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<PlayerBar> createState() => _PlayerBarState();
}
class _PlayerBarState extends ConsumerState<PlayerBar> {
/// 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<FabledSwordTheme>()!;
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 +