diff --git a/flutter_client/lib/api/endpoints/radio.dart b/flutter_client/lib/api/endpoints/radio.dart new file mode 100644 index 00000000..b39774bb --- /dev/null +++ b/flutter_client/lib/api/endpoints/radio.dart @@ -0,0 +1,27 @@ +import 'package:dio/dio.dart'; + +import '../../models/track.dart'; + +/// GET /api/radio?seed_track=&limit=. 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> seedTrack(String trackId, {int? limit}) async { + final r = await _dio.get>( + '/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())) + .toList(growable: false); + } +} diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index d859769f..132d9296 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -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 = {}; + 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()); } diff --git a/flutter_client/lib/player/now_playing_screen.dart b/flutter_client/lib/player/now_playing_screen.dart index 5cdc2e11..a2cdf704 100644 --- a/flutter_client/lib/player/now_playing_screen.dart +++ b/flutter_client/lib/player/now_playing_screen.dart @@ -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: '', diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart index e068103e..0e03fed3 100644 --- a/flutter_client/lib/player/player_bar.dart +++ b/flutter_client/lib/player/player_bar.dart @@ -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: '', diff --git a/flutter_client/lib/player/player_provider.dart b/flutter_client/lib/player/player_provider.dart index eafcb9a7..1b8d2283 100644 --- a/flutter_client/lib/player/player_provider.dart +++ b/flutter_client/lib/player/player_provider.dart @@ -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 setVolume(double v) async { await _ref.read(audioHandlerProvider).setVolume(v); } + + /// Fetches /api/radio?seed_track= and starts playing the + /// returned track list (seed at index 0 + recommended picks). + Future 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((ref) => PlayerActions(ref)); diff --git a/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart b/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart index bbb62d6f..c3adaf7e 100644 --- a/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart +++ b/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart @@ -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'),