// Named constructor params can't be private, so initializing formals aren't // possible for the private callback fields below. // ignore_for_file: prefer_initializing_formals import 'dart:async'; import 'dart:convert'; import 'dart:io' show File, Platform; import 'dart:math' show Random; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show PlatformException; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart'; import 'package:just_audio_background/just_audio_background.dart'; import 'package:path_provider/path_provider.dart'; import '../subsonic/models.dart'; /// Why playback halted. Surfaced to the UI so a failed source pauses with an /// explanation instead of the platform player silently thrashing (restart / /// auto-skip). Null when there is no active error. enum PlaybackError { /// The current source could not be loaded and no local copy exists — almost /// always a dropped network connection with a remote (streaming) track. offline, } /// Immutable snapshot of the player, mirroring Timbre's `QueueState` + /// player-event stream (`timbre-player/src/engine.rs`). class PlaybackState { const PlaybackState({ this.queue = const [], this.currentIndex, this.playing = false, this.position = Duration.zero, this.duration = Duration.zero, this.shuffle = false, this.loop = LoopMode.off, this.supported = true, this.error, }); final List queue; final int? currentIndex; final bool playing; final Duration position; final Duration duration; final bool shuffle; final LoopMode loop; /// Non-null when playback is halted by a load failure (see [PlaybackError]). /// Cleared on the next successful play/jump or an in-place recovery. final PlaybackError? error; /// False on platforms without a just_audio backend (e.g. Linux desktop /// without media_kit). The UI still reflects the selected track; only audio /// output is unavailable. final bool supported; Song? get current => (currentIndex != null && currentIndex! >= 0 && currentIndex! < queue.length) ? queue[currentIndex!] : null; /// Duration to display/compute progress against. Prefers the value reported /// by `just_audio`'s `durationStream`, but falls back to the current song's /// known length from Subsonic metadata when the stream hasn't emitted yet (or /// desynced after a cold start / audio-session interruption). Without this /// fallback the bar renders "0:00 / 0:00" while audio actually plays. Duration get effectiveDuration { if (duration > Duration.zero) return duration; final secs = current?.duration; return secs != null ? Duration(seconds: secs) : Duration.zero; } double get progress { final total = effectiveDuration.inMilliseconds; if (total <= 0) return 0; return (position.inMilliseconds / total).clamp(0.0, 1.0); } PlaybackState copyWith({ List? queue, int? currentIndex, bool? playing, Duration? position, Duration? duration, bool? shuffle, LoopMode? loop, bool? supported, PlaybackError? error, bool clearError = false, }) { return PlaybackState( queue: queue ?? this.queue, currentIndex: currentIndex ?? this.currentIndex, playing: playing ?? this.playing, position: position ?? this.position, duration: duration ?? this.duration, shuffle: shuffle ?? this.shuffle, loop: loop ?? this.loop, supported: supported ?? this.supported, error: clearError ? null : (error ?? this.error), ); } } /// 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 playSongs(List songs, {int startIndex = 0}); Future jumpTo(int index); Future playNext(Song song); Future addToQueue(Song song); Future removeAt(int index); Future reorderQueue(int oldIndex, int newIndex); Future togglePlayPause(); Future next(); Future previous(); Future seek(Duration position); Future toggleShuffle(); Future cycleLoop(); Future 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 implements PlaybackCommands { PlaybackController({ required Uri? Function(Song) streamUriFor, required Uri? Function(Song) coverArtUriFor, required String? Function() serverKeyGetter, required void Function(Uri artUri) onArt, required void Function(Song song) onPlay, @visibleForTesting bool? audioEnabled, }) : _streamUriFor = streamUriFor, _coverArtUriFor = coverArtUriFor, _serverKeyGetter = serverKeyGetter, _onArt = onArt, _onPlay = onPlay, _audioEnabled = audioEnabled ?? _audioSupported, super(PlaybackState(supported: audioEnabled ?? _audioSupported)) { if (_audioEnabled) { _player = AudioPlayer(); _wireStreams(); } // Persist the queue on any change (throttled) so it survives an app kill. addListener((_) => _scheduleSave(), fireImmediately: false); } final Uri? Function(Song) _streamUriFor; final Uri? Function(Song) _coverArtUriFor; final String? Function() _serverKeyGetter; final void Function(Uri artUri) _onArt; final void Function(Song song) _onPlay; /// Whether a platform [AudioPlayer] was created. Defaults to [_audioSupported] /// but tests force it false so a headless CI host (macOS/iOS, where /// [_audioSupported] is true) doesn't touch uninitialized just_audio platform /// channels. The queue/state logic runs identically either way. final bool _audioEnabled; AudioPlayer? _player; final Random _random = Random(); /// Snapshot of the queue order taken the moment shuffle was enabled, so /// disabling it can restore the original (album / track-list) order. Null /// while shuffle is off, or after a restart restored a shuffled queue (the /// pre-shuffle order isn't persisted, so we can't rebuild it then). List? _unshuffledOrder; /// Coalesces rapid state changes (a position tick every second) into at most /// one disk write per window. Timer? _saveTimer; /// Server key whose queue we've already restored (or adopted). Gates saves so /// the empty launch state can't clobber a snapshot before restore runs. String? _restoredKey; 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) { if (i == null) return; // Our own window slide/trim shifts the player's index without the // playing track changing; ignore those emissions (logical index holds). if (_windowBusy) return; final logical = _windowStart + i; if (logical < 0 || logical >= state.queue.length) return; state = state.copyWith(currentIndex: logical); _notifyCurrent(); _maybeUpgradeToLocal(); _maybeSlideWindow(); }); player.playerStateStream.listen((s) { state = state.copyWith(playing: s.playing); }); player.positionStream.listen((p) { state = state.copyWith(position: p); }); player.durationStream.listen((d) { if (d != null) state = state.copyWith(duration: d); }); // just_audio surfaces load/decode failures (e.g. an unreachable remote // source after the network drops) as errors on the event stream. Without a // handler the platform player runs its own recovery — restarting the item // at 0 or auto-advancing — which is the reported "scrub back / skip" bug. player.playbackEventStream.listen( (_) {}, onError: (Object e, StackTrace st) => _onPlayerError(e), ); } /// Id of the song we last ran play side effects for. Queue edits shift /// `currentIndex` (and re-emit `currentIndexStream`) without changing the /// playing track, so we dedupe on song id to avoid re-scrobbling / re-running /// accent extraction when the current song hasn't actually changed. String? _lastNotifiedId; /// Monotonic tag counter — every AudioSource gets a globally-unique MediaItem /// id even when the same song appears in the queue twice. just_audio_background /// keys its notification off the tag id, so duplicate ids would confuse it. int _tagSeq = 0; /// Song ids whose currently-loaded source resolved to a *remote* stream URL /// (as opposed to a `file://` local download). Used to cheaply decide whether /// a track can be upgraded to a now-available local copy, and to avoid /// rebuilding when it can't. Populated as sources are built. final Set _remoteSourceIds = {}; /// Guards the in-place source rebuild used by error recovery / local upgrade /// so its own `currentIndexStream` re-emission can't re-enter the rebuild. bool _rebuilding = false; // ---- Windowed player queue ------------------------------------------ // // The just_audio player holds only a bounded slice of `state.queue` around // the current track, never the whole (potentially thousands-long) queue. // Loading every source at once overran Android's ~1 MB Binder transaction // limit (TransactionTooLargeException) and blocked the UI thread building // thousands of MediaItems — the reported crash/freeze on "queue all" and // shuffle-all. `state.queue` remains the full logical queue (so the UI shows // every track, correctly numbered); only what's handed to the platform is // bounded. // // [_windowStart] is the logical index (into `state.queue`) of the first // source currently loaded, and [_windowLen] how many are loaded, so // `logicalIndex == _windowStart + playerIndex`. The window slides via // incremental edge edits (no reload of the playing track) as playback nears // an edge; jumps / reorders that cross it rebuild the window outright. /// Logical index of the first loaded source, and the number loaded. int _windowStart = 0; int _windowLen = 0; /// Guards window slide/trim edits: the front insert/remove they use shifts /// the player's own index and re-emits `currentIndexStream`, which must not /// be mistaken for a real track change (the logical index is unchanged) or /// re-enter the slider. bool _windowBusy = false; /// Keep roughly this many tracks loaded on either side of the current one; /// begin sliding once the current track comes within [_kSlideMargin] of a /// loaded edge that still has more queue beyond it. static const int _kWindowRadius = 100; static const int _kSlideMargin = 40; void _notifyCurrent() { final song = state.current; if (song == null) return; if (song.id == _lastNotifiedId) return; _lastNotifiedId = song.id; final art = _coverArtUriFor(song); if (art != null) _onArt(art); _onPlay(song); } /// Build the player source list for [songs], resetting the remote-id tracking /// so [_maybeUpgradeToLocal] reflects exactly what is now loaded. List _buildSources(List songs) { _remoteSourceIds.clear(); return songs.map(_sourceFor).toList(); } AudioSource _sourceFor(Song song) { final uri = _streamUriFor(song)!; if (!uri.isScheme('file')) _remoteSourceIds.add(song.id); final art = _coverArtUriFor(song); return AudioSource.uri( uri, tag: MediaItem( id: '${song.id}#${_tagSeq++}', title: song.title ?? 'Unknown', album: song.album, artist: song.artist, duration: song.duration != null ? Duration(seconds: song.duration!) : null, artUri: art, ), ); } /// Replace the queue with [songs] and start at [startIndex]. /// /// `state.queue` is set to exactly the *streamable* subset that gets loaded /// 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 playSongs(List songs, {int startIndex = 0}) async { if (songs.isEmpty) return; final streamable = songs.where((s) => _streamUriFor(s) != null).toList(); if (streamable.isEmpty) { // Not connected / nothing playable — reflect the selection for the UI // only; no player sources exist to mutate. state = state.copyWith( queue: songs, currentIndex: startIndex.clamp(0, songs.length - 1), position: Duration.zero, clearError: true, ); _lastNotifiedId = null; _notifyCurrent(); return; } // Remap the requested start into the filtered list (the chosen song may // itself have been unstreamable). final target = songs[startIndex.clamp(0, songs.length - 1)]; final targetIndex = streamable.indexOf(target); var start = targetIndex >= 0 ? targetIndex : startIndex.clamp(0, streamable.length - 1); // With shuffle on, a fresh play still plays the chosen song first and // shuffles the rest (we own the order — the player's own shuffle stays off). // Remember the incoming order so a later un-shuffle can restore it. var ordered = streamable; if (state.shuffle && streamable.length > 1) { _unshuffledOrder = [...streamable]; final first = streamable[start]; final rest = [...streamable]..removeAt(start); rest.shuffle(_random); ordered = [first, ...rest]; start = 0; } else { _unshuffledOrder = null; } state = state.copyWith( queue: ordered, currentIndex: start, position: Duration.zero, clearError: true, ); // An explicit play should always (re)scrobble, even if it's the same song. _lastNotifiedId = null; final player = _player; if (player == null) { // Linux/desktop: no streams will fire, so reflect the selection (art + // history) directly. UI-only, no audio output. _notifyCurrent(); return; } try { // Load only a window around the start; the full queue lives in // `state.queue` and the window slides as playback advances. await _loadWindow( queue: ordered, center: start, position: Duration.zero, play: true, ); // currentIndexStream fires _notifyCurrent for the started track. } catch (e) { await _onPlayerError(e); } } /// (Re)load exactly the window around [center] of [queue] into the player, /// replacing whatever was loaded, and record its bounds in /// [_windowStart]/[_windowLen]. The current track is loaded at [position], /// so an out-of-window jump / reorder re-buffers only briefly. No-op on the /// no-audio path beyond recording the bounds. Future _loadWindow({ required List queue, required int center, required Duration position, required bool play, }) async { final len = queue.length; final start = (center - _kWindowRadius).clamp(0, len).toInt(); final end = (center + _kWindowRadius + 1).clamp(0, len).toInt(); _windowStart = start; _windowLen = end - start; final player = _player; if (player == null) return; // Guard the rebuild so the `currentIndexStream` re-emission it triggers // can't re-enter the local-upgrade / slide paths and rebuild again. _rebuilding = true; try { await player.setAudioSources( _buildSources(queue.sublist(start, end)), initialIndex: center - start, initialPosition: position, ); if (play) await player.play(); if (state.error != null) state = state.copyWith(clearError: true); } finally { _rebuilding = false; } } /// Extend/trim the loaded window so it stays centered (within /// [_kWindowRadius]) on the current track, using incremental edge edits that /// leave the playing source untouched (no re-buffer). Called on every real /// track change. Bulk range ops keep each edit to a single, index-consistent /// `currentIndexStream` emission. Future _maybeSlideWindow() async { final player = _player; if (player == null || _rebuilding || _windowBusy) return; final len = state.queue.length; final cur = state.currentIndex; if (cur == null || len == 0) return; final windowEnd = _windowStart + _windowLen; // exclusive final nearAhead = windowEnd < len && (windowEnd - cur) <= _kSlideMargin; final nearBehind = _windowStart > 0 && (cur - _windowStart) <= _kSlideMargin; if (!nearAhead && !nearBehind) return; final desiredStart = (cur - _kWindowRadius).clamp(0, len).toInt(); final desiredEnd = (cur + _kWindowRadius + 1).clamp(0, len).toInt(); _windowBusy = true; try { // Extend ahead (append leaves the current player index untouched). if (desiredEnd > windowEnd) { final add = state.queue.sublist(windowEnd, desiredEnd); await player.addAudioSources(add.map(_sourceFor).toList()); _windowLen += add.length; } // Extend behind (prepend shifts the player index up; logical is stable). if (desiredStart < _windowStart) { final pre = state.queue.sublist(desiredStart, _windowStart); await player.insertAudioSources(0, pre.map(_sourceFor).toList()); _windowStart = desiredStart; _windowLen += pre.length; } // Trim behind (removed sources sit before the current track). if (desiredStart > _windowStart) { final n = desiredStart - _windowStart; await player.removeAudioSourceRange(0, n); _windowStart += n; _windowLen -= n; } // Trim ahead (removed sources sit after the current track). if (desiredEnd < _windowStart + _windowLen) { await player.removeAudioSourceRange( desiredEnd - _windowStart, _windowLen); _windowLen = desiredEnd - _windowStart; } } finally { _windowBusy = false; } } /// 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 jumpTo(int index) async { final q = state.queue; if (index < 0 || index >= q.length) return; final player = _player; if (player == null) { state = state.copyWith( currentIndex: index, position: Duration.zero, clearError: true); _notifyCurrent(); return; } try { if (index >= _windowStart && index < _windowStart + _windowLen) { // Target is already loaded: a cheap seek keeps the rest of the window. await player.seek(Duration.zero, index: index - _windowStart); await player.play(); if (state.error != null) state = state.copyWith(clearError: true); // currentIndexStream fires _notifyCurrent for the jumped-to track. } else { // Target is outside the window: rebuild it around the target (this // re-buffers once, as any far jump would). state = state.copyWith( currentIndex: index, position: Duration.zero, clearError: true); _lastNotifiedId = null; await _loadWindow( queue: q, center: index, position: Duration.zero, play: true); } } catch (e) { await _onPlayerError(e); } } /// Insert [song] right after the current track (Timbre's "play next"). /// Falls back to [playSongs] when nothing is playing. @override Future playNext(Song song) async { if (_streamUriFor(song) == null) return; final q = state.queue; final current = state.currentIndex; if (q.isEmpty || current == null) { await playSongs([song]); return; } final at = (current + 1).clamp(0, q.length); state = state.copyWith(queue: [...q]..insert(at, song)); final player = _player; if (player == null) return; // Insert into the player only if the slot is inside the loaded window; // otherwise it lands in the not-yet-loaded tail and loads on the next // slide. `at` is current+1 — always just past the (loaded) current track. final playerAt = at - _windowStart; if (playerAt >= 0 && playerAt <= _windowLen) { await player.insertAudioSource(playerAt, _sourceFor(song)); _windowLen++; } } /// Append [song] to the end of the queue. @override Future addToQueue(Song song) async { if (_streamUriFor(song) == null) return; final q = state.queue; if (q.isEmpty || state.currentIndex == null) { await playSongs([song]); return; } // The player only needs the new source if the window currently reaches the // end of the queue; otherwise it loads when the window slides to the tail. final tailLoaded = _windowStart + _windowLen == q.length; state = state.copyWith(queue: [...q, song]); final player = _player; if (player == null) return; if (tailLoaded) { await player.addAudioSource(_sourceFor(song)); _windowLen++; } } /// Remove the queue entry at [index], keeping `state.queue` and the player /// 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 removeAt(int index) async { final q = state.queue; if (index < 0 || index >= q.length) return; final next = [...q]..removeAt(index); if (next.isEmpty) { await _player?.clearAudioSources(); _windowStart = 0; _windowLen = 0; state = PlaybackState( shuffle: state.shuffle, loop: state.loop, supported: state.supported, ); _lastNotifiedId = null; return; } final player = _player; if (player == null) { // Desktop/no-audio: no stream will correct the index for us. final current = state.currentIndex; var newIndex = current; if (current != null) { if (index < current) { newIndex = current - 1; } else if (index == current) { // The removed slot now holds the following track. newIndex = current.clamp(0, next.length - 1); } } state = state.copyWith(queue: next, currentIndex: newIndex); _notifyCurrent(); return; } if (index < _windowStart) { // Behind the window: nothing loaded to remove, but every loaded track's // logical index (and the current index) shifts down by one. _windowStart -= 1; final cur = state.currentIndex; state = state.copyWith( queue: next, currentIndex: cur == null ? null : cur - 1); } else if (index < _windowStart + _windowLen) { // Inside the window: drop the matching player source. just_audio adjusts // its own current index and re-emits `currentIndexStream`. state = state.copyWith(queue: next); _windowLen -= 1; await player.removeAudioSourceAt(index - _windowStart); } else { // Ahead of the window, not yet loaded: only the logical queue changes. state = state.copyWith(queue: next); } } /// Move the queue entry from [oldIndex] to [newIndex] (drag-and-drop reorder). /// /// Indices are "clean" post-removal targets — the same convention as /// just_audio's `moveAudioSource` and Flutter's `ReorderableListView` after /// its standard `newIndex -= 1` adjustment (done by the caller). Reorder /// mutates the currently-active order: applied while shuffled, the move sticks /// 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 reorderQueue(int oldIndex, int newIndex) async { final q = state.queue; if (oldIndex < 0 || oldIndex >= q.length) return; if (newIndex < 0 || newIndex >= q.length) return; if (oldIndex == newIndex) return; final next = [...q]; next.insert(newIndex, next.removeAt(oldIndex)); final player = _player; if (player == null) { // Desktop/no-audio: no stream will correct the index for us, so follow // the current track to its new slot by hand. final cur = state.currentIndex; var newCur = cur; if (cur != null) { if (cur == oldIndex) { newCur = newIndex; } else { var c = cur; if (oldIndex < c) c -= 1; if (newIndex <= c) c += 1; newCur = c; } } state = state.copyWith(queue: next, currentIndex: newCur); _notifyCurrent(); return; } // 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. final windowEnd = _windowStart + _windowLen; final bothInWindow = oldIndex >= _windowStart && oldIndex < windowEnd && newIndex >= _windowStart && newIndex < windowEnd; if (bothInWindow) { // Targeted move within the loaded window. state = state.copyWith(queue: next); await player.moveAudioSource( oldIndex - _windowStart, newIndex - _windowStart); } else { // The move crosses the loaded window: recompute where the current track // landed and rebuild the window around it (a one-off re-buffer). final curSong = state.current; var newCur = state.currentIndex ?? 0; if (curSong != null) { final idx = next.indexWhere((s) => identical(s, curSong)); if (idx >= 0) newCur = idx; } final pos = state.position; final wasPlaying = state.playing; state = state.copyWith(queue: next, currentIndex: newCur); await _loadWindow( queue: next, center: newCur, position: pos, play: wasPlaying); } } @override Future togglePlayPause() async { final player = _player; if (player == null) return; if (player.playing) { await player.pause(); } else { await player.play(); } } @override Future next() async => _player?.seekToNext(); @override Future previous() async => _player?.seekToPrevious(); @override Future seek(Duration position) async => _player?.seek(position); /// Toggle shuffle by physically reordering the queue. /// /// just_audio's built-in shuffle only reorders an invisible internal index — /// `state.queue` (and therefore the queue UI) stays in album order, so the /// playing track appears to jump around and `playNext` / `addToQueue` insert /// relative to a position the shuffle ignores. Instead we own the order: /// enabling shuffles only the *upcoming* songs and keeps the current track /// (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 toggleShuffle() async { final enabled = !state.shuffle; final q = state.queue; final cur = state.currentIndex; state = state.copyWith(shuffle: enabled); if (q.isEmpty || cur == null) return; if (enabled) { _unshuffledOrder = [...q]; final head = q.sublist(0, cur + 1); final tail = q.sublist(cur + 1)..shuffle(_random); await _applyOrder([...head, ...tail]); } else { final original = _unshuffledOrder; _unshuffledOrder = null; if (original == null) return; // e.g. a shuffled queue restored on launch. final live = q.toSet(); final originalSet = original.toSet(); final restored = [ for (final s in original) if (live.contains(s)) s, for (final s in q) if (!originalSet.contains(s)) s, ]; await _applyOrder(restored); } } /// Reorder both `state.queue` and the player playlist to match [target]. /// /// [target] must be a permutation of the current queue by object identity. /// We rebuild the player's sources from [target] in one shot (rather than a /// sequence of live moves) so `state.queue` and the player can never drift /// out of alignment — the playing track is reloaded at its current position, /// so at most it re-buffers briefly. `currentIndex` follows the playing track /// to its new slot. Future _applyOrder(List target, {bool forcePlay = false}) async { final cur = state.current; var newIndex = state.currentIndex ?? 0; if (cur != null) { final idx = target.indexWhere((s) => identical(s, cur)); if (idx >= 0) newIndex = idx; } final position = state.position; final wasPlaying = state.playing; state = state.copyWith(queue: target, currentIndex: newIndex); final player = _player; if (player == null) return; // The current song is unchanged, so [_notifyCurrent]'s id dedupe suppresses // a spurious re-scrobble when currentIndexStream re-fires. Only a window // around the current track is (re)loaded, not the whole queue. [_loadWindow] // sets [_rebuilding] so its own re-emission can't re-enter this path. await _loadWindow( queue: target, center: newIndex, position: position, play: wasPlaying || forcePlay, ); } /// Handle a load/decode failure from the player. Deliberately conservative: /// it never seeks to zero or auto-advances (that thrashing IS the reported /// bug). If a local download now exists for the failing track it swaps the /// dead remote source for the local file in place and keeps playing; /// otherwise it pauses and surfaces an offline error rather than letting the /// platform player restart or skip the track. Future _onPlayerError(Object error) async { // Ignore transient hiccups — just_audio recovers from brief buffer // underruns on its own. Only genuine load failures reach recovery. if (!_isFatalLoadError(error)) return; final player = _player; final song = state.current; if (player == null || song == null) return; if (_rebuilding) return; // A local copy may now be available (e.g. a download that finished, or a // track that was always downloaded but got baked as a remote source). final uri = _streamUriFor(song); if (uri != null && uri.isScheme('file')) { try { await _applyOrder([...state.queue], forcePlay: true); return; } catch (_) { // Fall through to the graceful pause below. } } // Nothing local to fall back to: stop the platform's restart/skip recovery // loop and surface the error, leaving the playhead where it is. try { await player.pause(); } catch (_) {} state = state.copyWith(error: PlaybackError.offline); } static bool _isFatalLoadError(Object e) => e is PlayerException || e is PlatformException; /// 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 retry() async { if (state.queue.isEmpty) return; try { await _applyOrder([...state.queue], forcePlay: true); } catch (e) { await _onPlayerError(e); } } /// When the current track advances to one that was loaded as a remote source /// but now has a local download, transparently swap to the local file. Cheap /// no-op in the common case (guarded by [_remoteSourceIds] membership). Future _maybeUpgradeToLocal() async { if (_rebuilding) return; final song = state.current; if (song == null || !_remoteSourceIds.contains(song.id)) return; final uri = _streamUriFor(song); if (uri == null || !uri.isScheme('file')) return; await _applyOrder([...state.queue]); } /// Re-read authoritative position/duration/playing/index from the player. /// /// `positionStream`/`durationStream` can go stale while the app is /// backgrounded or across an audio-session interruption, leaving the playhead /// 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; final pos = player.position; final dur = player.duration ?? state.duration; final playing = player.playing; // player.currentIndex is a window-relative index; map it back to logical. final idx = player.currentIndex != null ? _windowStart + player.currentIndex! : state.currentIndex; // Skip the write (and the save tick it triggers) when nothing changed. if (pos == state.position && dur == state.duration && playing == state.playing && idx == state.currentIndex) { return; } state = state.copyWith( position: pos, duration: dur, playing: playing, currentIndex: idx, ); } /// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with /// single-track repeat). @override Future cycleLoop() async { final nextMode = switch (state.loop) { LoopMode.off => LoopMode.all, LoopMode.all => LoopMode.one, LoopMode.one => LoopMode.off, }; state = state.copyWith(loop: nextMode); await _player?.setLoopMode(nextMode); } // ---- Persistence -------------------------------------------------------- /// Per-server queue snapshot file, alongside the playlists/downloads mirrors. Future _snapshotFile(String key) async { final dir = await getApplicationSupportDirectory(); return File('${dir.path}/queue_$key.json'); } /// Throttle a save: schedule one write per window, capturing the latest state /// when it fires (so continuous position ticks don't hammer the disk). void _scheduleSave() { if (_saveTimer?.isActive ?? false) return; _saveTimer = Timer(const Duration(seconds: 3), _persist); } Future _persist() async { final key = _serverKeyGetter(); // Only persist once the active server's queue has been restored/adopted. if (key == null || key != _restoredKey) return; try { final file = await _snapshotFile(key); final tmp = File('${file.path}.tmp'); await tmp.writeAsString(jsonEncode({ 'queue': state.queue.map((s) => s.toJson()).toList(), 'currentIndex': state.currentIndex, 'positionMs': state.position.inMilliseconds, 'shuffle': state.shuffle, 'loop': state.loop.name, })); await tmp.rename(file.path); // atomic swap } catch (_) { // Best effort — a failed save just means a slightly staler queue. } } /// Restore the saved queue for the active server (on connect / server switch). /// The tracks, shuffle/loop and position are loaded back into the player /// **paused**, so the queue auto-populates without surprising the user with /// sudden playback. Called from the [serverKeyProvider] listener; a no-op once /// the current server has already been restored. Future restoreForServer() async { final key = _serverKeyGetter(); if (key == null || key == _restoredKey) return; Map? snap; try { final file = await _snapshotFile(key); if (await file.exists()) { final raw = jsonDecode(await file.readAsString()); if (raw is Map) snap = raw.cast(); } } catch (_) { // Missing/corrupt snapshot is non-fatal. } // Mark restored *after* reading, so saves for this server are now allowed. _restoredKey = key; // Don't clobber an already-loaded queue (user started playback first). if (state.queue.isNotEmpty || snap == null) return; final songs = (snap['queue'] as List? ?? const []) .whereType() .map((e) => Song.fromJson(e.cast())) .toList(); if (songs.isEmpty) return; final loop = LoopMode.values.firstWhere( (m) => m.name == snap!['loop'], orElse: () => LoopMode.off, ); final shuffle = snap['shuffle'] == true; final savedIndex = (snap['currentIndex'] as int?) ?? 0; final savedPos = Duration(milliseconds: (snap['positionMs'] as int?) ?? 0); final streamable = songs.where((s) => _streamUriFor(s) != null).toList(); if (streamable.isEmpty) { // Offline / nothing streamable: reflect the queue for the UI only. final start = savedIndex.clamp(0, songs.length - 1); _lastNotifiedId = songs[start].id; state = state.copyWith( queue: songs, currentIndex: start, position: savedPos, shuffle: shuffle, loop: loop, ); return; } // Remap the saved index onto the streamable subset (same invariant as // [playSongs]: state.queue == the sources loaded into the player). final target = songs[savedIndex.clamp(0, songs.length - 1)]; final targetIndex = streamable.indexOf(target); final start = targetIndex >= 0 ? targetIndex : savedIndex.clamp(0, streamable.length - 1); // Suppress a spurious scrobble/history entry for the restored (not actually // played) track, but still drive the album-art accent. _lastNotifiedId = streamable[start].id; final art = _coverArtUriFor(streamable[start]); if (art != null) _onArt(art); state = state.copyWith( queue: streamable, currentIndex: start, position: savedPos, shuffle: shuffle, loop: loop, ); final player = _player; if (player == null) return; // desktop: UI-only, already reflected above. // Load only a window around the restored index (see the windowing note by // [_loadWindow]); a large restored queue would otherwise crash on load. // [_loadWindow] seeks to [savedPos] via initialPosition and leaves the // queue paused (play: false). await _loadWindow( queue: streamable, center: start, position: savedPos, play: false, ); // The persisted queue is already stored in play order, so shuffle is a UI // flag only — the player's own shuffle mode stays off (see [toggleShuffle]). await player.setLoopMode(loop); // Intentionally no play() — restore leaves the queue paused. } @override void dispose() { _saveTimer?.cancel(); _player?.dispose(); super.dispose(); } }