import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.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 createState() => _NowPlayingScreenState(); } class _NowPlayingScreenState extends ConsumerState { // 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; @override void initState() { super.initState(); // Seed displayed state from the current mediaItem if a track is // already playing when the full player is opened. ref.listen // below only fires on CHANGES after subscription, so without // this seed the screen sits on "Nothing playing." even though // the mini bar shows a live track — the listener never gets a // mediaItem emission because the stream's current value hasn't // changed. final current = ref.read(mediaItemProvider).value; if (current == null) return; _displayedMedia = current; // Best-effort synchronous color seed: if albumColorProvider has // already extracted this album's dominant color earlier in the // session, pull it now so the backdrop renders correctly on // first frame. Otherwise the post-frame _scheduleSwap below // resolves it asynchronously and AnimatedContainer tweens. final albumId = current.extras?['album_id'] as String?; if (albumId != null && albumId.isNotEmpty) { _displayedDominant = ref.read(albumColorProvider(albumId)).asData?.value; } // Kick the preload pipeline post-frame so the cover bytes are // decoded + the dominant color is resolved even when the user // opens the player to a track that hasn't yet flowed through // ref.listen. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _scheduleSwap(current); }); } /// 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 _scheduleSwap(MediaItem newMedia) async { _pendingPreloadId = newMedia.id; // Fast path: when Prefetcher has already warmed both the cover // bytes (file:// artUri populated via _toMediaItem's peekCached) // AND the palette color (AlbumColorCache.peekColor returns // non-null), we can commit synchronously. The slow async path // exists for genuine cold-cache moments (first play of an album // that sync hasn't seen yet); the fast path is what makes the // common in-queue auto-advance flip the visual in lockstep with // the audio transition instead of lagging a tick behind. final artUri = newMedia.artUri; final albumId = newMedia.extras?['album_id'] as String?; if (artUri != null && artUri.isScheme('file') && albumId != null && albumId.isNotEmpty) { final cachedColor = ref.read(albumColorCacheProvider).peekColor(albumId); if (cachedColor != null) { if (!mounted) return; if (_pendingPreloadId != newMedia.id) return; setState(() { _displayedMedia = newMedia; _displayedDominant = cachedColor; }); return; } } // Slow path: cover and/or color are not yet cached. Hold the // current displayed state, preload, then atomic-commit. // // 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. 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; 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()!; // 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>(mediaItemProvider, (prev, next) { final newMedia = next.asData?.value; if (newMedia == null) { if (_displayedMedia != null) { setState(() { _displayedMedia = null; _displayedDominant = null; _pendingPreloadId = null; }); // Session was torn down (#52 idle/dismiss) while the full // player was open. Don't strand the user on an empty // "Nothing playing." screen — minimize back (the mini bar is // already gone too). maybePop is a no-op if this is somehow // the root route. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) Navigator.of(context).maybePop(); }); } 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(LucideIcons.chevron_down, color: fs.parchment), tooltip: 'Close', onPressed: () => Navigator.of(context).maybePop(), ), const Spacer(), IconButton( icon: Icon(LucideIcons.list_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//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(LucideIcons.skip_back, color: fs.parchment), onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), ), IconButton( iconSize: 72, icon: Icon( isPlaying ? LucideIcons.circle_pause : LucideIcons.circle_play, color: fs.accent, ), onPressed: () { final h = ref.read(audioHandlerProvider); if (isPlaying) { h.pause(); } else { h.play(); } }, ), IconButton( iconSize: 36, icon: Icon(LucideIcons.skip_forward, 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( LucideIcons.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 ? LucideIcons.repeat_1 : LucideIcons.repeat, color: repeatMode == AudioServiceRepeatMode.none ? fs.ash : fs.accent, ), onPressed: actions.cycleRepeat, ), IconButton( tooltip: 'Queue', icon: Icon(LucideIcons.list_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); }, ), ], ); } }