diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs index 5f26fcd..5fece45 100644 --- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs @@ -295,15 +295,22 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS { await RecoverFromFailedRefill(CurrentTime, userError); } - else + else if (ReferenceEquals(_streamingCancellation, loadCts)) { - // First-segment failure (nothing buffered / playing yet) or superseded load: the - // normal unload-to-error path is correct — no buffered tail to halt. + // First-segment failure (nothing buffered / playing yet), still the active operation: + // the normal unload-to-error path is correct — nothing is in the scheduler to halt. ErrorMessage = userError; LoadProgress = 0; IsLoaded = false; IsStreamingMode = false; } + else + { + // Superseded load: a newer seek (or track switch) has already claimed _streamingCancellation + // and owns all shared state. Writing IsLoaded/IsStreamingMode here would corrupt the live + // operation — mirror the OCE catch's identity guard and do nothing to shared state. + _logger.LogDebug("Generic throw on superseded load for track {TrackId} — newer operation owns state, skipping unload", track.EntryKey); + } } finally { @@ -879,13 +886,16 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS _logger.LogWarning("Refill-failure recovery interop did not succeed: {Error}", recovered.Error); } - // Settle C# into the matching recoverable state: not playing, paused at the target, still loaded. - // IsLoaded = true is load-bearing — the "paused-but-loaded" contract lets the listener retry - // the seek or pick another track; resetting to unloaded would evict the track identity. + // Settle C# into the matching recoverable state: not playing, paused at the target, still loaded + // and still in streaming mode. IsLoaded = true and IsStreamingMode = true are both load-bearing — + // the "paused-but-loaded" contract lets the listener retry the seek (Seek early-returns when + // !IsLoaded || !IsStreamingMode), resume via TogglePlayPause, or pick another track. Resetting + // either to false would wedge at least one of the three retry routes (AC6 / Phase 21.3). ErrorMessage = userFacingError; IsPlaying = false; IsPaused = true; IsLoaded = true; + IsStreamingMode = true; CurrentTime = seekPosition; IsSeekingBeyondBuffer = false; await NotifyStateChanged(); diff --git a/DeepDrftTests/SegmentedStreamLoopTests.cs b/DeepDrftTests/SegmentedStreamLoopTests.cs index 79c5af0..47978d6 100644 --- a/DeepDrftTests/SegmentedStreamLoopTests.cs +++ b/DeepDrftTests/SegmentedStreamLoopTests.cs @@ -353,6 +353,8 @@ public class SegmentedStreamLoopTests "a truncated segment while cursor < totalLength is a failure: scheduler must be halted via recovery"); Assert.That(player.IsLoaded, Is.True, "recovery leaves the track loaded so the listener can retry — not torn down to unloaded"); + Assert.That(player.IsStreamingMode, Is.True, + "recovery must restore IsStreamingMode=true so Seek() is not wedged (AC6 / Phase 21.3 retry contract)"); Assert.That(player.IsPaused, Is.True, "recovery settles into a paused state, not playing"); Assert.That(player.ErrorMessage, Is.Not.Null.And.Not.Empty, @@ -381,6 +383,8 @@ public class SegmentedStreamLoopTests "a mid-stream fetch failure must halt the scheduler via recovery, not leave it to drain"); Assert.That(player.IsLoaded, Is.True, "recovery leaves the track loaded so the listener can retry — not torn down to unloaded"); + Assert.That(player.IsStreamingMode, Is.True, + "recovery must restore IsStreamingMode=true so Seek() is not wedged (AC6 / Phase 21.3 retry contract)"); Assert.That(player.IsPaused, Is.True, "recovery settles into a paused state, not playing"); Assert.That(player.ErrorMessage, Is.Not.Null.And.Not.Empty,