feat(flutter/player): playNext + enqueue + PlaylistsApi.appendTracks

Three small data-layer additions for the upcoming track-actions menu:

- PlaylistsApi.appendTracks(playlistId, trackIds) wraps
  POST /api/playlists/{id}/tracks for the "Add to playlist" action.
- audio_handler gains playNext (insertAudioSource at currentIndex+1)
  and enqueue (addAudioSource) — both also push the audio_service
  queue notifier so the queue-screen UI stays in sync.
- The AudioSource construction was extracted into a private
  _buildAudioSource helper so setQueueFromTracks / playNext / enqueue
  share one source-building path.
- PlayerActions exposes playNext / enqueue for menu use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 13:40:17 -04:00
parent 5159bcd3f4
commit 2499449c0b
3 changed files with 65 additions and 20 deletions
@@ -44,4 +44,13 @@ class PlaylistsApi {
final r = await _dio.get<Map<String, dynamic>>('/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<void> appendTracks(String playlistId, List<String> trackIds) async {
await _dio.post<void>(
'/api/playlists/$playlistId/tracks',
data: {'track_ids': trackIds},
);
}
}
+46 -20
View File
@@ -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<void> 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<void> 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,
@@ -42,6 +42,16 @@ class PlayerActions {
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
await h.play();
}
Future<void> playNext(TrackRef track) async {
final h = _ref.read(audioHandlerProvider);
await h.playNext(track);
}
Future<void> enqueue(TrackRef track) async {
final h = _ref.read(audioHandlerProvider);
await h.enqueue(track);
}
}
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));