network addition

This commit is contained in:
Forrest 2026-08-04 16:39:18 -04:00
parent 3bd713d667
commit 2099d3d64d
27 changed files with 2237 additions and 40 deletions

View file

@ -105,11 +105,36 @@ class PlaybackState {
}
}
/// The command surface the UI drives, independent of *where* playback happens.
///
/// Implemented today only by [PlaybackController] (the local engine). Once
/// cross-device control lands (updates-features.md #3), a `RemotePlaybackProxy`
/// will also implement it, serializing each call into a LAN message to the
/// device that is actually playing. The UI binds to this interface via
/// `playbackCommandsProvider` so neither implementation leaks into widgets.
abstract interface class PlaybackCommands {
Future<void> playSongs(List<Song> songs, {int startIndex = 0});
Future<void> jumpTo(int index);
Future<void> playNext(Song song);
Future<void> addToQueue(Song song);
Future<void> removeAt(int index);
Future<void> reorderQueue(int oldIndex, int newIndex);
Future<void> togglePlayPause();
Future<void> next();
Future<void> previous();
Future<void> seek(Duration position);
Future<void> toggleShuffle();
Future<void> cycleLoop();
Future<void> retry();
void resyncFromPlayer();
}
/// Owns the just_audio player and translates Subsonic songs into a gapless
/// queue. Playback methods no-op where audio is unsupported, but queue/current
/// state and album-art accent extraction still run so the UI is fully alive on
/// the Linux dev target.
class PlaybackController extends StateNotifier<PlaybackState> {
class PlaybackController extends StateNotifier<PlaybackState>
implements PlaybackCommands {
PlaybackController({
required Uri? Function(Song) streamUriFor,
required Uri? Function(Song) coverArtUriFor,
@ -157,6 +182,11 @@ class PlaybackController extends StateNotifier<PlaybackState> {
static bool get _audioSupported =>
!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
/// The current state, for out-of-widget consumers (e.g. the remote host,
/// which reads it to send a newly-connected remote an immediate snapshot).
/// Widgets should watch `activePlaybackProvider` instead.
PlaybackState get currentState => state;
void _wireStreams() {
final player = _player!;
player.currentIndexStream.listen((i) {
@ -246,6 +276,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// into the player, so queue index == player source index 1:1. Every later
/// queue mutation (`playNext` / `addToQueue` / `removeAt`) relies on that
/// invariant to stay aligned.
@override
Future<void> playSongs(List<Song> songs, {int startIndex = 0}) async {
if (songs.isEmpty) return;
@ -317,6 +348,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// Start playing the queue entry at [index] without rebuilding the queue —
/// used by the queue list so tapping a row jumps to it and keeps the current
/// (possibly shuffled) order intact.
@override
Future<void> jumpTo(int index) async {
final q = state.queue;
if (index < 0 || index >= q.length) return;
@ -339,6 +371,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// Insert [song] right after the current track (Timbre's "play next").
/// Falls back to [playSongs] when nothing is playing.
@override
Future<void> playNext(Song song) async {
if (_streamUriFor(song) == null) return;
final q = state.queue;
@ -353,6 +386,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
}
/// Append [song] to the end of the queue.
@override
Future<void> addToQueue(Song song) async {
if (_streamUriFor(song) == null) return;
final q = state.queue;
@ -368,6 +402,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// playlist aligned. On mobile just_audio adjusts its own current index and
/// re-emits `currentIndexStream`; the id dedupe in [_notifyCurrent] avoids a
/// spurious re-scrobble when the playing track didn't actually change.
@override
Future<void> removeAt(int index) async {
final q = state.queue;
if (index < 0 || index >= q.length) return;
@ -415,6 +450,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// among the shuffled tail; hitting shuffle afterwards re-randomizes as usual.
/// A single targeted `moveAudioSource` keeps the current track playing without
/// the re-buffer a full source rebuild ([_applyOrder]) would cause.
@override
Future<void> reorderQueue(int oldIndex, int newIndex) async {
final q = state.queue;
if (oldIndex < 0 || oldIndex >= q.length) return;
@ -452,6 +488,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
await player.moveAudioSource(oldIndex, newIndex);
}
@override
Future<void> togglePlayPause() async {
final player = _player;
if (player == null) return;
@ -462,10 +499,13 @@ class PlaybackController extends StateNotifier<PlaybackState> {
}
}
@override
Future<void> next() async => _player?.seekToNext();
@override
Future<void> previous() async => _player?.seekToPrevious();
@override
Future<void> seek(Duration position) async => _player?.seek(position);
/// Toggle shuffle by physically reordering the queue.
@ -478,6 +518,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// (and already-played history) in place; disabling restores the pre-shuffle
/// order, dropping anything since removed and appending anything since added.
/// The player's own shuffle mode is left off permanently.
@override
Future<void> toggleShuffle() async {
final enabled = !state.shuffle;
final q = state.queue;
@ -587,6 +628,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// Re-attempt the current queue after an [PlaybackError] (e.g. the network
/// came back). Rebuilds sources from the current queue — re-resolving each
/// URI, so any now-available local download is preferred — and resumes.
@override
Future<void> retry() async {
if (state.queue.isEmpty) return;
try {
@ -615,6 +657,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// frozen (often at 0:00) when the app returns to the foreground. Called from
/// the app-lifecycle `resumed` hook. Reads only — never rebuilds sources or
/// touches the queue order, so the queue-index==source-index invariant holds.
@override
void resyncFromPlayer() {
final player = _player;
if (player == null) return;
@ -639,6 +682,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with
/// single-track repeat).
@override
Future<void> cycleLoop() async {
final nextMode = switch (state.loop) {
LoopMode.off => LoopMode.all,