major bug fixes
This commit is contained in:
parent
9728906556
commit
d6144c4483
39 changed files with 483 additions and 53 deletions
|
|
@ -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.
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue