diff --git a/flutter_client/lib/api/endpoints/playlists.dart b/flutter_client/lib/api/endpoints/playlists.dart index fc4bac55..9d772dd8 100644 --- a/flutter_client/lib/api/endpoints/playlists.dart +++ b/flutter_client/lib/api/endpoints/playlists.dart @@ -44,4 +44,13 @@ class PlaylistsApi { final r = await _dio.get>('/api/playlists/$id'); return PlaylistDetail.fromJson(r.data ?? const {}); } + + /// POST /api/playlists/{id}/tracks. Owner only; server returns the + /// playlist detail with the new rows. + Future appendTracks(String playlistId, List trackIds) async { + await _dio.post( + '/api/playlists/$playlistId/tracks', + data: {'track_ids': trackIds}, + ); + } } diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index f9e2095b..b59ad8f8 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -38,28 +38,9 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]); } - final headers = _token == null ? null : {'Authorization': 'Bearer $_token'}; debugPrint('audio_handler.setQueueFromTracks: ' '_baseUrl="$_baseUrl" trackCount=${tracks.length}'); - final sources = tracks.map((t) { - final url = _resolveStreamUrl(t); - debugPrint('audio_handler: track.id=${t.id} ' - 'track.streamUrl="${t.streamUrl}" → resolved="$url"'); - // Defensive: a scheme-less URL handed to ExoPlayer crashes with - // a confusing "Cleartext HTTP traffic to 127.0.0.1 not permitted" - // error because Android's URL parser falls back to localhost. - // Surface the actual URL so debugging is straightforward. - final parsed = Uri.parse(url); - if (!parsed.hasScheme || parsed.host.isEmpty) { - throw StateError( - 'audio_handler: refused to play scheme-less URL "$url" ' - '(baseUrl="$_baseUrl", track.streamUrl="${t.streamUrl}", ' - 'track.id="${t.id}"). configure() must be called with a ' - 'non-empty baseUrl before setQueueFromTracks().', - ); - } - return AudioSource.uri(parsed, headers: headers); - }).toList(); + final sources = tracks.map(_buildAudioSource).toList(); await _player.setAudioSources( sources, @@ -83,6 +64,51 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl return '$_baseUrl${t.streamUrl}'; } + /// Builds a single AudioSource for a track, applying URL resolution + /// against _baseUrl and attaching the Bearer header. Throws StateError + /// if the resolved URL is scheme-less (which would otherwise crash + /// ExoPlayer with a confusing localhost cleartext error). + AudioSource _buildAudioSource(TrackRef t) { + final headers = _token == null ? null : {'Authorization': 'Bearer $_token'}; + final url = _resolveStreamUrl(t); + debugPrint('audio_handler: track.id=${t.id} ' + 'track.streamUrl="${t.streamUrl}" → resolved="$url"'); + final parsed = Uri.parse(url); + if (!parsed.hasScheme || parsed.host.isEmpty) { + throw StateError( + 'audio_handler: refused to play scheme-less URL "$url" ' + '(baseUrl="$_baseUrl", track.streamUrl="${t.streamUrl}", ' + 'track.id="${t.id}"). configure() must be called with a ' + 'non-empty baseUrl before setQueueFromTracks().', + ); + } + return AudioSource.uri(parsed, headers: headers); + } + + /// Inserts [track] right after the currently-playing item so it plays + /// next. If nothing is playing, appends to the end. + Future playNext(TrackRef track) async { + final source = _buildAudioSource(track); + final item = _toMediaItem(track); + final currentIdx = _player.currentIndex; + final insertAt = currentIdx == null ? queue.value.length : currentIdx + 1; + await _player.insertAudioSource(insertAt, source); + final current = queue.value; + queue.add([ + ...current.sublist(0, insertAt), + item, + ...current.sublist(insertAt), + ]); + } + + /// Appends [track] to the end of the queue. + Future enqueue(TrackRef track) async { + final source = _buildAudioSource(track); + final item = _toMediaItem(track); + await _player.addAudioSource(source); + queue.add([...queue.value, item]); + } + MediaItem _toMediaItem(TrackRef t) => MediaItem( id: t.id, title: t.title, diff --git a/flutter_client/lib/player/player_provider.dart b/flutter_client/lib/player/player_provider.dart index 43107b63..6f09fe5e 100644 --- a/flutter_client/lib/player/player_provider.dart +++ b/flutter_client/lib/player/player_provider.dart @@ -42,6 +42,16 @@ class PlayerActions { await h.setQueueFromTracks(tracks, initialIndex: initialIndex); await h.play(); } + + Future playNext(TrackRef track) async { + final h = _ref.read(audioHandlerProvider); + await h.playNext(track); + } + + Future enqueue(TrackRef track) async { + final h = _ref.read(audioHandlerProvider); + await h.enqueue(track); + } } final playerActionsProvider = Provider((ref) => PlayerActions(ref));