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:
@@ -0,0 +1,84 @@
|
||||
// Dominant-color extraction from album cover art (#396 item 1).
|
||||
// The full-screen Now Playing screen uses this to paint a top-to-bottom
|
||||
// gradient backdrop that grounds each track in its album's palette.
|
||||
//
|
||||
// Implementation: reuses AlbumCoverCache to get a local file path for
|
||||
// the cover, then runs PaletteGenerator over it. Results are cached
|
||||
// in-memory keyed by album_id so back-to-back plays of the same album
|
||||
// don't repeat the work. Cache is process-lifetime; album-art changes
|
||||
// are rare enough that LRU eviction isn't worth the complexity.
|
||||
//
|
||||
// Returns null on any failure (no cover, empty album_id, palette
|
||||
// extraction returned nothing). Callers fall back to the FabledSword
|
||||
// obsidian color for the gradient when null.
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
|
||||
import 'player_provider.dart';
|
||||
|
||||
/// Caches dominant colors keyed by album_id. Process-lifetime; refilled
|
||||
/// on app restart.
|
||||
class AlbumColorCache {
|
||||
AlbumColorCache(this._ref);
|
||||
|
||||
final Ref _ref;
|
||||
final Map<String, Color?> _cache = {};
|
||||
|
||||
/// Returns the dominant color for the album's cover, or null if no
|
||||
/// cover is available or extraction fails. Same call for the same
|
||||
/// album_id is cached after the first successful resolution.
|
||||
Future<Color?> getOrExtract(String albumId) async {
|
||||
if (albumId.isEmpty) return null;
|
||||
if (_cache.containsKey(albumId)) return _cache[albumId];
|
||||
final color = await _extract(albumId);
|
||||
_cache[albumId] = color;
|
||||
return color;
|
||||
}
|
||||
|
||||
Future<Color?> _extract(String albumId) async {
|
||||
try {
|
||||
final coverCache = _ref.read(albumCoverCacheProvider);
|
||||
final path = await coverCache.getOrFetch(albumId);
|
||||
if (path == null) return null;
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return null;
|
||||
final palette = await PaletteGenerator.fromImageProvider(
|
||||
FileImage(file),
|
||||
// Small target size: palette extraction is CPU-bound and the
|
||||
// gradient only needs a single dominant color, so we don't
|
||||
// need full-resolution sampling.
|
||||
size: const Size(80, 80),
|
||||
maximumColorCount: 8,
|
||||
);
|
||||
// Prefer the explicit dominant color; fall back to the strongest
|
||||
// muted swatch (which tends to read better as a background than
|
||||
// a hot vibrant pick), then any populated swatch.
|
||||
final swatch = palette.dominantColor ??
|
||||
palette.darkMutedColor ??
|
||||
palette.darkVibrantColor ??
|
||||
palette.mutedColor;
|
||||
return swatch?.color;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final albumColorCacheProvider = Provider<AlbumColorCache>(
|
||||
(ref) => AlbumColorCache(ref),
|
||||
);
|
||||
|
||||
/// Family provider: dominant color for the given album_id, or null if
|
||||
/// no cover / extraction failed. The Now Playing screen watches this
|
||||
/// keyed by the current MediaItem's album_id.
|
||||
final albumColorProvider = FutureProvider.family<Color?, String>(
|
||||
(ref, albumId) async {
|
||||
final cache = ref.watch(albumColorCacheProvider);
|
||||
return cache.getOrExtract(albumId);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../likes/like_button.dart';
|
||||
import '../models/track.dart';
|
||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'now_playing_screen.dart' show kPlayerCoverHeroTag;
|
||||
import 'player_provider.dart';
|
||||
|
||||
/// Compact player bar mounted at the bottom of the app shell. Mini
|
||||
@@ -88,29 +89,40 @@ class _TrackInfo extends StatelessWidget {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final artistName = (media.artist ?? '').trim();
|
||||
|
||||
// Cover is wrapped in a Hero with the shared kPlayerCoverHeroTag so
|
||||
// tapping the mini bar to expand into NowPlayingScreen animates the
|
||||
// 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;
|
||||
if (media.artUri != null) {
|
||||
cover = Image(
|
||||
image: media.artUri!.isScheme('file')
|
||||
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
|
||||
: NetworkImage(media.artUri.toString()),
|
||||
width: 48,
|
||||
height: 48,
|
||||
// Without a fit, Image paints the source at its intrinsic
|
||||
// resolution inside the 48dp box — a thumbnail-sized cover
|
||||
// would render as a tiny inset. Cover stretches/crops to fill
|
||||
// the box uniformly.
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
);
|
||||
} else {
|
||||
cover = Container(width: 48, height: 48, color: fs.slate);
|
||||
}
|
||||
return Row(
|
||||
// Default centering vertically aligns the like/kebab buttons
|
||||
// against the album art (48dp) — visually they span the title +
|
||||
// artist block instead of sitting on the title baseline.
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (media.artUri != null)
|
||||
Image(
|
||||
image: media.artUri!.isScheme('file')
|
||||
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
|
||||
: NetworkImage(media.artUri.toString()),
|
||||
width: 48,
|
||||
height: 48,
|
||||
// Without a fit, Image paints the source at its intrinsic
|
||||
// resolution inside the 48dp box — a thumbnail-sized cover
|
||||
// would render as a tiny inset. Cover stretches/crops to
|
||||
// fill the box uniformly.
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
)
|
||||
else
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
Hero(
|
||||
tag: kPlayerCoverHeroTag,
|
||||
child: cover,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
|
||||
@@ -696,6 +696,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
palette_generator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: palette_generator
|
||||
sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.3+7"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -29,6 +29,7 @@ dependencies:
|
||||
sqlite3_flutter_libs: ^0.5.24
|
||||
connectivity_plus: ^6.0.5
|
||||
flutter_timezone: ^4.1.1
|
||||
palette_generator: ^0.3.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user