fix(audio): guard underrun/stream-complete against false end-of-playback

pause() clears underrun_ so setStreamComplete can't fire TrackEnded while paused; resetToStart() resets streamComplete. Prior fix: underrun_ park + streamComplete discriminator prevent the Opus-startup false-end. Tests: 18 PlaybackScheduler cases including pause-during-underrun and underrun->resume->genuine-end-once.
This commit is contained in:
daniel-c-harvey
2026-06-25 15:16:22 -04:00
parent 3aed5c129f
commit 67422e922d
5 changed files with 340 additions and 14 deletions
@@ -186,6 +186,10 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
var result = await _audioInterop.UnloadAsync(PlayerId);
if (result.Success)
{
// [RELOAD-DIAG] One of two base-class sites that null Duration (the other is
// OnPlaybackEndCallback). Logged so a run can attribute a "Duration set from header"
// re-fire to this path vs the spurious end-callback. Trivially removable.
OnDurationNulledDiag("Unload");
IsPlaying = false;
IsPaused = false;
CurrentTime = 0;
@@ -278,6 +282,12 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
private async Task OnPlaybackEndCallback()
{
// [RELOAD-DIAG] The second base-class Duration-null site — the JS PlaybackScheduler's
// end-of-playback callback. A false (mid-stream) fire here is the Opus-startup bug: it nulls
// Duration (forcing a second "Duration set from header"), sets IsLoaded=false/CurrentTime=0,
// and raises TrackEnded (premature queue auto-advance). After the scheduler fix this must fire
// only on genuine end-of-track. Trivially removable.
OnDurationNulledDiag("OnPlaybackEndCallback");
IsPlaying = false;
IsPaused = false;
IsLoaded = false;
@@ -308,6 +318,14 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
/// </summary>
protected virtual void OnPlaybackEnded() { }
/// <summary>
/// [RELOAD-DIAG] Diagnostic seam — invoked at each base-class site that nulls <see cref="Duration"/>
/// (<see cref="Unload"/> and <see cref="OnPlaybackEndCallback"/>), naming the caller. The streaming
/// subclass overrides this to emit a tagged log via its logger so a run can attribute a re-fired
/// "Duration set from header" to its true cause. No-op in the base; trivially removable.
/// </summary>
protected virtual void OnDurationNulledDiag(string caller) { }
protected async Task EnsureInitializedAsync()
{
@@ -138,6 +138,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// Organic end-of-stream closes the session; the bucket reflects the high-water fraction reached.
protected override void OnPlaybackEnded() => _playTracker?.Close();
// [RELOAD-DIAG] Emit the tagged log at each base-class Duration-null site so a run unambiguously
// shows which path nulled Duration between two "Duration set from header" lines. Trivially removable.
protected override void OnDurationNulledDiag(string caller) =>
_logger.LogInformation(
"[RELOAD-DIAG] Base nulling Duration caller={Caller} (gen={Gen})", caller, _loadGeneration);
public override async Task SelectTrack(TrackDto track)
{
await SelectTrackStreaming(track);