diff --git a/flutter_client/lib/player/album_color_extractor.dart b/flutter_client/lib/player/album_color_extractor.dart new file mode 100644 index 00000000..3fe56fdd --- /dev/null +++ b/flutter_client/lib/player/album_color_extractor.dart @@ -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 _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 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 _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( + (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( + (ref, albumId) async { + final cache = ref.watch(albumColorCacheProvider); + return cache.getOrExtract(albumId); + }, +); diff --git a/flutter_client/lib/player/now_playing_screen.dart b/flutter_client/lib/player/now_playing_screen.dart index e04a2d64..d1752cd3 100644 --- a/flutter_client/lib/player/now_playing_screen.dart +++ b/flutter_client/lib/player/now_playing_screen.dart @@ -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 { 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 { 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 { 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 { ), ], ), + ), ), ), ); @@ -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, + ), ), ), ); diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart index 0e03fed3..13c14165 100644 --- a/flutter_client/lib/player/player_bar.dart +++ b/flutter_client/lib/player/player_bar.dart @@ -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()!; 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( diff --git a/flutter_client/pubspec.lock b/flutter_client/pubspec.lock index 4c049000..88f2a920 100644 --- a/flutter_client/pubspec.lock +++ b/flutter_client/pubspec.lock @@ -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: diff --git a/flutter_client/pubspec.yaml b/flutter_client/pubspec.yaml index 6da3624f..7ba0dbd0 100644 --- a/flutter_client/pubspec.yaml +++ b/flutter_client/pubspec.yaml @@ -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: