import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.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 'now_playing_screen.dart' show kPlayerCoverHeroTag; import 'player_provider.dart'; /// Compact player bar mounted at the bottom of the app shell. Mini /// view only — the heavyweight shuffle/repeat/queue/volume controls /// live in NowPlayingScreen, accessible by tap or swipe-up. /// /// ┌─────────────────────────────────────┬──────────┐ /// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │ /// │ Artist │ │ /// ├─────────────────────────────────────────────────┤ /// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │ /// └─────────────────────────────────────────────────┘ /// /// Tap anywhere on the bar (including art/title) ascends to the full /// player. A vertical drag upward also ascends, so the gesture mirrors /// the drag-down dismissal on the full screen. class PlayerBar extends ConsumerStatefulWidget { const PlayerBar({super.key}); @override ConsumerState createState() => _PlayerBarState(); } class _PlayerBarState extends ConsumerState { /// Last non-null artUri we've seen from the mediaItem stream. Held /// across the audio_handler's two-broadcast track-change pattern /// (bare MediaItem first, then with artUri once AlbumCoverCache /// resolves) so the mini bar keeps showing the previous track's /// cover until the new one is known. Without this hold the bar /// flickered to the slate placeholder for ~100ms on every track /// change. Uri? _lastArtUri; @override Widget build(BuildContext context) { 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(); // Capture the latest non-null artUri so _TrackInfo's cover stays // continuous across track changes. if (media.artUri != null) _lastArtUri = media.artUri; final displayMedia = media.artUri == null && _lastArtUri != null ? media.copyWith(artUri: _lastArtUri) : media; // positionProvider is just_audio's positionStream (~200ms cadence) // so the mini bar's seek crawls forward smoothly. PlaybackState // only updates on event transitions and would leave it frozen. final pos = ref.watch(positionProvider).value ?? Duration.zero; final dur = media.duration ?? Duration.zero; return Material( color: fs.iron, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => context.push('/now-playing'), // Swipe up anywhere on the bar to expand into the full player. // We only act on a clear upward flick (>200 px/s) so a slow // tap-with-tiny-jitter doesn't accidentally open the screen. onVerticalDragEnd: (d) { final v = d.primaryVelocity ?? 0; if (v < -200) context.push('/now-playing'); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded(child: _TrackInfo(media: displayMedia)), const SizedBox(width: 8), _PlayControls(playback: playback, ref: ref), ], ), ), const SizedBox(height: 4), _SeekRow(position: pos, duration: dur), ], ), ), ), ); } } class _TrackInfo extends StatelessWidget { const _TrackInfo({required this.media}); final MediaItem media; @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; final artistName = (media.artist ?? '').trim(); // Cover is wrapped in a Hero with the shared kPlayerCoverHeroTag so // tapping the mini bar to expand into NowPlayingScreen animates the // artwork from this 48dp footprint to the full-screen size rather // than fade-cutting. Tag is stable per-route (not keyed by media.id) // so the transition works regardless of what's playing. // // Why no transition / placeholder handling: the audio_handler // broadcasts MediaItem twice on track change — once with artUri // null (the new track's bare metadata), then with artUri set once // AlbumCoverCache resolves. The widget tree above hands us the // most-recent non-null artUri (`displayArtUri`), so the previous // track's cover stays visible across that null gap and the new // cover snaps in the moment its artUri arrives. Rapid change is // fine here per operator preference; AnimatedSwitcher previously // smeared the swap and made the slate placeholder visible. final displayArtUri = media.artUri; final Widget cover; if (displayArtUri != null) { // CachedNetworkImageProvider for HTTPS art URIs so the mini bar // hits the same disk cache the rest of the UI uses (ServerImage, // discover thumbnails). FileImage stays for the file:// branch // populated by AlbumCoverCache — bytes are already on disk and a // second cache layer would only burn duplicate space. cover = Image( image: displayArtUri.isScheme('file') ? FileImage(File.fromUri(displayArtUri)) as ImageProvider : CachedNetworkImageProvider(displayArtUri.toString()), width: 48, height: 48, // Without a fit, Image paints the source at its intrinsic // resolution inside the 48dp box — a thumbnail-sized cover // would render as a tiny inset. Cover stretches/crops to fill // the box uniformly. fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(width: 48, height: 48, color: fs.slate), ); } else { cover = Container(width: 48, height: 48, color: fs.slate); } return Row( // Default centering vertically aligns the like/kebab buttons // against the album art (48dp) — visually they span the title + // artist block instead of sitting on the title baseline. crossAxisAlignment: CrossAxisAlignment.center, children: [ Hero( tag: kPlayerCoverHeroTag, child: cover, ), 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), ), if (artistName.isNotEmpty) Text( artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: fs.ash, fontSize: 12), ), ], ), ), // Like + kebab as siblings to the title/artist column rather // than nested inside the title row. Width stays 32dp each so // horizontal footprint matches what we had; height stretches // to the row's full 48dp so the icons sit at vertical center // against the title+artist block. SizedBox( width: 32, height: 48, child: LikeButton( kind: LikeKind.track, id: media.id, size: 20, ), ), SizedBox( width: 32, height: 48, child: TrackActionsButton( track: _trackRefFromMediaItem(media), hideQueueActions: true, ), ), ], ); } } 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: 36, height: 36, child: IconButton( padding: EdgeInsets.zero, iconSize: 22, icon: Icon(LucideIcons.skip_back, color: fs.parchment), onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), ), ), SizedBox( width: 44, height: 44, child: IconButton( padding: EdgeInsets.zero, iconSize: 32, icon: Icon( isPlaying ? LucideIcons.circle_pause : LucideIcons.circle_play, color: fs.accent, ), onPressed: () { final h = ref.read(audioHandlerProvider); if (isPlaying) { h.pause(); } else { h.play(); } }, ), ), SizedBox( width: 36, height: 36, child: IconButton( padding: EdgeInsets.zero, iconSize: 22, icon: Icon(LucideIcons.skip_forward, color: fs.parchment), onPressed: () => ref.read(audioHandlerProvider).skipToNext(), ), ), ], ); } } 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 ?? '', // artist_id is stashed in extras by audio_handler — without it // the kebab's "Go to artist" pushes /artists/ (empty id) and 404s. artistId: (media.extras?['artist_id'] as String?) ?? '', artistName: media.artist ?? '', durationSec: media.duration?.inSeconds ?? 0, streamUrl: '', );