From 163c1174db62888e822d44ce3087e1318ee22fd7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 08:06:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20mini=20=E2=86=94=20full=20play?= =?UTF-8?q?er=20transition=20+=20flesh=20out=20NowPlayingScreen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mini player simplifies to track-info + prev/play/next. The shuffle/repeat/queue cluster moves to the full player, where it has the room to breathe. Full player (NowPlayingScreen) now has: - Real album art (FileImage when artUri is file://, ServerImage from /api/albums//cover otherwise — same auth-aware loader as the rest of the app, with errorBuilder so a failed fetch falls back to a slate placeholder instead of a broken-image glyph). - Title row with inline like + kebab. - Full-width seek with start/end timestamps below. - 36/72/36 prev/play/next. - Secondary row: shuffle, repeat, queue. Mini ↔ Full transition mechanics: - Tap mini → push /now-playing with custom slide-up transition (CustomTransitionPage, 280ms easeOutCubic). - Vertical drag-up flick on mini (>200 px/s) → same push. - System back / leading expand-more icon → Navigator.pop, which the CustomTransitionPage replays as a slide-down (240ms easeInCubic). - Drag-down on full player past 80px OR a >500 px/s downward flick → Navigator.pop. Buttons and the seek slider stay tappable since Flutter's gesture arena gives priority to the more-specific child recognizers. - Mini player hides while /now-playing is the active route — the full player IS the player UI in that state, no need to fight over visual space. --- .../lib/player/now_playing_screen.dart | 429 +++++++++++++++--- flutter_client/lib/player/player_bar.dart | 386 +++++++--------- flutter_client/lib/shared/routing.dart | 34 +- 3 files changed, 542 insertions(+), 307 deletions(-) diff --git a/flutter_client/lib/player/now_playing_screen.dart b/flutter_client/lib/player/now_playing_screen.dart index db9db86e..83e5efcc 100644 --- a/flutter_client/lib/player/now_playing_screen.dart +++ b/flutter_client/lib/player/now_playing_screen.dart @@ -1,17 +1,59 @@ +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'; -class NowPlayingScreen extends ConsumerWidget { +/// 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 - Widget build(BuildContext context, WidgetRef ref) { + 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; @@ -22,85 +64,322 @@ class NowPlayingScreen extends ConsumerWidget { final pos = playback?.updatePosition ?? 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, - appBar: AppBar( - backgroundColor: fs.obsidian, - leading: IconButton( - icon: Icon(Icons.expand_more, color: fs.parchment), - onPressed: () => Navigator.of(context).pop(), - ), - actions: [ - IconButton( - icon: Icon(Icons.queue_music, color: fs.parchment), - tooltip: 'Queue', - onPressed: () => GoRouter.of(context).push('/queue'), + // 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, + ), + const SizedBox(height: 24), + _SeekRow(position: pos, duration: dur, fs: fs, ref: ref), + const SizedBox(height: 8), + _PrimaryControls( + fs: fs, + ref: ref, + isPlaying: isPlaying, + ), + const SizedBox(height: 16), + _SecondaryControls( + fs: fs, + actions: actions, + shuffleOn: shuffleOn, + repeatMode: repeatMode, + ), + const Spacer(), + ], + ), + ), + ), + ], ), - ], - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column(children: [ - const Spacer(), - Container( - width: 280, height: 280, color: fs.slate, - ), - const SizedBox(height: 24), - Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Flexible( - child: Text( - media.title, - style: TextStyle(color: fs.parchment, fontSize: 22), - overflow: TextOverflow.ellipsis, - ), - ), - TrackActionsButton( - track: TrackRef( - id: media.id, - title: media.title, - albumId: (media.extras?['album_id'] as String?) ?? '', - albumTitle: media.album ?? '', - artistId: '', // not stashed in MediaItem.extras today - artistName: media.artist ?? '', - durationSec: media.duration?.inSeconds ?? 0, - streamUrl: '', - ), - hideQueueActions: true, - ), - ]), - Text(media.artist ?? '', style: TextStyle(color: fs.ash, fontSize: 14)), - const SizedBox(height: 16), - Slider( - activeColor: fs.accent, - min: 0, - max: dur.inMilliseconds.toDouble().clamp(1, double.infinity), - value: pos.inMilliseconds.toDouble().clamp(0, dur.inMilliseconds.toDouble()), - onChanged: (v) => ref.read(audioHandlerProvider).seek(Duration(milliseconds: v.toInt())), - ), - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - IconButton( - icon: Icon(Icons.skip_previous, color: fs.parchment, size: 32), - onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), - ), - IconButton( - iconSize: 56, - icon: Icon(playback?.playing == true ? Icons.pause_circle_filled : Icons.play_circle_filled, color: fs.accent), - onPressed: () { - final h = ref.read(audioHandlerProvider); - if (playback?.playing == true) { h.pause(); } else { h.play(); } - }, - ), - IconButton( - icon: Icon(Icons.skip_next, color: fs.parchment, size: 32), - onPressed: () => ref.read(audioHandlerProvider).skipToNext(), - ), - ]), - const Spacer(), - ]), ), ), ); } } + +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) { + return Row(children: [ + Expanded( + child: Text( + media.title, + style: TextStyle(color: fs.parchment, fontSize: 22), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + 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: '', + artistName: media.artist ?? '', + durationSec: media.duration?.inSeconds ?? 0, + streamUrl: '', + ), + hideQueueActions: true, + ), + ]); + } +} + +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(), + ), + ], + ); + } +} + +class _SecondaryControls extends StatelessWidget { + const _SecondaryControls({ + required this.fs, + required this.actions, + required this.shuffleOn, + required this.repeatMode, + }); + final FabledSwordTheme fs; + final PlayerActions actions; + final bool shuffleOn; + final AudioServiceRepeatMode repeatMode; + + @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'), + ), + ], + ); + } +} diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart index 674cb6fb..1fa84e16 100644 --- a/flutter_client/lib/player/player_bar.dart +++ b/flutter_client/lib/player/player_bar.dart @@ -12,20 +12,20 @@ import '../shared/widgets/track_actions/track_actions_button.dart'; import '../theme/theme_extension.dart'; import 'player_provider.dart'; -/// Compact player bar mounted in the app shell. Layout: +/// Compact player bar mounted at the bottom of the app shell. Mini +/// view only — the heavyweight shuffle/repeat/queue/volume controls +/// live in NowPlayingScreen, accessible by tap or swipe-up. /// -/// ┌──────────────────────────────┬──────────────┐ -/// │ [art] Title ♥ ⋮ │ 🔀 🔁 ☰ │ -/// │ Artist │ ⏮ ⏯ ⏭ │ -/// ├──────────────────────────────┴──────────────┤ -/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │ -/// └───────────────────────────────────────────────┘ +/// ┌─────────────────────────────────────┬──────────┐ +/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │ +/// │ Artist │ │ +/// ├─────────────────────────────────────────────────┤ +/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │ +/// └─────────────────────────────────────────────────┘ /// -/// Track info gets a 2:1 share of the row vs the controls column on -/// the right. Heart + kebab stay inline at the right of the title. -/// Right column stacks shuffle/repeat/queue above prev/play/next. -/// Volume is handled by the phone's hardware buttons. Bottom: -/// full-width seek + time labels. +/// Tap anywhere on the bar (including art/title) ascends to the full +/// player. A vertical drag upward also ascends, so the gesture mirrors +/// the drag-down dismissal on the full screen. class PlayerBar extends ConsumerWidget { const PlayerBar({super.key}); @@ -41,38 +41,43 @@ class PlayerBar extends ConsumerWidget { return Material( color: fs.iron, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Top row: track info (2 fill) | controls column (1 fill). - // The controls column stacks shuffle/repeat/queue above the - // prev/play/next row. - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded(flex: 2, child: _TrackInfoColumn(media: media)), - const SizedBox(width: 8), - Expanded( - flex: 1, - child: _ControlsColumn(playback: playback), - ), - ], + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => context.push('/now-playing'), + // Swipe up anywhere on the bar to expand into the full player. + // We only act on a clear upward flick (>200 px/s) so a slow + // tap-with-tiny-jitter doesn't accidentally open the screen. + onVerticalDragEnd: (d) { + final v = d.primaryVelocity ?? 0; + if (v < -200) context.push('/now-playing'); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: _TrackInfo(media: media)), + const SizedBox(width: 8), + _PlayControls(playback: playback, ref: ref), + ], + ), ), - ), - const SizedBox(height: 4), - _SeekRow(position: pos, duration: dur), - ], + const SizedBox(height: 4), + _SeekRow(position: pos, duration: dur), + ], + ), ), ), ); } } -class _TrackInfoColumn extends StatelessWidget { - const _TrackInfoColumn({required this.media}); +class _TrackInfo extends StatelessWidget { + const _TrackInfo({required this.media}); final MediaItem media; @override @@ -80,209 +85,130 @@ class _TrackInfoColumn extends StatelessWidget { final fs = Theme.of(context).extension()!; final artistName = (media.artist ?? '').trim(); - return InkWell( - onTap: () => context.push('/now-playing'), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (media.artUri != null) - Image( - // artUri is a file:// path written by AlbumCoverCache — - // FileImage is the right loader; NetworkImage would - // attempt HTTP on a file:// URI and fail. - image: media.artUri!.isScheme('file') - ? FileImage(File.fromUri(media.artUri!)) as ImageProvider - : NetworkImage(media.artUri.toString()), - width: 48, - height: 48, - errorBuilder: (_, __, ___) => - Container(width: 48, height: 48, color: fs.slate), - ) - else - Container(width: 48, height: 48, color: fs.slate), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - // Title row: title text + inline like + kebab. The - // like/kebab buttons get their default 48dp tap target - // shrunk to 32dp so the title has room — otherwise it - // collapses to nothing in the available width. - Row( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text( - media.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - ), - SizedBox( - width: 32, - height: 32, - child: LikeButton( - kind: LikeKind.track, - id: media.id, - size: 18, - ), - ), - SizedBox( - width: 32, - height: 32, - child: TrackActionsButton( - track: _trackRefFromMediaItem(media), - hideQueueActions: true, - ), - ), - ], - ), - if (artistName.isNotEmpty) - Text( - artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _ControlsColumn extends ConsumerWidget { - const _ControlsColumn({required this.playback}); - final PlaybackState? playback; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final isPlaying = playback?.playing == true; - final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all; - final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none; - final actions = ref.read(playerActionsProvider); - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, + return Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Top sub-row: shuffle / repeat / queue - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _IconBtn( - size: 32, - iconSize: 18, - tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off', - icon: Icons.shuffle, - color: shuffleOn ? fs.accent : fs.ash, - onPressed: actions.toggleShuffle, - ), - _IconBtn( - size: 32, - iconSize: 18, - tooltip: switch (repeatMode) { - AudioServiceRepeatMode.none => 'Repeat off', - AudioServiceRepeatMode.one => 'Repeat one', - _ => 'Repeat all', - }, - icon: repeatMode == AudioServiceRepeatMode.one - ? Icons.repeat_one - : Icons.repeat, - color: repeatMode == AudioServiceRepeatMode.none - ? fs.ash - : fs.accent, - onPressed: actions.cycleRepeat, - ), - _IconBtn( - size: 32, - iconSize: 18, - tooltip: 'Queue', - icon: Icons.queue_music, - color: fs.ash, - onPressed: () => context.push('/queue'), - ), - ], - ), - // Bottom sub-row: prev / play / next. Sized to match the - // shuffle/repeat/queue row above (3×32 = 96dp) with the play - // button slightly larger; total 32+40+32 = 104dp. - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _IconBtn( - size: 32, - iconSize: 20, - icon: Icons.skip_previous, - color: fs.parchment, - onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), - ), - _IconBtn( - size: 40, - iconSize: 26, - 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(); - } - }, - ), - _IconBtn( - size: 32, - iconSize: 20, - icon: Icons.skip_next, - color: fs.parchment, - onPressed: () => ref.read(audioHandlerProvider).skipToNext(), - ), - ], + 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, + errorBuilder: (_, __, ___) => + Container(width: 48, height: 48, color: fs.slate), + ) + else + Container(width: 48, height: 48, color: fs.slate), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + // Title row: title text + inline like + kebab. The + // like/kebab buttons get their default 48dp tap target + // shrunk to 32dp so the title has room. + Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + media.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 14), + ), + ), + SizedBox( + width: 32, + height: 32, + child: LikeButton( + kind: LikeKind.track, + id: media.id, + size: 18, + ), + ), + SizedBox( + width: 32, + height: 32, + child: TrackActionsButton( + track: _trackRefFromMediaItem(media), + hideQueueActions: true, + ), + ), + ], + ), + if (artistName.isNotEmpty) + Text( + artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ], + ), ), ], ); } } -class _IconBtn extends StatelessWidget { - const _IconBtn({ - required this.size, - required this.iconSize, - required this.icon, - required this.color, - required this.onPressed, - this.tooltip, - }); - final double size; - final double iconSize; - final IconData icon; - final Color color; - final VoidCallback onPressed; - final String? tooltip; +class _PlayControls extends StatelessWidget { + const _PlayControls({required this.playback, required this.ref}); + final PlaybackState? playback; + final WidgetRef ref; @override Widget build(BuildContext context) { - return SizedBox( - width: size, - height: size, - child: IconButton( - padding: EdgeInsets.zero, - tooltip: tooltip, - iconSize: iconSize, - icon: Icon(icon, color: color), - onPressed: onPressed, - ), + final fs = Theme.of(context).extension()!; + final isPlaying = playback?.playing == true; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 36, + height: 36, + child: IconButton( + padding: EdgeInsets.zero, + iconSize: 22, + icon: Icon(Icons.skip_previous, color: fs.parchment), + onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), + ), + ), + SizedBox( + width: 44, + height: 44, + child: IconButton( + padding: EdgeInsets.zero, + iconSize: 32, + 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(); + } + }, + ), + ), + SizedBox( + width: 36, + height: 36, + child: IconButton( + padding: EdgeInsets.zero, + iconSize: 22, + icon: Icon(Icons.skip_next, color: fs.parchment), + onPressed: () => ref.read(audioHandlerProvider).skipToNext(), + ), + ), + ], ); } } diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index 781fd8ce..d66985bb 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -67,7 +67,32 @@ GoRouter buildRouter(Ref ref) { path: '/albums/:id', builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!), ), - GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()), + GoRoute( + path: '/now-playing', + // Slide-up transition: full player ascends from the bottom + // edge (matching where the mini player sits) into a full + // screen view. Drag-down dismissal in NowPlayingScreen + // mirrors this with a slide back down via Navigator.pop. + pageBuilder: (_, __) => CustomTransitionPage( + child: const NowPlayingScreen(), + transitionDuration: const Duration(milliseconds: 280), + reverseTransitionDuration: const Duration(milliseconds: 240), + transitionsBuilder: (_, anim, __, child) { + final eased = CurvedAnimation( + parent: anim, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(eased), + child: child, + ); + }, + ), + ), GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()), GoRoute(path: '/search', builder: (_, __) => const SearchScreen()), GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()), @@ -101,6 +126,11 @@ class _ShellWithPlayerBar extends ConsumerWidget { // When the banner is showing, strip that inset from the child so // its AppBar lands directly under the banner. final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null; + // Hide the mini player when the full player is on top — the full + // player IS the player UI in that state, and overlapping them + // would just fight for visual space. + final loc = GoRouterState.of(context).matchedLocation; + final hideMini = loc == '/now-playing'; return Column( children: [ const UpdateBanner(), @@ -113,7 +143,7 @@ class _ShellWithPlayerBar extends ConsumerWidget { ) : child, ), - const PlayerBar(), + if (!hideMini) const PlayerBar(), ], ); }