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.
This commit is contained in:
2026-05-11 12:32:43 -04:00
parent 2299824ad9
commit 4dbb3190ff
6 changed files with 85 additions and 12 deletions
@@ -0,0 +1,27 @@
import 'package:dio/dio.dart';
import '../../models/track.dart';
/// GET /api/radio?seed_track=<uuid>&limit=<int>. Returns the seed at
/// index 0 followed by up to `limit-1` weighted-shuffle picks scored
/// by the server's recommendation engine. The shape matches what
/// `playerActions.playTracks` expects, so a radio start is just one
/// fetch + one playTracks call.
class RadioApi {
RadioApi(this._dio);
final Dio _dio;
Future<List<TrackRef>> seedTrack(String trackId, {int? limit}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/radio',
queryParameters: {
'seed_track': trackId,
if (limit != null) 'limit': limit,
},
);
final raw = (r.data?['tracks'] as List?) ?? const [];
return raw
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
}
+27 -10
View File
@@ -165,19 +165,36 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
queue.add([...queue.value, item]);
}
MediaItem _toMediaItem(TrackRef t) => MediaItem(
id: t.id,
title: t.title,
artist: t.artistName,
album: t.albumTitle,
duration: Duration(seconds: t.durationSec),
// Stash album_id in extras so _loadArtForCurrentItem can pull it
// back without re-walking the track list.
extras: t.albumId.isEmpty ? null : {'album_id': t.albumId},
);
MediaItem _toMediaItem(TrackRef t) {
// Stash album_id + artist_id in extras so widgets reconstructing
// a TrackRef from the MediaItem (player kebab → "Go to artist",
// "Go to album") have the IDs they need to navigate. Earlier code
// only carried album_id which left "Go to artist" pushing
// /artists/ (empty id, route 404).
final extras = <String, dynamic>{};
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
return MediaItem(
id: t.id,
title: t.title,
artist: t.artistName,
album: t.albumTitle,
duration: Duration(seconds: t.durationSec),
extras: extras.isEmpty ? null : extras,
);
}
void _onCurrentIndexChanged(int? idx) {
if (idx == null) return;
// Push the new track's MediaItem onto the mediaItem stream so
// the player UI rebuilds with the new title/artist/album/cover.
// Without this, the bar and full player stayed pinned to whichever
// track was passed via setQueueFromTracks(initialIndex:) regardless
// of skip/auto-advance.
final items = queue.value;
if (idx >= 0 && idx < items.length) {
mediaItem.add(items[idx]);
}
unawaited(_loadArtForCurrentItem());
}
@@ -235,7 +235,7 @@ class _TitleRow extends StatelessWidget {
title: media.title,
albumId: (media.extras?['album_id'] as String?) ?? '',
albumTitle: media.album ?? '',
artistId: '',
artistId: (media.extras?['artist_id'] as String?) ?? '',
artistName: media.artist ?? '',
durationSec: media.duration?.inSeconds ?? 0,
streamUrl: '',
+3 -1
View File
@@ -286,7 +286,9 @@ TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef(
title: media.title,
albumId: (media.extras?['album_id'] as String?) ?? '',
albumTitle: media.album ?? '',
artistId: '',
// 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: '',
@@ -1,6 +1,7 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/radio.dart';
import '../auth/auth_provider.dart';
import '../cache/audio_cache_manager.dart';
import '../library/library_providers.dart' show dioProvider;
@@ -102,6 +103,15 @@ class PlayerActions {
Future<void> setVolume(double v) async {
await _ref.read(audioHandlerProvider).setVolume(v);
}
/// Fetches /api/radio?seed_track=<id> and starts playing the
/// returned track list (seed at index 0 + recommended picks).
Future<void> startRadio(String trackId) async {
final dio = await _ref.read(dioProvider.future);
final tracks = await RadioApi(dio).seedTrack(trackId);
if (tracks.isEmpty) return;
await playTracks(tracks);
}
}
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
@@ -117,6 +117,23 @@ class TrackActionsSheet extends ConsumerWidget {
}
},
),
_MenuItem(
key: const Key('track_actions_start_radio'),
icon: Icons.radio,
label: 'Start radio',
onTap: () async {
Navigator.pop(context);
try {
await ref.read(playerActionsProvider).startRadio(track.id);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Couldn't start radio: $e")),
);
}
}
},
),
const _Divider(),
_MenuItem(
key: const Key('track_actions_go_to_album'),