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:
@@ -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},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user