fix(flutter): player bar — title visible, controls stacked, no overflow

Three issues from the screenshot:

1. Title invisible: LikeButton + TrackActionsButton were raw IconButtons
   with the default 48dp tap target, eating ~96dp of the title row.
   Wrap both in 32×32 SizedBox at the call site so the title actually
   gets enough room to render (still ellipsizes if needed).

2. Layout per operator's revised spec: track info gets a 2:1 share of
   the row vs the controls column. The controls column now stacks the
   shuffle/repeat/queue sub-row above the prev/play/next sub-row, both
   right-aligned.

3. Right-edge 2px overflow: previous play controls were 40+48+40=128dp
   in a flex-1 slot of ~110dp. Resize to 32+40+32=104dp matching the
   shuffle/repeat/queue row above (3×32=96dp), with the play button
   slightly larger to keep visual hierarchy.

Drop the volume provider import since the slider is gone (handled by
phone hardware buttons).
This commit is contained in:
2026-05-11 07:34:11 -04:00
parent d703fc27f8
commit 7cabe4efef
+147 -120
View File
@@ -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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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,
),
);
}
}