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 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()!; 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: '', );