7cabe4efef
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).
356 lines
12 KiB
Dart
356 lines
12 KiB
Dart
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/track_actions/track_actions_button.dart';
|
||
import '../theme/theme_extension.dart';
|
||
import 'player_provider.dart';
|
||
|
||
/// Compact player bar mounted in the app shell. Layout:
|
||
///
|
||
/// ┌──────────────────────────────┬──────────────┐
|
||
/// │ [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.
|
||
class PlayerBar extends ConsumerWidget {
|
||
const PlayerBar({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||
final media = ref.watch(mediaItemProvider).value;
|
||
final playback = ref.watch(playbackStateProvider).value;
|
||
if (media == null) return const SizedBox.shrink();
|
||
|
||
final pos = playback?.updatePosition ?? Duration.zero;
|
||
final dur = media.duration ?? Duration.zero;
|
||
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
_SeekRow(position: pos, duration: dur),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _TrackInfoColumn extends StatelessWidget {
|
||
const _TrackInfoColumn({required this.media});
|
||
final MediaItem media;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||
final artistName = (media.artist ?? '').trim();
|
||
|
||
return InkWell(
|
||
onTap: () => context.push('/now-playing'),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
if (media.artUri != null)
|
||
Image(
|
||
image: 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<FabledSwordTheme>()!;
|
||
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,
|
||
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(),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
return SizedBox(
|
||
width: size,
|
||
height: size,
|
||
child: IconButton(
|
||
padding: EdgeInsets.zero,
|
||
tooltip: tooltip,
|
||
iconSize: iconSize,
|
||
icon: Icon(icon, color: color),
|
||
onPressed: onPressed,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SeekRow extends ConsumerWidget {
|
||
const _SeekRow({required this.position, required this.duration});
|
||
final Duration position;
|
||
final Duration duration;
|
||
|
||
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, WidgetRef ref) {
|
||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||
final maxMs =
|
||
duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
|
||
return Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 36,
|
||
child: Text(
|
||
_fmt(position),
|
||
textAlign: TextAlign.right,
|
||
style: TextStyle(color: fs.ash, fontSize: 10),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: SliderTheme(
|
||
data: SliderTheme.of(context).copyWith(
|
||
trackHeight: 2,
|
||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
|
||
),
|
||
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())),
|
||
),
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 36,
|
||
child: Text(
|
||
_fmt(duration),
|
||
style: TextStyle(color: fs.ash, fontSize: 10),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Reconstructs a minimal TrackRef from a MediaItem so the
|
||
/// TrackActionsButton has the fields its sheet expects. Mirrors the
|
||
/// pattern used by NowPlayingScreen — extras['album_id'] is what
|
||
/// audio_handler stashed at queue-build time.
|
||
TrackRef _trackRefFromMediaItem(MediaItem media) => 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: '',
|
||
);
|