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: /// /// ┌──────────────────────────────┬──────────────┐ /// │ [art] Title ♥ ⋮ │ 🔀 🔁 ☰ │ /// │ Artist │ ⏮ ⏯ ⏭ │ /// ├──────────────────────────────┴──────────────┤ /// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │ /// └───────────────────────────────────────────────┘ /// /// Track info gets a 2:1 share of the row vs the controls column on /// the right. Heart + kebab stay inline at the right of the title. /// Right column stacks shuffle/repeat/queue above prev/play/next. /// Volume is handled by the phone's hardware buttons. Bottom: /// full-width seek + time labels. 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: [ // Top row: track info (2 fill) | controls column (1 fill). // The controls column stacks shuffle/repeat/queue above the // prev/play/next row. IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded(flex: 2, child: _TrackInfoColumn(media: media)), const SizedBox(width: 8), Expanded( flex: 1, child: _ControlsColumn(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()!; final artistName = (media.artist ?? '').trim(); 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: [ // Title row: title text + inline like + kebab. The // like/kebab buttons get their default 48dp tap target // shrunk to 32dp so the title has room — otherwise it // collapses to nothing in the available width. Row( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Text( media.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: fs.parchment, fontSize: 14), ), ), SizedBox( width: 32, height: 32, child: LikeButton( kind: LikeKind.track, id: media.id, size: 18, ), ), SizedBox( width: 32, height: 32, child: TrackActionsButton( track: _trackRefFromMediaItem(media), hideQueueActions: true, ), ), ], ), if (artistName.isNotEmpty) Text( artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: fs.ash, fontSize: 12), ), ], ), ), ], ), ); } } class _ControlsColumn extends ConsumerWidget { const _ControlsColumn({required this.playback}); final PlaybackState? playback; @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final isPlaying = playback?.playing == true; final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all; final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none; final actions = ref.read(playerActionsProvider); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ // Top sub-row: shuffle / repeat / queue Row( mainAxisSize: MainAxisSize.min, children: [ _IconBtn( size: 32, iconSize: 18, tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off', icon: Icons.shuffle, color: shuffleOn ? fs.accent : fs.ash, onPressed: actions.toggleShuffle, ), _IconBtn( size: 32, iconSize: 18, tooltip: switch (repeatMode) { AudioServiceRepeatMode.none => 'Repeat off', AudioServiceRepeatMode.one => 'Repeat one', _ => 'Repeat all', }, icon: repeatMode == AudioServiceRepeatMode.one ? Icons.repeat_one : Icons.repeat, color: repeatMode == AudioServiceRepeatMode.none ? fs.ash : fs.accent, onPressed: actions.cycleRepeat, ), _IconBtn( size: 32, iconSize: 18, tooltip: 'Queue', icon: Icons.queue_music, color: fs.ash, onPressed: () => context.push('/queue'), ), ], ), // Bottom sub-row: prev / play / next. Sized to match the // shuffle/repeat/queue row above (3×32 = 96dp) with the play // button slightly larger; total 32+40+32 = 104dp. Row( mainAxisSize: MainAxisSize.min, children: [ _IconBtn( size: 32, iconSize: 20, icon: Icons.skip_previous, color: fs.parchment, onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), ), _IconBtn( size: 40, iconSize: 26, 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(); } }, ), _IconBtn( size: 32, iconSize: 20, icon: Icons.skip_next, color: fs.parchment, onPressed: () => ref.read(audioHandlerProvider).skipToNext(), ), ], ), ], ); } } class _IconBtn extends StatelessWidget { const _IconBtn({ required this.size, required this.iconSize, required this.icon, required this.color, required this.onPressed, this.tooltip, }); final double size; final double iconSize; final IconData icon; final Color color; final VoidCallback onPressed; final String? tooltip; @override Widget build(BuildContext context) { return SizedBox( width: size, height: size, child: IconButton( padding: EdgeInsets.zero, tooltip: tooltip, iconSize: iconSize, icon: Icon(icon, color: color), onPressed: onPressed, ), ); } } 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: '', );