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:
@@ -12,18 +12,18 @@ import 'player_provider.dart';
|
|||||||
|
|
||||||
/// Compact player bar mounted in the app shell. Layout:
|
/// Compact player bar mounted in the app shell. Layout:
|
||||||
///
|
///
|
||||||
/// ┌────────────────────────────┬────────┬─────────┐
|
/// ┌──────────────────────────────┬──────────────┐
|
||||||
/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │ 🔀 🔁 ☰ │
|
/// │ [art] Title ♥ ⋮ │ 🔀 🔁 ☰ │
|
||||||
/// │ Artist │ │ │
|
/// │ Artist │ ⏮ ⏯ ⏭ │
|
||||||
/// ├────────────────────────────┴────────┴─────────┤
|
/// ├──────────────────────────────┴──────────────┤
|
||||||
/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │
|
/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │
|
||||||
/// └────────────────────────────────────────────────┘
|
/// └───────────────────────────────────────────────┘
|
||||||
///
|
///
|
||||||
/// Heart + kebab sit inline at the right of the title (per operator
|
/// Track info gets a 2:1 share of the row vs the controls column on
|
||||||
/// "directly to the right of the track name"). Play controls stay
|
/// the right. Heart + kebab stay inline at the right of the title.
|
||||||
/// full-size (40/48/40). Right column: shuffle/repeat/queue. Volume
|
/// Right column stacks shuffle/repeat/queue above prev/play/next.
|
||||||
/// is handled by the phone's hardware buttons. Bottom: full-width
|
/// Volume is handled by the phone's hardware buttons. Bottom:
|
||||||
/// seek + time labels.
|
/// full-width seek + time labels.
|
||||||
class PlayerBar extends ConsumerWidget {
|
class PlayerBar extends ConsumerWidget {
|
||||||
const PlayerBar({super.key});
|
const PlayerBar({super.key});
|
||||||
|
|
||||||
@@ -44,16 +44,19 @@ class PlayerBar extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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(
|
IntrinsicHeight(
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _TrackInfoColumn(media: media)),
|
Expanded(flex: 2, child: _TrackInfoColumn(media: media)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_PlayControls(playback: playback, ref: ref),
|
Expanded(
|
||||||
const SizedBox(width: 8),
|
flex: 1,
|
||||||
_RightCluster(playback: playback),
|
child: _ControlsColumn(playback: playback),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -97,7 +100,10 @@ class _TrackInfoColumn extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.max,
|
mainAxisSize: MainAxisSize.max,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
@@ -110,11 +116,23 @@ class _TrackInfoColumn extends StatelessWidget {
|
|||||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
LikeButton(kind: LikeKind.track, id: media.id, size: 18),
|
SizedBox(
|
||||||
TrackActionsButton(
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
child: LikeButton(
|
||||||
|
kind: LikeKind.track,
|
||||||
|
id: media.id,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
child: TrackActionsButton(
|
||||||
track: _trackRefFromMediaItem(media),
|
track: _trackRefFromMediaItem(media),
|
||||||
hideQueueActions: true,
|
hideQueueActions: true,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (artistName.isNotEmpty)
|
if (artistName.isNotEmpty)
|
||||||
@@ -133,37 +151,80 @@ class _TrackInfoColumn extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlayControls extends StatelessWidget {
|
class _ControlsColumn extends ConsumerWidget {
|
||||||
const _PlayControls({required this.playback, required this.ref});
|
const _ControlsColumn({required this.playback});
|
||||||
final PlaybackState? playback;
|
final PlaybackState? playback;
|
||||||
final WidgetRef ref;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final isPlaying = playback?.playing == true;
|
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: [
|
||||||
|
// Top sub-row: shuffle / repeat / queue
|
||||||
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
_IconBtn(
|
||||||
width: 40,
|
size: 32,
|
||||||
height: 40,
|
iconSize: 18,
|
||||||
child: IconButton(
|
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
|
||||||
padding: EdgeInsets.zero,
|
icon: Icons.shuffle,
|
||||||
icon: Icon(Icons.skip_previous, color: fs.parchment, size: 22),
|
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(),
|
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||||
),
|
),
|
||||||
),
|
_IconBtn(
|
||||||
SizedBox(
|
size: 40,
|
||||||
width: 48,
|
iconSize: 26,
|
||||||
height: 48,
|
icon: isPlaying
|
||||||
child: IconButton(
|
? Icons.pause_circle_filled
|
||||||
padding: EdgeInsets.zero,
|
: Icons.play_circle_filled,
|
||||||
iconSize: 28,
|
|
||||||
icon: Icon(
|
|
||||||
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
|
||||||
color: fs.accent,
|
color: fs.accent,
|
||||||
),
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final h = ref.read(audioHandlerProvider);
|
final h = ref.read(audioHandlerProvider);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
@@ -173,82 +234,48 @@ class _PlayControls extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
_IconBtn(
|
||||||
SizedBox(
|
size: 32,
|
||||||
width: 40,
|
iconSize: 20,
|
||||||
height: 40,
|
icon: Icons.skip_next,
|
||||||
child: IconButton(
|
color: fs.parchment,
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
icon: Icon(Icons.skip_next, color: fs.parchment, size: 22),
|
|
||||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RightCluster extends ConsumerWidget {
|
class _IconBtn extends StatelessWidget {
|
||||||
const _RightCluster({required this.playback});
|
const _IconBtn({
|
||||||
final PlaybackState? playback;
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
return SizedBox(
|
||||||
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
width: size,
|
||||||
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
height: size,
|
||||||
final actions = ref.read(playerActionsProvider);
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
|
tooltip: tooltip,
|
||||||
icon: Icon(
|
iconSize: iconSize,
|
||||||
Icons.shuffle,
|
icon: Icon(icon, color: color),
|
||||||
size: 18,
|
onPressed: onPressed,
|
||||||
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'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user