Files
minstrel/flutter_client/lib/player/player_bar.dart
T
bvandeusen 4dbb3190ff fix(flutter): player updates on track change + kebab artist nav + Start radio
Three issues, all related to the player surface:

1. Player UI didn't update on track change. audio_handler's
   _onCurrentIndexChanged only kicked off the cover load — it never
   pushed the new MediaItem onto the mediaItem stream. Title/artist/
   cover stayed pinned to whatever setQueueFromTracks(initialIndex:)
   set on first play. Now the listener pushes queue[idx] when the
   index changes.

2. Player kebab "Go to artist" 404'd while the same item from
   MostPlayed worked. Same TrackActionsSheet for both, but the
   player's _trackRefFromMediaItem was hardcoding artistId: ''
   because audio_handler's _toMediaItem never stashed it in extras.
   Stash artist_id alongside album_id; player_bar +
   now_playing_screen read it back. Both kebabs now navigate.

3. "Start radio" didn't exist on Flutter even though the server has
   /api/radio?seed_track=<id>. New RadioApi (lib/api/endpoints/
   radio.dart) wraps the endpoint; PlayerActions.startRadio(trackId)
   fetches + plays the result via the existing playTracks path.
   New menu item between "Add to playlist" and the divider above
   "Go to album", calls startRadio with a snackbar error fallback.
2026-05-11 12:32:43 -04:00

296 lines
10 KiB
Dart

import 'dart:io';
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 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 ConsumerWidget {
const PlayerBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final media = ref.watch(mediaItemProvider).value;
final playback = ref.watch(playbackStateProvider).value;
if (media == null) return const SizedBox.shrink();
// 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: media)),
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<FabledSwordTheme>()!;
final artistName = (media.artist ?? '').trim();
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: [
if (media.artUri != null)
Image(
image: media.artUri!.isScheme('file')
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
: NetworkImage(media.artUri.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
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),
),
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<FabledSwordTheme>()!;
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(Icons.skip_previous, color: fs.parchment),
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
),
),
SizedBox(
width: 44,
height: 44,
child: IconButton(
padding: EdgeInsets.zero,
iconSize: 32,
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: 36,
height: 36,
child: IconButton(
padding: EdgeInsets.zero,
iconSize: 22,
icon: Icon(Icons.skip_next, 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<FabledSwordTheme>()!;
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: '',
);