Files
minstrel/flutter_client/lib/player/player_bar.dart
T
bvandeusen 19061cd10c feat(flutter): full parity for player bar — shuffle/repeat/volume + Like + kebab + 3-column layout (#356)
Mirrors web's compact PlayerBar both visually (3-column top + full-
width seek bottom) and functionally — closes parity gaps that had
been hidden because the prior mini-bar surfaced none of these
controls.

### audio_handler.dart

- Override `setShuffleMode` → delegates to just_audio's
  `setShuffleModeEnabled`.
- Override `setRepeatMode` → maps audio_service modes (none/one/all/
  group) to just_audio's LoopMode (off/one/all).
- New `setVolume(double)` + `volumeStream` getter (audio_service's
  PlaybackState doesn't carry volume — surface it separately).
- `_broadcastState` now populates PlaybackState's shuffleMode +
  repeatMode fields, and re-fires on shuffle/repeat/loop stream
  changes so UI subscribers stay in sync.

### player_provider.dart

- New `volumeProvider` (StreamProvider<double>).
- `PlayerActions` gains `toggleShuffle()`, `cycleRepeat()` (none →
  all → one → none), and `setVolume(double)`.

### player_bar.dart

Complete rebuild matching the operator-designed layout:

  ┌──────────┬───────────┬──────────────────┐
  │ art      │ ♥   ⋮     │ 🔀 🔁 ☰           │  ← short row
  │ title    │           │                  │
  │ artist   │ ⏮  ⏯  ⏭   │ volume slider    │  ← play row, full size
  ├──────────┴───────────┴──────────────────┤
  │ 0:42 ━━━━━●━━━━━━━━━━━━━━━━━━━━━ 3:21  │
  └─────────────────────────────────────────┘

- Column 1 (35%): cover + title + artist. Tap → /now-playing.
- Column 2 (intrinsic): like + kebab on top, play controls (40/48/40)
  on bottom.
- Column 3 (30%): shuffle/repeat/queue on top, volume slider on bottom.
- Bottom: full-width seek slider with mm:ss labels.

`TrackActionsButton` reused for the kebab; reconstructs a minimal
TrackRef from MediaItem extras (same pattern NowPlayingScreen
already uses).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:59:53 -04:00

380 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 mirrors the
/// web's compact PlayerBar (#358):
///
/// ┌──────────┬───────────┬──────────────────┐
/// │ art │ ♥ ⋮ │ 🔀 🔁 ☰ │ ← short row
/// │ title │ │ │
/// │ artist │ ⏮ ⏯ ⏭ │ volume slider │ ← play controls
/// ├──────────┴───────────┴──────────────────┤
/// │ 0:42 ━━━━━●━━━━━━━━━━━━━━━━━━━━━ 3:21 │
/// └─────────────────────────────────────────┘
///
/// Track-info column (cover + title block) tap target opens
/// NowPlayingScreen.
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: [
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 35,
child: _TrackInfoColumn(media: media),
),
const SizedBox(width: 8),
_MiddleColumn(media: media, playback: playback, ref: ref),
const SizedBox(width: 8),
Expanded(
flex: 30,
child: _RightColumn(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>()!;
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: [
Text(
media.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
Text(
media.artist ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
],
),
),
],
),
);
}
}
class _MiddleColumn extends StatelessWidget {
const _MiddleColumn({
required this.media,
required this.playback,
required this.ref,
});
final MediaItem media;
final PlaybackState? playback;
final WidgetRef ref;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final isPlaying = playback?.playing == true;
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Top sub-row: like + kebab (shorter than play controls)
Row(
mainAxisSize: MainAxisSize.min,
children: [
LikeButton(kind: LikeKind.track, id: media.id, size: 18),
TrackActionsButton(
track: _trackRefFromMediaItem(media),
hideQueueActions: true,
),
],
),
// Bottom sub-row: play controls — full size 40 / 48 / 40
Row(
mainAxisSize: MainAxisSize.min,
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,
),
onPressed: () {
final h = ref.read(audioHandlerProvider);
if (isPlaying) {
h.pause();
} else {
h.play();
}
},
),
),
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(),
),
),
],
),
],
);
}
}
class _RightColumn extends ConsumerWidget {
const _RightColumn({required this.playback});
final PlaybackState? playback;
@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 volume = ref.watch(volumeProvider).value ?? 1.0;
final actions = ref.read(playerActionsProvider);
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Top sub-row: shuffle / repeat / queue
Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.max,
children: [
SizedBox(
width: 32,
height: 32,
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: 32,
height: 32,
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: 32,
height: 32,
child: IconButton(
padding: EdgeInsets.zero,
tooltip: 'Queue',
icon: Icon(Icons.queue_music, size: 18, color: fs.ash),
onPressed: () => context.push('/queue'),
),
),
],
),
// Bottom sub-row: volume slider
Row(
children: [
Icon(
volume == 0
? Icons.volume_off
: (volume < 0.5 ? Icons.volume_down : Icons.volume_up),
size: 14,
color: fs.ash,
),
const SizedBox(width: 4),
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: 1,
value: volume.clamp(0.0, 1.0),
onChanged: actions.setVolume,
),
),
),
],
),
],
);
}
}
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: '',
);