major bug fixes

This commit is contained in:
Forrest 2026-08-04 19:29:58 -04:00
parent 9728906556
commit d6144c4483
39 changed files with 483 additions and 53 deletions

View file

@ -199,9 +199,15 @@ class PlaybackController extends StateNotifier<PlaybackState>
final player = _player!;
player.currentIndexStream.listen((i) {
if (i == null) return;
state = state.copyWith(currentIndex: i);
// 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);
@ -243,6 +249,39 @@ class PlaybackController extends StateNotifier<PlaybackState>
/// 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;
@ -342,17 +381,109 @@ class PlaybackController extends StateNotifier<PlaybackState>
return;
}
try {
await player.setAudioSources(
_buildSources(ordered),
initialIndex: start,
// 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,
);
await player.play();
// 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.
@ -368,10 +499,21 @@ class PlaybackController extends StateNotifier<PlaybackState>
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.
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);
}
@ -390,7 +532,16 @@ class PlaybackController extends StateNotifier<PlaybackState>
}
final at = (current + 1).clamp(0, q.length);
state = state.copyWith(queue: [...q]..insert(at, song));
await _player?.insertAudioSource(at, _sourceFor(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.
@ -402,8 +553,16 @@ class PlaybackController extends StateNotifier<PlaybackState>
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]);
await _player?.addAudioSource(_sourceFor(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
@ -418,6 +577,8 @@ class PlaybackController extends StateNotifier<PlaybackState>
final next = [...q]..removeAt(index);
if (next.isEmpty) {
await _player?.clearAudioSources();
_windowStart = 0;
_windowLen = 0;
state = PlaybackState(
shuffle: state.shuffle,
loop: state.loop,
@ -445,8 +606,23 @@ class PlaybackController extends StateNotifier<PlaybackState>
return;
}
state = state.copyWith(queue: next);
await player.removeAudioSourceAt(index);
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).
@ -492,8 +668,31 @@ class PlaybackController extends StateNotifier<PlaybackState>
// 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);
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
@ -577,22 +776,16 @@ class PlaybackController extends StateNotifier<PlaybackState>
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;
}
// 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:
@ -672,7 +865,10 @@ class PlaybackController extends StateNotifier<PlaybackState>
final pos = player.position;
final dur = player.duration ?? state.duration;
final playing = player.playing;
final idx = player.currentIndex ?? state.currentIndex;
// 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 &&
@ -815,14 +1011,19 @@ class PlaybackController extends StateNotifier<PlaybackState>
final player = _player;
if (player == null) return; // desktop: UI-only, already reflected above.
await player.setAudioSources(
_buildSources(streamable),
initialIndex: start,
// 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);
if (savedPos > Duration.zero) await player.seek(savedPos, index: start);
// Intentionally no play() — restore leaves the queue paused.
}