fixes to scrubbing and offline issues
This commit is contained in:
parent
5bf85a5f44
commit
d558aba246
31 changed files with 296 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue