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/server_image.dart';
|
||||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
|
import 'album_color_extractor.dart';
|
||||||
import 'player_provider.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
|
/// Full-screen player. Mounted on /now-playing. Pushed via a slide-up
|
||||||
/// transition (see routing.dart). Dismisses three ways:
|
/// transition (see routing.dart). Dismisses three ways:
|
||||||
///
|
///
|
||||||
@@ -74,6 +86,18 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
final actions = ref.read(playerActionsProvider);
|
final actions = ref.read(playerActionsProvider);
|
||||||
final albumId = (media.extras?['album_id'] as String?) ?? '';
|
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(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
// The whole screen accepts vertical drag for dismissal. Buttons
|
// The whole screen accepts vertical drag for dismissal. Buttons
|
||||||
@@ -84,7 +108,17 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
onVerticalDragUpdate: _onDragUpdate,
|
onVerticalDragUpdate: _onDragUpdate,
|
||||||
onVerticalDragEnd: _onDragEnd,
|
onVerticalDragEnd: _onDragEnd,
|
||||||
behavior: HitTestBehavior.translucent,
|
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(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_TopBar(fs: fs),
|
_TopBar(fs: fs),
|
||||||
@@ -94,25 +128,47 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
_AlbumArt(media: media, albumId: albumId, fs: fs),
|
AnimatedSwitcher(
|
||||||
const SizedBox(height: 28),
|
duration: _trackChangeDuration,
|
||||||
_TitleRow(media: media, fs: fs),
|
child: KeyedSubtree(
|
||||||
const SizedBox(height: 4),
|
key: ValueKey('cover-${media.id}'),
|
||||||
Text(
|
child: _AlbumArt(
|
||||||
media.artist ?? '',
|
media: media, albumId: albumId, fs: fs),
|
||||||
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: 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),
|
const SizedBox(height: 24),
|
||||||
_SecondaryControls(
|
_SecondaryControls(
|
||||||
fs: fs,
|
fs: fs,
|
||||||
@@ -136,6 +192,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -204,9 +261,18 @@ class _AlbumArt extends StatelessWidget {
|
|||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
|
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
|
||||||
child: ClipRRect(
|
child: Hero(
|
||||||
borderRadius: BorderRadius.circular(8),
|
tag: kPlayerCoverHeroTag,
|
||||||
child: cover,
|
// 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 '../models/track.dart';
|
||||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
|
import 'now_playing_screen.dart' show kPlayerCoverHeroTag;
|
||||||
import 'player_provider.dart';
|
import 'player_provider.dart';
|
||||||
|
|
||||||
/// Compact player bar mounted at the bottom of the app shell. Mini
|
/// 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 fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final artistName = (media.artist ?? '').trim();
|
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(
|
return Row(
|
||||||
// Default centering vertically aligns the like/kebab buttons
|
// Default centering vertically aligns the like/kebab buttons
|
||||||
// against the album art (48dp) — visually they span the title +
|
// against the album art (48dp) — visually they span the title +
|
||||||
// artist block instead of sitting on the title baseline.
|
// artist block instead of sitting on the title baseline.
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
if (media.artUri != null)
|
Hero(
|
||||||
Image(
|
tag: kPlayerCoverHeroTag,
|
||||||
image: media.artUri!.isScheme('file')
|
child: cover,
|
||||||
? 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),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -696,6 +696,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
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:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ dependencies:
|
|||||||
sqlite3_flutter_libs: ^0.5.24
|
sqlite3_flutter_libs: ^0.5.24
|
||||||
connectivity_plus: ^6.0.5
|
connectivity_plus: ^6.0.5
|
||||||
flutter_timezone: ^4.1.1
|
flutter_timezone: ^4.1.1
|
||||||
|
palette_generator: ^0.3.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user