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');
|
final r = await _dio.get<Map<String, dynamic>>('/api/playlists/$id');
|
||||||
return PlaylistDetail.fromJson(r.data ?? const {});
|
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)]);
|
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
|
|
||||||
debugPrint('audio_handler.setQueueFromTracks: '
|
debugPrint('audio_handler.setQueueFromTracks: '
|
||||||
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
|
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
|
||||||
final sources = tracks.map((t) {
|
final sources = tracks.map(_buildAudioSource).toList();
|
||||||
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();
|
|
||||||
|
|
||||||
await _player.setAudioSources(
|
await _player.setAudioSources(
|
||||||
sources,
|
sources,
|
||||||
@@ -83,6 +64,51 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
return '$_baseUrl${t.streamUrl}';
|
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(
|
MediaItem _toMediaItem(TrackRef t) => MediaItem(
|
||||||
id: t.id,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ class PlayerActions {
|
|||||||
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
||||||
await h.play();
|
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));
|
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
|
||||||
|
|||||||
Reference in New Issue
Block a user