fixes to scrubbing and offline issues

This commit is contained in:
Forrest 2026-07-31 21:06:19 -04:00
parent 5bf85a5f44
commit d558aba246
31 changed files with 296 additions and 33 deletions

View file

@ -8,6 +8,7 @@ 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';
@ -15,6 +16,15 @@ 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 {
@ -27,6 +37,7 @@ class PlaybackState {
this.shuffle = false,
this.loop = LoopMode.off,
this.supported = true,
this.error,
});
final List<Song> queue;
@ -37,6 +48,10 @@ class PlaybackState {
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.
@ -47,8 +62,19 @@ class PlaybackState {
? 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 = duration.inMilliseconds;
final total = effectiveDuration.inMilliseconds;
if (total <= 0) return 0;
return (position.inMilliseconds / total).clamp(0.0, 1.0);
}
@ -62,6 +88,8 @@ class PlaybackState {
bool? shuffle,
LoopMode? loop,
bool? supported,
PlaybackError? error,
bool clearError = false,
}) {
return PlaybackState(
queue: queue ?? this.queue,
@ -72,6 +100,7 @@ class PlaybackState {
shuffle: shuffle ?? this.shuffle,
loop: loop ?? this.loop,
supported: supported ?? this.supported,
error: clearError ? null : (error ?? this.error),
);
}
}
@ -134,6 +163,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
if (i == null) return;
state = state.copyWith(currentIndex: i);
_notifyCurrent();
_maybeUpgradeToLocal();
});
player.playerStateStream.listen((s) {
state = state.copyWith(playing: s.playing);
@ -144,6 +174,14 @@ class PlaybackController extends StateNotifier<PlaybackState> {
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
@ -157,6 +195,16 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// 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;
@ -167,8 +215,16 @@ class PlaybackController extends StateNotifier<PlaybackState> {
_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,
@ -201,6 +257,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
queue: songs,
currentIndex: startIndex.clamp(0, songs.length - 1),
position: Duration.zero,
clearError: true,
);
_lastNotifiedId = null;
_notifyCurrent();
@ -233,6 +290,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
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;
@ -244,12 +302,16 @@ class PlaybackController extends StateNotifier<PlaybackState> {
_notifyCurrent();
return;
}
await player.setAudioSources(
ordered.map(_sourceFor).toList(),
initialIndex: start,
);
await player.play();
// currentIndexStream fires _notifyCurrent for the started track.
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 —
@ -260,13 +322,19 @@ class PlaybackController extends StateNotifier<PlaybackState> {
if (index < 0 || index >= q.length) return;
final player = _player;
if (player == null) {
state = state.copyWith(currentIndex: index, position: Duration.zero);
state = state.copyWith(
currentIndex: index, position: Duration.zero, clearError: true);
_notifyCurrent();
return;
}
await player.seek(Duration.zero, index: index);
await player.play();
// currentIndexStream fires _notifyCurrent for the jumped-to track.
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").
@ -399,7 +467,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
/// 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) async {
Future<void> _applyOrder(List<Song> target, {bool forcePlay = false}) async {
final cur = state.current;
var newIndex = state.currentIndex ?? 0;
if (cur != null) {
@ -414,14 +482,113 @@ class PlaybackController extends StateNotifier<PlaybackState> {
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 for the rebuild.
await player.setAudioSources(
target.map(_sourceFor).toList(),
initialIndex: newIndex,
initialPosition: position,
// 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.
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.
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,
);
if (wasPlaying) await player.play();
}
/// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with
@ -551,7 +718,7 @@ class PlaybackController extends StateNotifier<PlaybackState> {
final player = _player;
if (player == null) return; // desktop: UI-only, already reflected above.
await player.setAudioSources(
streamable.map(_sourceFor).toList(),
_buildSources(streamable),
initialIndex: start,
);
// The persisted queue is already stored in play order, so shuffle is a UI