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>
This commit is contained in:
@@ -14,6 +14,10 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
MinstrelAudioHandler() {
|
MinstrelAudioHandler() {
|
||||||
_player.playbackEventStream.listen(_broadcastState);
|
_player.playbackEventStream.listen(_broadcastState);
|
||||||
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
||||||
|
// Re-broadcast on shuffle/repeat changes so the PlaybackState's
|
||||||
|
// shuffleMode + repeatMode fields stay current for UI subscribers.
|
||||||
|
_player.shuffleModeEnabledStream.listen((_) => _broadcastState(null));
|
||||||
|
_player.loopModeStream.listen((_) => _broadcastState(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
@@ -22,6 +26,11 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
AlbumCoverCache? _coverCache;
|
AlbumCoverCache? _coverCache;
|
||||||
AudioCacheManager? _audioCacheManager;
|
AudioCacheManager? _audioCacheManager;
|
||||||
|
|
||||||
|
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
||||||
|
/// volume directly; set via setVolume(double).
|
||||||
|
Stream<double> get volumeStream => _player.volumeStream;
|
||||||
|
double get volume => _player.volume;
|
||||||
|
|
||||||
void configure({
|
void configure({
|
||||||
required String baseUrl,
|
required String baseUrl,
|
||||||
required String? token,
|
required String? token,
|
||||||
@@ -198,7 +207,34 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
@override
|
@override
|
||||||
Future<void> skipToPrevious() => _player.seekToPrevious();
|
Future<void> skipToPrevious() => _player.seekToPrevious();
|
||||||
|
|
||||||
void _broadcastState(PlaybackEvent event) {
|
@override
|
||||||
|
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
||||||
|
await _player
|
||||||
|
.setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none);
|
||||||
|
// _broadcastState picks up the change via shuffleModeEnabledStream.
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
|
||||||
|
final loop = switch (repeatMode) {
|
||||||
|
AudioServiceRepeatMode.none => LoopMode.off,
|
||||||
|
AudioServiceRepeatMode.one => LoopMode.one,
|
||||||
|
AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all,
|
||||||
|
};
|
||||||
|
await _player.setLoopMode(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets player volume in [0.0, 1.0]. Note: most mobile browsers tie
|
||||||
|
/// page audio to system volume — this is the in-app slider for parity
|
||||||
|
/// with desktop/web; on mobile the system volume is the real control.
|
||||||
|
Future<void> setVolume(double v) async {
|
||||||
|
await _player.setVolume(v.clamp(0.0, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// _broadcastState accepts a nullable event because the shuffle/repeat
|
||||||
|
// listeners don't have one — we just want to re-emit PlaybackState
|
||||||
|
// with up-to-date shuffleMode/repeatMode fields.
|
||||||
|
void _broadcastState(PlaybackEvent? event) {
|
||||||
final playing = _player.playing;
|
final playing = _player.playing;
|
||||||
playbackState.add(PlaybackState(
|
playbackState.add(PlaybackState(
|
||||||
controls: [
|
controls: [
|
||||||
@@ -218,7 +254,15 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
updatePosition: _player.position,
|
updatePosition: _player.position,
|
||||||
bufferedPosition: _player.bufferedPosition,
|
bufferedPosition: _player.bufferedPosition,
|
||||||
speed: _player.speed,
|
speed: _player.speed,
|
||||||
queueIndex: event.currentIndex,
|
queueIndex: event?.currentIndex ?? _player.currentIndex,
|
||||||
|
shuffleMode: _player.shuffleModeEnabled
|
||||||
|
? AudioServiceShuffleMode.all
|
||||||
|
: AudioServiceShuffleMode.none,
|
||||||
|
repeatMode: switch (_player.loopMode) {
|
||||||
|
LoopMode.off => AudioServiceRepeatMode.none,
|
||||||
|
LoopMode.one => AudioServiceRepeatMode.one,
|
||||||
|
LoopMode.all => AudioServiceRepeatMode.all,
|
||||||
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,379 @@
|
|||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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 '../theme/theme_extension.dart';
|
||||||
import 'player_provider.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 {
|
class PlayerBar extends ConsumerWidget {
|
||||||
const PlayerBar({super.key});
|
const PlayerBar({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final mediaItem = ref.watch(mediaItemProvider).value;
|
final media = ref.watch(mediaItemProvider).value;
|
||||||
final playback = ref.watch(playbackStateProvider).value;
|
final playback = ref.watch(playbackStateProvider).value;
|
||||||
if (mediaItem == null) return const SizedBox.shrink();
|
if (media == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final pos = playback?.updatePosition ?? Duration.zero;
|
||||||
|
final dur = media.duration ?? Duration.zero;
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: fs.iron,
|
color: fs.iron,
|
||||||
child: InkWell(
|
child: Padding(
|
||||||
onTap: () => context.push('/now-playing'),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Row(children: [
|
children: [
|
||||||
Container(width: 48, height: 48, color: fs.slate),
|
IntrinsicHeight(
|
||||||
const SizedBox(width: 12),
|
child: Row(
|
||||||
Expanded(child: Column(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
Expanded(
|
||||||
children: [
|
flex: 35,
|
||||||
Text(mediaItem.title, maxLines: 1, overflow: TextOverflow.ellipsis,
|
child: _TrackInfoColumn(media: media),
|
||||||
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
),
|
||||||
Text(mediaItem.artist ?? '', maxLines: 1, overflow: TextOverflow.ellipsis,
|
const SizedBox(width: 8),
|
||||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
_MiddleColumn(media: media, playback: playback, ref: ref),
|
||||||
],
|
const SizedBox(width: 8),
|
||||||
)),
|
Expanded(
|
||||||
IconButton(
|
flex: 30,
|
||||||
icon: Icon(playback?.playing == true ? Icons.pause : Icons.play_arrow, color: fs.parchment),
|
child: _RightColumn(playback: playback),
|
||||||
onPressed: () {
|
),
|
||||||
final h = ref.read(audioHandlerProvider);
|
],
|
||||||
if (playback?.playing == true) { h.pause(); } else { h.play(); }
|
),
|
||||||
},
|
|
||||||
),
|
),
|
||||||
IconButton(
|
const SizedBox(height: 4),
|
||||||
icon: Icon(Icons.skip_next, color: fs.parchment),
|
_SeekRow(position: pos, duration: dur),
|
||||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
],
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: '',
|
||||||
|
);
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ final queueProvider = StreamProvider<List<MediaItem>>(
|
|||||||
(ref) => ref.watch(audioHandlerProvider).queue,
|
(ref) => ref.watch(audioHandlerProvider).queue,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Player volume in [0.0, 1.0]. Mirrors just_audio's player.volume;
|
||||||
|
/// modify via PlayerActions.setVolume(). Surfaced separately from
|
||||||
|
/// PlaybackState because audio_service's PlaybackState doesn't carry
|
||||||
|
/// volume — it's a player-side concern.
|
||||||
|
final volumeProvider = StreamProvider<double>(
|
||||||
|
(ref) => ref.watch(audioHandlerProvider).volumeStream,
|
||||||
|
);
|
||||||
|
|
||||||
class PlayerActions {
|
class PlayerActions {
|
||||||
PlayerActions(this._ref);
|
PlayerActions(this._ref);
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
@@ -59,6 +67,32 @@ class PlayerActions {
|
|||||||
final h = _ref.read(audioHandlerProvider);
|
final h = _ref.read(audioHandlerProvider);
|
||||||
await h.enqueue(track);
|
await h.enqueue(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toggles shuffle on/off. Pre-existing audio_service convention.
|
||||||
|
Future<void> toggleShuffle() async {
|
||||||
|
final h = _ref.read(audioHandlerProvider);
|
||||||
|
final state = await h.playbackState.first;
|
||||||
|
final next = state.shuffleMode == AudioServiceShuffleMode.none
|
||||||
|
? AudioServiceShuffleMode.all
|
||||||
|
: AudioServiceShuffleMode.none;
|
||||||
|
await h.setShuffleMode(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cycles repeat mode: none → all → one → none.
|
||||||
|
Future<void> cycleRepeat() async {
|
||||||
|
final h = _ref.read(audioHandlerProvider);
|
||||||
|
final state = await h.playbackState.first;
|
||||||
|
final next = switch (state.repeatMode) {
|
||||||
|
AudioServiceRepeatMode.none => AudioServiceRepeatMode.all,
|
||||||
|
AudioServiceRepeatMode.all => AudioServiceRepeatMode.one,
|
||||||
|
_ => AudioServiceRepeatMode.none,
|
||||||
|
};
|
||||||
|
await h.setRepeatMode(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setVolume(double v) async {
|
||||||
|
await _ref.read(audioHandlerProvider).setVolume(v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
|
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
|
||||||
|
|||||||
Reference in New Issue
Block a user