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 │ /// └────────────────────────────────────────────────┘ /// /// Heart + kebab sit inline at the right of the title (per operator /// "directly to the right of the track name"). Play controls stay /// full-size (40/48/40). Right column: shuffle/repeat/queue. 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 (with inline like+kebab) | play controls | right cluster IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded(child: _TrackInfoColumn(media: media)), const SizedBox(width: 8), _PlayControls(playback: playback, ref: ref), const SizedBox(width: 8), _RightCluster(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 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), ), ), LikeButton(kind: LikeKind.track, id: media.id, size: 18), TrackActionsButton( track: _trackRefFromMediaItem(media), hideQueueActions: true, ), ], ), if (artistName.isNotEmpty) Text( artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: fs.ash, fontSize: 12), ), ], ), ), ], ), ); } } class _PlayControls extends StatelessWidget { const _PlayControls({required this.playback, required this.ref}); final PlaybackState? playback; final WidgetRef ref; @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; final isPlaying = playback?.playing == true; return 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 _RightCluster extends ConsumerWidget { const _RightCluster({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 actions = ref.read(playerActionsProvider); return Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 36, height: 36, 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: 36, height: 36, 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: 36, height: 36, child: IconButton( padding: EdgeInsets.zero, tooltip: 'Queue', icon: Icon(Icons.queue_music, size: 18, color: fs.ash), onPressed: () => context.push('/queue'), ), ), ], ); } } 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: '', );