stream edits
This commit is contained in:
parent
db33f0764b
commit
7a199fe4df
2 changed files with 251 additions and 23 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue