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 'player_provider.dart'; /// 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?) ?? ''; 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: SafeArea( child: Column( children: [ _TopBar(fs: fs), Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), 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, ), ], 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: 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); }, ), ], ); } }