diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index d1ecfefe..57c14bed 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -14,6 +14,10 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl MinstrelAudioHandler() { _player.playbackEventStream.listen(_broadcastState); _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(); @@ -22,6 +26,11 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl AlbumCoverCache? _coverCache; AudioCacheManager? _audioCacheManager; + /// Volume stream for UI subscribers. Mirrors the just_audio player's + /// volume directly; set via setVolume(double). + Stream get volumeStream => _player.volumeStream; + double get volume => _player.volume; + void configure({ required String baseUrl, required String? token, @@ -198,7 +207,34 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl @override Future skipToPrevious() => _player.seekToPrevious(); - void _broadcastState(PlaybackEvent event) { + @override + Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { + await _player + .setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none); + // _broadcastState picks up the change via shuffleModeEnabledStream. + } + + @override + Future 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 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; playbackState.add(PlaybackState( controls: [ @@ -218,7 +254,15 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl updatePosition: _player.position, bufferedPosition: _player.bufferedPosition, 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, + }, )); } } diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart index 810b3e7e..18feed55 100644 --- a/flutter_client/lib/player/player_bar.dart +++ b/flutter_client/lib/player/player_bar.dart @@ -1,53 +1,379 @@ +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()!; - final mediaItem = ref.watch(mediaItemProvider).value; + final media = ref.watch(mediaItemProvider).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( color: fs.iron, - child: InkWell( - onTap: () => context.push('/now-playing'), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row(children: [ - Container(width: 48, height: 48, color: fs.slate), - const SizedBox(width: 12), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(mediaItem.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14)), - Text(mediaItem.artist ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12)), - ], - )), - IconButton( - icon: Icon(playback?.playing == true ? Icons.pause : Icons.play_arrow, color: fs.parchment), - onPressed: () { - final h = ref.read(audioHandlerProvider); - if (playback?.playing == true) { h.pause(); } else { h.play(); } - }, + 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), + ), + ], + ), ), - IconButton( - icon: Icon(Icons.skip_next, color: fs.parchment), - onPressed: () => ref.read(audioHandlerProvider).skipToNext(), - ), - ]), + 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()!; + 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()!; + 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()!; + 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()!; + 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: '', + ); diff --git a/flutter_client/lib/player/player_provider.dart b/flutter_client/lib/player/player_provider.dart index 1c00eac1..8cbeff3c 100644 --- a/flutter_client/lib/player/player_provider.dart +++ b/flutter_client/lib/player/player_provider.dart @@ -30,6 +30,14 @@ final queueProvider = StreamProvider>( (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( + (ref) => ref.watch(audioHandlerProvider).volumeStream, +); + class PlayerActions { PlayerActions(this._ref); final Ref _ref; @@ -59,6 +67,32 @@ class PlayerActions { final h = _ref.read(audioHandlerProvider); await h.enqueue(track); } + + /// Toggles shuffle on/off. Pre-existing audio_service convention. + Future 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 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 setVolume(double v) async { + await _ref.read(audioHandlerProvider).setVolume(v); + } } final playerActionsProvider = Provider((ref) => PlayerActions(ref));