diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs index 44eeb25..7568df8 100644 --- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs @@ -92,13 +92,17 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS // track while it's still loading, not only after playback starts. CurrentTrack = track; - // Create new cancellation token for this streaming operation - _streamingCancellation = new CancellationTokenSource(); + // Create new cancellation token for this streaming operation. Capture it in a local + // so the catch/finally can compare identity against _streamingCancellation: a seek + // replaces _streamingCancellation with its own seekCts before this load's continuation + // resumes on the single-threaded WASM dispatcher, and we must not clobber the seek's state. + var loadCts = new CancellationTokenSource(); + _streamingCancellation = loadCts; // Fetch the waveform profile alongside the audio. Fire-and-forget against the same // streaming token so a track switch abandons it; it only updates display state and must // never gate or fail the audio load (a missing profile yields the flat-seekbar fallback). - _ = LoadWaveformProfileAsync(track.EntryKey, _streamingCancellation.Token); + _ = LoadWaveformProfileAsync(track.EntryKey, loadCts.Token); try { @@ -119,7 +123,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS var mediaResult = await _trackMediaClient.GetTrackMedia( track.EntryKey, byteOffset: 0, - cancellationToken: _streamingCancellation.Token); + cancellationToken: loadCts.Token); if (!mediaResult.Success) { var technicalError = mediaResult.GetMessage(); @@ -150,15 +154,24 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS return; } - _activeStreamingTask = StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token); + _activeStreamingTask = StreamAudioWithEarlyPlayback(audio, loadCts.Token); await _activeStreamingTask; } - catch (OperationCanceledException) + catch (OperationCanceledException) when (loadCts.IsCancellationRequested) { - // Cancellation is expected, reset state + // Cancellation is expected when this load was superseded (track switch or seek). + // The when filter ensures HttpClient timeout OCEs — where loadCts was NOT + // cancelled — fall through to the error handler below instead of being swallowed. _logger.LogDebug("Audio streaming cancelled for track {TrackId}", track.EntryKey); - IsLoaded = false; - IsStreamingMode = false; + + // Only reset streaming state if this load is still the active operation. A seek + // in flight has already replaced _streamingCancellation with its own seekCts and + // owns IsLoaded/IsStreamingMode; clobbering them here corrupts the seek mid-flight. + if (ReferenceEquals(_streamingCancellation, loadCts)) + { + IsLoaded = false; + IsStreamingMode = false; + } } catch (Exception ex) { @@ -171,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(); + } } } @@ -429,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 {