diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs index 8ccd5e3..7568df8 100644 --- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs @@ -184,7 +184,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS finally { IsLoading = false; - await NotifyStateChanged(); + // Only notify if this load is still the active operation. A superseding seek + // owns state notifications; firing here mid-seek would push a stale snapshot. + if (ReferenceEquals(_streamingCancellation, loadCts)) + { + await NotifyStateChanged(); + } } } @@ -442,11 +447,21 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS // OperationCanceledException asynchronously; if we kick off a new loop // immediately, both can race against the single-instance JS StreamDecoder // and corrupt decode state. Draining here is the load-bearing guarantee. - _streamingCancellation?.Cancel(); - await DrainActiveStreamingTaskAsync(); - _streamingCancellation?.Dispose(); + // + // Invariant: any caller that supersedes a load WITHOUT wanting the load's + // state reset must assign its own CTS to _streamingCancellation *before* + // its first await. LoadTrackStreaming's OCE continuation fires during the + // drain await on the shared _activeStreamingTask; it resets IsLoaded/ + // IsStreamingMode only when _streamingCancellation still equals its loadCts. + // Assigning seekCts synchronously here makes that identity check fail, so + // the seek's state survives. (ResetToIdle deliberately does NOT do this — + // it wants the reset, and nulls _streamingCancellation only after the drain.) + var oldCts = _streamingCancellation; var seekCts = new CancellationTokenSource(); _streamingCancellation = seekCts; + oldCts?.Cancel(); + await DrainActiveStreamingTaskAsync(); + oldCts?.Dispose(); try {