feat(flutter): #396 — full-screen now-playing polish

Three of the four locked items (1, 2, 4); item 6 (swipe-tabs) stays
deferred until server-side lyrics ingestion exists.

1. Dominant-color gradient backdrop. New album_color_extractor.dart
   wraps the existing AlbumCoverCache: extracts the dominant color
   via PaletteGenerator over the local file, caches in-memory keyed
   by album_id. Top 55% of the screen carries the color (0.55 alpha
   → fs.obsidian) so controls below stay legible. AnimatedContainer
   tweens the gradient across track changes.

2. Hero transition for cover art (mini bar → full screen). Stable
   kPlayerCoverHeroTag (not media.id keyed) so the transition works
   regardless of what's playing and isn't racy if media swaps mid-tap.
   flightShuttleBuilder renders the destination's Hero widget for the
   whole flight, which reads as a clean grow rather than a swap.

4. Crossfade on track change. AnimatedSwitcher around the album art,
   title, and artist+album text block, all keyed by media.id so the
   switcher fades between old and new on each track-change rebuild.
   Pairs with the AnimatedContainer gradient so the whole "what's
   playing" zone changes in lockstep.

palette_generator: ^0.3.3 added.

For #396 / #356 umbrella.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 13:40:51 -04:00
parent b5c5dbbe76
commit 046ee8d576
5 changed files with 210 additions and 39 deletions
@@ -11,8 +11,20 @@ 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:
///
@@ -74,6 +86,18 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
final actions = ref.read(playerActionsProvider);
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.
final dominantAsync = ref.watch(albumColorProvider(albumId));
final dominant = dominantAsync.valueOrNull ?? 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);
return Scaffold(
backgroundColor: fs.obsidian,
// The whole screen accepts vertical drag for dismissal. Buttons
@@ -84,7 +108,17 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: _onDragEnd,
behavior: HitTestBehavior.translucent,
child: SafeArea(
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),
@@ -94,25 +128,47 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
child: Column(
children: [
const Spacer(),
_AlbumArt(media: media, albumId: albumId, fs: fs),
const SizedBox(height: 28),
_TitleRow(media: media, fs: fs),
const SizedBox(height: 4),
Text(
media.artist ?? '',
style: TextStyle(color: fs.ash, fontSize: 14),
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,
AnimatedSwitcher(
duration: _trackChangeDuration,
child: KeyedSubtree(
key: ValueKey('cover-${media.id}'),
child: _AlbumArt(
media: media, albumId: albumId, fs: fs),
),
],
),
const SizedBox(height: 28),
AnimatedSwitcher(
duration: _trackChangeDuration,
child: KeyedSubtree(
key: ValueKey('title-${media.id}'),
child: _TitleRow(media: media, fs: fs),
),
),
const SizedBox(height: 4),
AnimatedSwitcher(
duration: _trackChangeDuration,
child: Column(
key: ValueKey('artist-album-${media.id}'),
mainAxisSize: MainAxisSize.min,
children: [
Text(
media.artist ?? '',
style: TextStyle(color: fs.ash, fontSize: 14),
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(
fs: fs,
@@ -136,6 +192,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
),
],
),
),
),
),
);
@@ -204,9 +261,18 @@ class _AlbumArt extends StatelessWidget {
aspectRatio: 1,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: cover,
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,
),
),
),
);