1112 lines
43 KiB
Dart
1112 lines
43 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.buffering = 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;
|
|
|
|
/// True while the player is loading/buffering a source (not yet `ready`). A
|
|
/// streamed source that is buffering legitimately reports position 0; this
|
|
/// lets the UI show a spinner instead of a frozen 0:00 bar. Transient — never
|
|
/// persisted.
|
|
final bool buffering;
|
|
|
|
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,
|
|
bool? buffering,
|
|
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,
|
|
buffering: buffering ?? this.buffering,
|
|
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,
|
|
@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<Song>? _unshuffledOrder;
|
|
|
|
/// Coalesces rapid state changes (a position tick every second) into at most
|
|
/// one disk write per window.
|
|
Timer? _saveTimer;
|
|
|
|
// ---- Position interpolation -----------------------------------------
|
|
//
|
|
// just_audio's `positionStream`/`position` getter clamps the playing position
|
|
// to the reported duration; on iOS an unknown-length stream reports
|
|
// `duration == Duration.zero` (not null), so the clamp pins the playhead to
|
|
// 0:00 while playing (paused reads the raw value — hence "0:00 playing,
|
|
// correct when paused"). We sidestep the clamp entirely by anchoring on the
|
|
// raw, unclamped `updatePosition` from `playbackEventStream` and advancing it
|
|
// ourselves against the wall clock while actually playing.
|
|
|
|
/// Last unclamped position reported by the platform, and the wall-clock time
|
|
/// it was sampled (`PlaybackEvent.updateTime`).
|
|
Duration _posAnchor = Duration.zero;
|
|
DateTime _posAnchorAt = DateTime.fromMillisecondsSinceEpoch(0);
|
|
|
|
/// Ticks the interpolated position forward while playing.
|
|
Timer? _positionTicker;
|
|
|
|
/// 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 ||
|
|
Platform.isLinux ||
|
|
Platform.isWindows);
|
|
|
|
/// 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) {
|
|
final ps = s.processingState;
|
|
state = state.copyWith(
|
|
playing: s.playing,
|
|
buffering: ps == ProcessingState.loading ||
|
|
ps == ProcessingState.buffering,
|
|
);
|
|
});
|
|
player.durationStream.listen((d) {
|
|
if (d != null) state = state.copyWith(duration: d);
|
|
});
|
|
// The event stream carries the raw, unclamped `updatePosition`; anchor on it
|
|
// (and re-anchor on every seek / pause / track change) and reflect it
|
|
// immediately so paused/seeked positions are exact. Steady-state advancing
|
|
// is done by the ticker below. We also handle load/decode failures here:
|
|
// without a handler the platform player runs its own recovery — restarting
|
|
// the item at 0 or auto-advancing — the reported "scrub back / skip" bug.
|
|
player.playbackEventStream.listen(
|
|
(event) {
|
|
_posAnchor = event.updatePosition;
|
|
_posAnchorAt = event.updateTime;
|
|
state = state.copyWith(position: event.updatePosition);
|
|
},
|
|
onError: (Object e, StackTrace st) => _onPlayerError(e),
|
|
);
|
|
_positionTicker?.cancel();
|
|
_positionTicker = Timer.periodic(
|
|
const Duration(milliseconds: 200),
|
|
(_) => _tickPosition(),
|
|
);
|
|
}
|
|
|
|
/// Advances the displayed position off [_posAnchor] against the wall clock,
|
|
/// bypassing just_audio's duration-zero clamp. Only runs while genuinely
|
|
/// playing (not buffering/stalled) so the playhead never drifts ahead of the
|
|
/// audio; clamped to [PlaybackState.effectiveDuration] (which falls back to
|
|
/// the Subsonic metadata length) so it can't run past the end.
|
|
void _tickPosition() {
|
|
if (!state.playing || state.buffering) return;
|
|
final elapsed = DateTime.now().difference(_posAnchorAt);
|
|
if (elapsed.isNegative) return;
|
|
var pos = _posAnchor + elapsed;
|
|
final total = state.effectiveDuration;
|
|
if (total > Duration.zero && pos > total) pos = total;
|
|
state = state.copyWith(position: pos);
|
|
}
|
|
|
|
/// 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;
|
|
|
|
// ---- 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<AudioSource> _buildSources(List<Song> songs) {
|
|
_remoteSourceIds.clear();
|
|
return songs.map(_sourceFor).toList();
|
|
}
|
|
|
|
AudioSource _sourceFor(Song song) {
|
|
final uri = _streamUriFor(song)!;
|
|
final isRemote = !uri.isScheme('file');
|
|
if (isRemote) _remoteSourceIds.add(song.id);
|
|
final art = _coverArtUriFor(song);
|
|
final 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,
|
|
);
|
|
// Remote streams go straight to the native player (AVPlayer / ExoPlayer),
|
|
// which fetches the origin directly via its own networking stack. We
|
|
// deliberately do NOT wrap in LockCachingAudioSource nor set a player
|
|
// userAgent: both route the fetch through just_audio's localhost proxy,
|
|
// whose bare dart:io HttpClient sends a default User-Agent and demands an
|
|
// exact HTTP 200 — off-LAN edges (reverse proxy / WAF) reject or redirect
|
|
// that, breaking streaming while dio downloads still work. The proxy also
|
|
// hides the stream's Content-Length, which leaves the native duration
|
|
// indefinite and freezes the playhead (see the _wireStreams ticker).
|
|
return AudioSource.uri(uri, tag: tag);
|
|
}
|
|
|
|
/// 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 {
|
|
// 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<void> _loadWindow({
|
|
required List<Song> 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<void> _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<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 {
|
|
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<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));
|
|
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<void> 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<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();
|
|
_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<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.
|
|
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<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;
|
|
|
|
// 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<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 playing = player.playing;
|
|
// `player.position` is clamped to the reported duration, which is
|
|
// `Duration.zero` for unknown-length streams and would snap the playhead to
|
|
// 0 while playing. Use our unclamped anchor when playing; the raw getter is
|
|
// correct when paused.
|
|
final pos = playing
|
|
? _posAnchor + DateTime.now().difference(_posAnchorAt)
|
|
: player.position;
|
|
final dur = player.duration ?? state.duration;
|
|
// 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<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.
|
|
// 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();
|
|
_positionTicker?.cancel();
|
|
_player?.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|