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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,19 +165,36 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
queue.add([...queue.value, item]);
|
queue.add([...queue.value, item]);
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaItem _toMediaItem(TrackRef t) => MediaItem(
|
MediaItem _toMediaItem(TrackRef t) {
|
||||||
id: t.id,
|
// Stash album_id + artist_id in extras so widgets reconstructing
|
||||||
title: t.title,
|
// a TrackRef from the MediaItem (player kebab → "Go to artist",
|
||||||
artist: t.artistName,
|
// "Go to album") have the IDs they need to navigate. Earlier code
|
||||||
album: t.albumTitle,
|
// only carried album_id which left "Go to artist" pushing
|
||||||
duration: Duration(seconds: t.durationSec),
|
// /artists/ (empty id, route 404).
|
||||||
// Stash album_id in extras so _loadArtForCurrentItem can pull it
|
final extras = <String, dynamic>{};
|
||||||
// back without re-walking the track list.
|
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
|
||||||
extras: t.albumId.isEmpty ? null : {'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) {
|
void _onCurrentIndexChanged(int? idx) {
|
||||||
if (idx == null) return;
|
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());
|
unawaited(_loadArtForCurrentItem());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ class _TitleRow extends StatelessWidget {
|
|||||||
title: media.title,
|
title: media.title,
|
||||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||||
albumTitle: media.album ?? '',
|
albumTitle: media.album ?? '',
|
||||||
artistId: '',
|
artistId: (media.extras?['artist_id'] as String?) ?? '',
|
||||||
artistName: media.artist ?? '',
|
artistName: media.artist ?? '',
|
||||||
durationSec: media.duration?.inSeconds ?? 0,
|
durationSec: media.duration?.inSeconds ?? 0,
|
||||||
streamUrl: '',
|
streamUrl: '',
|
||||||
|
|||||||
@@ -286,7 +286,9 @@ TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef(
|
|||||||
title: media.title,
|
title: media.title,
|
||||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||||
albumTitle: media.album ?? '',
|
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 ?? '',
|
artistName: media.artist ?? '',
|
||||||
durationSec: media.duration?.inSeconds ?? 0,
|
durationSec: media.duration?.inSeconds ?? 0,
|
||||||
streamUrl: '',
|
streamUrl: '',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/radio.dart';
|
||||||
import '../auth/auth_provider.dart';
|
import '../auth/auth_provider.dart';
|
||||||
import '../cache/audio_cache_manager.dart';
|
import '../cache/audio_cache_manager.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
@@ -102,6 +103,15 @@ class PlayerActions {
|
|||||||
Future<void> setVolume(double v) async {
|
Future<void> setVolume(double v) async {
|
||||||
await _ref.read(audioHandlerProvider).setVolume(v);
|
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));
|
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(),
|
const _Divider(),
|
||||||
_MenuItem(
|
_MenuItem(
|
||||||
key: const Key('track_actions_go_to_album'),
|
key: const Key('track_actions_go_to_album'),
|
||||||
|
|||||||
Reference in New Issue
Block a user