19061cd10c
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>
380 lines
12 KiB
Dart
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: '',
|
|
);
|