stream edits

This commit is contained in:
Forrest 2026-08-13 13:40:42 -04:00
parent db33f0764b
commit 7a199fe4df
2 changed files with 251 additions and 23 deletions

View file

@ -193,6 +193,24 @@ class PlaybackController extends StateNotifier<PlaybackState>
/// 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;
@ -232,20 +250,43 @@ class PlaybackController extends StateNotifier<PlaybackState>
ps == ProcessingState.buffering,
);
});
player.positionStream.listen((p) {
state = state.copyWith(position: p);
});
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.
// 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
@ -333,19 +374,15 @@ class PlaybackController extends StateNotifier<PlaybackState>
song.duration != null ? Duration(seconds: song.duration!) : null,
artUri: art,
);
// Wrap remote streams in a caching source on mobile: it fetches bytes to an
// OS-evictable temp file, giving the native player a genuinely seekable
// source with a known length — robust against chunked transcodes that omit
// Content-Length and against brief network drops. Downloads (file://) are
// already seekable, and the media_kit desktop backend uses just_audio's
// localhost proxy path we don't rely on, so both stay on the plain source.
if (isRemote && (Platform.isAndroid || Platform.isIOS)) {
// LockCachingAudioSource is marked experimental in just_audio but is
// stable in practice; the streaming reliability it provides is the whole
// point of this path.
// ignore: experimental_member_use
return LockCachingAudioSource(uri, tag: tag);
}
// 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);
}
@ -894,9 +931,15 @@ class PlaybackController extends StateNotifier<PlaybackState>
void resyncFromPlayer() {
final player = _player;
if (player == null) return;
final pos = player.position;
final dur = player.duration ?? state.duration;
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!
@ -1062,6 +1105,7 @@ class PlaybackController extends StateNotifier<PlaybackState>
@override
void dispose() {
_saveTimer?.cancel();
_positionTicker?.cancel();
_player?.dispose();
super.dispose();
}