import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.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; 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()!; final media = ref.watch(mediaItemProvider).value; final playback = ref.watch(playbackStateProvider).value; if (media == null) { 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 = media.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 = (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.asData?.value ?? 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 // 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(), 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, actions: actions, shuffleOn: shuffleOn, repeatMode: repeatMode, media: media, ), 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(Icons.expand_more, color: fs.parchment), tooltip: 'Close', onPressed: () => Navigator.of(context).maybePop(), ), const Spacer(), IconButton( icon: Icon(Icons.queue_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(Icons.skip_previous, color: fs.parchment), onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), ), IconButton( iconSize: 72, icon: Icon( isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled, color: fs.accent, ), onPressed: () { final h = ref.read(audioHandlerProvider); if (isPlaying) { h.pause(); } else { h.play(); } }, ), IconButton( iconSize: 36, icon: Icon(Icons.skip_next, 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( Icons.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 ? Icons.repeat_one : Icons.repeat, color: repeatMode == AudioServiceRepeatMode.none ? fs.ash : fs.accent, ), onPressed: actions.cycleRepeat, ), IconButton( tooltip: 'Queue', icon: Icon(Icons.queue_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); }, ), ], ); } }