mobile-music/lib/playback/playback_engine.dart
2026-08-04 16:39:18 -04:00

827 lines
30 KiB
Dart

// 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<Song> 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<Song>? 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<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>
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,
}) : _streamUriFor = streamUriFor,
_coverArtUriFor = coverArtUriFor,
_serverKeyGetter = serverKeyGetter,
_onArt = onArt,
_onPlay = onPlay,
super(PlaybackState(supported: _audioSupported)) {
if (_audioSupported) {
_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;
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<Song>? _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;
state = state.copyWith(currentIndex: i);
_notifyCurrent();
_maybeUpgradeToLocal();
});
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<String> _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;
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<AudioSource> _buildSources(List<Song> 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<void> playSongs(List<Song> 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 {
await player.setAudioSources(
_buildSources(ordered),
initialIndex: start,
);
await player.play();
// currentIndexStream fires _notifyCurrent for the started track.
} catch (e) {
await _onPlayerError(e);
}
}
/// 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;
final player = _player;
if (player == null) {
state = state.copyWith(
currentIndex: index, position: Duration.zero, clearError: true);
_notifyCurrent();
return;
}
try {
await player.seek(Duration.zero, index: index);
await player.play();
if (state.error != null) state = state.copyWith(clearError: true);
// currentIndexStream fires _notifyCurrent for the jumped-to track.
} 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<void> 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));
await _player?.insertAudioSource(at, _sourceFor(song));
}
/// Append [song] to the end of the queue.
@override
Future<void> addToQueue(Song song) async {
if (_streamUriFor(song) == null) return;
final q = state.queue;
if (q.isEmpty || state.currentIndex == null) {
await playSongs([song]);
return;
}
state = state.copyWith(queue: [...q, song]);
await _player?.addAudioSource(_sourceFor(song));
}
/// 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<void> 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();
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;
}
state = state.copyWith(queue: next);
await player.removeAudioSourceAt(index);
}
/// 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<void> 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.
state = state.copyWith(queue: next);
await player.moveAudioSource(oldIndex, newIndex);
}
@override
Future<void> togglePlayPause() async {
final player = _player;
if (player == null) return;
if (player.playing) {
await player.pause();
} else {
await player.play();
}
}
@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.
///
/// 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<void> 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 = <Song>[
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<void> _applyOrder(List<Song> 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;
// Guard the rebuild so the `currentIndexStream` re-emission it triggers
// can't re-enter [_maybeUpgradeToLocal] and rebuild again.
_rebuilding = true;
try {
// The current song is unchanged, so [_notifyCurrent]'s id dedupe
// suppresses a spurious re-scrobble when currentIndexStream re-fires.
await player.setAudioSources(
_buildSources(target),
initialIndex: newIndex,
initialPosition: position,
);
if (wasPlaying || forcePlay) await player.play();
if (state.error != null) state = state.copyWith(clearError: true);
} finally {
_rebuilding = false;
}
}
/// 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<void> _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<void> 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<void> _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;
final idx = 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<void> 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<File> _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<void> _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<void> restoreForServer() async {
final key = _serverKeyGetter();
if (key == null || key == _restoredKey) return;
Map<String, dynamic>? snap;
try {
final file = await _snapshotFile(key);
if (await file.exists()) {
final raw = jsonDecode(await file.readAsString());
if (raw is Map) snap = raw.cast<String, dynamic>();
}
} 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>()
.map((e) => Song.fromJson(e.cast<String, dynamic>()))
.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.
await player.setAudioSources(
_buildSources(streamable),
initialIndex: start,
);
// 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);
if (savedPos > Duration.zero) await player.seek(savedPos, index: start);
// Intentionally no play() — restore leaves the queue paused.
}
@override
void dispose() {
_saveTimer?.cancel();
_player?.dispose();
super.dispose();
}
}