diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart index 629f8356..6785d296 100644 --- a/flutter_client/lib/player/player_bar.dart +++ b/flutter_client/lib/player/player_bar.dart @@ -12,18 +12,18 @@ import 'player_provider.dart'; /// Compact player bar mounted in the app shell. Layout: /// -/// ┌────────────────────────────┬────────┬─────────┐ -/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │ 🔀 🔁 ☰ │ -/// │ Artist │ │ │ -/// ├────────────────────────────┴────────┴─────────┤ -/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │ -/// └────────────────────────────────────────────────┘ +/// ┌──────────────────────────────┬──────────────┐ +/// │ [art] Title ♥ ⋮ │ 🔀 🔁 ☰ │ +/// │ Artist │ ⏮ ⏯ ⏭ │ +/// ├──────────────────────────────┴──────────────┤ +/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │ +/// └───────────────────────────────────────────────┘ /// -/// Heart + kebab sit inline at the right of the title (per operator -/// "directly to the right of the track name"). Play controls stay -/// full-size (40/48/40). Right column: shuffle/repeat/queue. Volume -/// is handled by the phone's hardware buttons. Bottom: full-width -/// seek + time labels. +/// 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. class PlayerBar extends ConsumerWidget { const PlayerBar({super.key}); @@ -44,16 +44,19 @@ class PlayerBar extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Top row: track info (with inline like+kebab) | play controls | right cluster + // 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(child: _TrackInfoColumn(media: media)), + Expanded(flex: 2, child: _TrackInfoColumn(media: media)), const SizedBox(width: 8), - _PlayControls(playback: playback, ref: ref), - const SizedBox(width: 8), - _RightCluster(playback: playback), + Expanded( + flex: 1, + child: _ControlsColumn(playback: playback), + ), ], ), ), @@ -97,7 +100,10 @@ class _TrackInfoColumn extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - // Title row: title text + inline like + kebab + // 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, @@ -110,10 +116,22 @@ class _TrackInfoColumn extends StatelessWidget { style: TextStyle(color: fs.parchment, fontSize: 14), ), ), - LikeButton(kind: LikeKind.track, id: media.id, size: 18), - TrackActionsButton( - track: _trackRefFromMediaItem(media), - hideQueueActions: true, + 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, + ), ), ], ), @@ -133,122 +151,131 @@ class _TrackInfoColumn extends StatelessWidget { } } -class _PlayControls extends StatelessWidget { - const _PlayControls({required this.playback, required this.ref}); +class _ControlsColumn extends ConsumerWidget { + const _ControlsColumn({required this.playback}); final PlaybackState? playback; - final WidgetRef ref; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final isPlaying = playback?.playing == true; - return Row( + 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, children: [ - SizedBox( - width: 40, - height: 40, - child: IconButton( - padding: EdgeInsets.zero, - icon: Icon(Icons.skip_previous, color: fs.parchment, size: 22), - onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), - ), - ), - SizedBox( - width: 48, - height: 48, - child: IconButton( - padding: EdgeInsets.zero, - iconSize: 28, - icon: Icon( - isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled, - color: fs.accent, + // 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, ), - onPressed: () { - final h = ref.read(audioHandlerProvider); - if (isPlaying) { - h.pause(); - } else { - h.play(); - } - }, - ), + _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'), + ), + ], ), - SizedBox( - width: 40, - height: 40, - child: IconButton( - padding: EdgeInsets.zero, - icon: Icon(Icons.skip_next, color: fs.parchment, size: 22), - onPressed: () => ref.read(audioHandlerProvider).skipToNext(), - ), + // 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(), + ), + ], ), ], ); } } -class _RightCluster extends ConsumerWidget { - const _RightCluster({required this.playback}); - final PlaybackState? playback; +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; @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all; - final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none; - final actions = ref.read(playerActionsProvider); - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 36, - height: 36, - child: IconButton( - padding: EdgeInsets.zero, - tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off', - icon: Icon( - Icons.shuffle, - size: 18, - color: shuffleOn ? fs.accent : fs.ash, - ), - onPressed: actions.toggleShuffle, - ), - ), - SizedBox( - width: 36, - height: 36, - child: IconButton( - padding: EdgeInsets.zero, - tooltip: switch (repeatMode) { - AudioServiceRepeatMode.none => 'Repeat off', - AudioServiceRepeatMode.one => 'Repeat one', - _ => 'Repeat all', - }, - icon: Icon( - repeatMode == AudioServiceRepeatMode.one - ? Icons.repeat_one - : Icons.repeat, - size: 18, - color: repeatMode == AudioServiceRepeatMode.none - ? fs.ash - : fs.accent, - ), - onPressed: actions.cycleRepeat, - ), - ), - SizedBox( - width: 36, - height: 36, - child: IconButton( - padding: EdgeInsets.zero, - tooltip: 'Queue', - icon: Icon(Icons.queue_music, size: 18, color: fs.ash), - onPressed: () => context.push('/queue'), - ), - ), - ], + 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, + ), ); } }