Files
minstrel/flutter_client/lib/player/now_playing_screen.dart
T
bvandeusen 86d67f6fc6 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.
2026-05-14 12:07:50 -04:00

587 lines
21 KiB
Dart

import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../api/endpoints/likes.dart' show LikeKind;
import '../likes/like_button.dart';
import '../models/track.dart';
import '../shared/widgets/server_image.dart';
import '../shared/widgets/track_actions/track_actions_button.dart';
import '../theme/theme_extension.dart';
import 'album_color_extractor.dart';
import 'player_provider.dart';
/// Hero tag shared between the mini player's cover and the full-screen
/// _AlbumArt's cover so tapping the mini bar animates the artwork from
/// the bar's footprint to the full-screen size. Stable per-route (not
/// keyed by media.id) so the transition works regardless of what's
/// playing.
const String kPlayerCoverHeroTag = 'player-cover';
/// Duration for the AnimatedSwitcher / AnimatedContainer track-change
/// crossfade. ~300ms reads as a smooth transition without dragging.
const Duration _trackChangeDuration = Duration(milliseconds: 300);
/// Full-screen player. Mounted on /now-playing. Pushed via a slide-up
/// transition (see routing.dart). Dismisses three ways:
///
/// - System back / leading button → Navigator.pop
/// - Vertical drag down past threshold → Navigator.pop (matches the
/// slide-down animation users expect from a "pull down to close"
/// sheet)
/// - Tap of the mini player ascends here; reverse motion descends back.
class NowPlayingScreen extends ConsumerStatefulWidget {
const NowPlayingScreen({super.key});
@override
ConsumerState<NowPlayingScreen> createState() => _NowPlayingScreenState();
}
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
// Cumulative drag distance used to decide if a vertical drag should
// dismiss. Reset on each drag start.
double _dragOffset = 0;
/// 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;
/// 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;
}
void _onDragUpdate(DragUpdateDetails d) {
_dragOffset += d.delta.dy;
}
void _onDragEnd(DragEndDetails d) {
// Pop when either the user has dragged > 80px down OR flicked
// downward at speed (>500 px/s). Either should feel responsive
// without dismissing on accidental drags.
final flicked = d.primaryVelocity != null && d.primaryVelocity! > 500;
if (_dragOffset > 80 || flicked) {
Navigator.of(context).maybePop();
}
_dragOffset = 0;
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
// 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.')));
}
// Use positionProvider (just_audio's positionStream, ~200ms) for
// the seek bar so it scrubs live; PlaybackState.updatePosition
// only fires on state transitions and would leave the bar frozen
// between them.
final pos = ref.watch(positionProvider).value ?? 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 = (displayedMedia.extras?['album_id'] as String?) ?? '';
// 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);
return Scaffold(
backgroundColor: fs.obsidian,
// The whole screen accepts vertical drag for dismissal. Buttons
// and the seek slider are still tappable since gesture arena
// gives priority to their child gesture detectors.
body: GestureDetector(
onVerticalDragStart: _onDragStart,
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: _onDragEnd,
behavior: HitTestBehavior.translucent,
child: AnimatedContainer(
duration: _trackChangeDuration,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [gradientTop, fs.obsidian],
stops: const [0.0, 0.7],
),
),
child: SafeArea(
child: Column(
children: [
_TopBar(fs: fs),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const Spacer(),
// 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),
_TitleRow(media: displayedMedia, fs: fs),
const SizedBox(height: 4),
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(
displayedMedia.album!,
style: TextStyle(color: fs.ash, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
const SizedBox(height: 24),
_SecondaryControls(
fs: fs,
actions: actions,
shuffleOn: shuffleOn,
repeatMode: repeatMode,
media: displayedMedia,
),
const SizedBox(height: 8),
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
const SizedBox(height: 24),
_PrimaryControls(
fs: fs,
ref: ref,
isPlaying: isPlaying,
),
const SizedBox(height: 24),
],
),
),
),
],
),
),
),
),
);
}
}
class _TopBar extends StatelessWidget {
const _TopBar({required this.fs});
final FabledSwordTheme fs;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 48,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(children: [
IconButton(
icon: Icon(Icons.expand_more, color: fs.parchment),
tooltip: 'Close',
onPressed: () => Navigator.of(context).maybePop(),
),
const Spacer(),
IconButton(
icon: Icon(Icons.queue_music, color: fs.parchment),
tooltip: 'Queue',
onPressed: () => GoRouter.of(context).push('/queue'),
),
]),
),
);
}
}
class _AlbumArt extends StatelessWidget {
const _AlbumArt({required this.media, required this.albumId, required this.fs});
final MediaItem media;
final String albumId;
final FabledSwordTheme fs;
@override
Widget build(BuildContext context) {
// Pick the best available source. AlbumCoverCache writes a file://
// URI to media.artUri once the cover lands on disk; before then,
// fall back to fetching via ServerImage from /api/albums/<id>/cover
// (which carries the auth header and falls back gracefully on
// failure).
Widget cover;
final artUri = media.artUri;
if (artUri != null && artUri.isScheme('file')) {
cover = Image(
image: FileImage(File.fromUri(artUri)),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: fs.slate),
);
} else if (albumId.isNotEmpty) {
cover = ServerImage(
url: '/api/albums/$albumId/cover',
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
);
} else {
cover = Container(color: fs.slate);
}
return AspectRatio(
aspectRatio: 1,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
child: Hero(
tag: kPlayerCoverHeroTag,
// flightShuttleBuilder ensures the in-flight Hero renders the
// destination's cover image (not the mini bar's small one)
// during the entire animation, which reads as a smooth grow
// rather than a swap mid-flight.
flightShuttleBuilder:
(_, __, ___, ____, toHeroContext) => toHeroContext.widget,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: cover,
),
),
),
);
}
}
class _TitleRow extends StatelessWidget {
const _TitleRow({required this.media, required this.fs});
final MediaItem media;
final FabledSwordTheme fs;
@override
Widget build(BuildContext context) {
// Title-only — like + kebab moved into _SecondaryControls above
// the seek bar so they share the same row as shuffle/repeat/queue.
return Text(
media.title,
style: TextStyle(color: fs.parchment, fontSize: 22),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
);
}
}
class _SeekRow extends StatelessWidget {
const _SeekRow({
required this.position,
required this.duration,
required this.fs,
required this.ref,
});
final Duration position;
final Duration duration;
final FabledSwordTheme fs;
final WidgetRef ref;
String _fmt(Duration d) {
final m = d.inMinutes.remainder(60).toString();
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context) {
final maxMs = duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
return Column(children: [
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
),
child: Slider(
activeColor: fs.accent,
inactiveColor: fs.slate,
min: 0,
max: maxMs,
value: position.inMilliseconds
.toDouble()
.clamp(0.0, duration.inMilliseconds.toDouble()),
onChanged: (v) => ref
.read(audioHandlerProvider)
.seek(Duration(milliseconds: v.toInt())),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_fmt(position), style: TextStyle(color: fs.ash, fontSize: 11)),
Text(_fmt(duration), style: TextStyle(color: fs.ash, fontSize: 11)),
],
),
),
]);
}
}
class _PrimaryControls extends StatelessWidget {
const _PrimaryControls({
required this.fs,
required this.ref,
required this.isPlaying,
});
final FabledSwordTheme fs;
final WidgetRef ref;
final bool isPlaying;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
iconSize: 36,
icon: Icon(Icons.skip_previous, color: fs.parchment),
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
),
IconButton(
iconSize: 72,
icon: Icon(
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
color: fs.accent,
),
onPressed: () {
final h = ref.read(audioHandlerProvider);
if (isPlaying) {
h.pause();
} else {
h.play();
}
},
),
IconButton(
iconSize: 36,
icon: Icon(Icons.skip_next, color: fs.parchment),
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
),
],
);
}
}
/// Action row sitting just above the seek bar. Holds shuffle / repeat
/// / queue plus the like + kebab that used to live in the title row,
/// so the title can sit truly centered above and this row carries
/// every per-track action in one place.
class _SecondaryControls extends StatelessWidget {
const _SecondaryControls({
required this.fs,
required this.actions,
required this.shuffleOn,
required this.repeatMode,
required this.media,
});
final FabledSwordTheme fs;
final PlayerActions actions;
final bool shuffleOn;
final AudioServiceRepeatMode repeatMode;
final MediaItem media;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
icon: Icon(
Icons.shuffle,
color: shuffleOn ? fs.accent : fs.ash,
),
onPressed: actions.toggleShuffle,
),
IconButton(
tooltip: switch (repeatMode) {
AudioServiceRepeatMode.none => 'Repeat off',
AudioServiceRepeatMode.one => 'Repeat one',
_ => 'Repeat all',
},
icon: Icon(
repeatMode == AudioServiceRepeatMode.one
? Icons.repeat_one
: Icons.repeat,
color: repeatMode == AudioServiceRepeatMode.none
? fs.ash
: fs.accent,
),
onPressed: actions.cycleRepeat,
),
IconButton(
tooltip: 'Queue',
icon: Icon(Icons.queue_music, color: fs.ash),
onPressed: () => GoRouter.of(context).push('/queue'),
),
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
TrackActionsButton(
track: TrackRef(
id: media.id,
title: media.title,
albumId: (media.extras?['album_id'] as String?) ?? '',
albumTitle: media.album ?? '',
artistId: (media.extras?['artist_id'] as String?) ?? '',
artistName: media.artist ?? '',
durationSec: media.duration?.inSeconds ?? 0,
streamUrl: '',
),
hideQueueActions: true,
// /now-playing lives outside the ShellRoute. Pushing
// /artists/:id or /albums/:id (both shell-children) on top
// of it would make go_router try to mount a duplicate
// ShellRoute (the same _debugCheckDuplicatedPageKeys crash
// we hit before with /queue). Await our own pop fully
// before pushing the destination so go_router never sees
// both pages active in the same frame.
onNavigate: (path) async {
await Navigator.of(context).maybePop();
if (context.mounted) GoRouter.of(context).push(path);
},
),
],
);
}
}