Merge branch 'seek-load-race' into dev
This commit is contained in:
@@ -92,13 +92,17 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
// track while it's still loading, not only after playback starts.
|
// track while it's still loading, not only after playback starts.
|
||||||
CurrentTrack = track;
|
CurrentTrack = track;
|
||||||
|
|
||||||
// Create new cancellation token for this streaming operation
|
// Create new cancellation token for this streaming operation. Capture it in a local
|
||||||
_streamingCancellation = new CancellationTokenSource();
|
// 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
|
// 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
|
// 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).
|
// 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
|
try
|
||||||
{
|
{
|
||||||
@@ -119,7 +123,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
var mediaResult = await _trackMediaClient.GetTrackMedia(
|
var mediaResult = await _trackMediaClient.GetTrackMedia(
|
||||||
track.EntryKey,
|
track.EntryKey,
|
||||||
byteOffset: 0,
|
byteOffset: 0,
|
||||||
cancellationToken: _streamingCancellation.Token);
|
cancellationToken: loadCts.Token);
|
||||||
if (!mediaResult.Success)
|
if (!mediaResult.Success)
|
||||||
{
|
{
|
||||||
var technicalError = mediaResult.GetMessage();
|
var technicalError = mediaResult.GetMessage();
|
||||||
@@ -150,16 +154,25 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token);
|
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, loadCts.Token);
|
||||||
await _activeStreamingTask;
|
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);
|
_logger.LogDebug("Audio streaming cancelled for track {TrackId}", track.EntryKey);
|
||||||
|
|
||||||
|
// 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;
|
IsLoaded = false;
|
||||||
IsStreamingMode = false;
|
IsStreamingMode = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StreamingErrorHandler.LogError(_logger, ex, "LoadTrackStreaming", track.EntryKey);
|
StreamingErrorHandler.LogError(_logger, ex, "LoadTrackStreaming", track.EntryKey);
|
||||||
@@ -171,9 +184,14 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
IsLoading = false;
|
IsLoading = false;
|
||||||
|
// 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();
|
await NotifyStateChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches and decodes the track's waveform loudness profile, then notifies state so the
|
/// Fetches and decodes the track's waveform loudness profile, then notifies state so the
|
||||||
@@ -429,11 +447,21 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
// OperationCanceledException asynchronously; if we kick off a new loop
|
// OperationCanceledException asynchronously; if we kick off a new loop
|
||||||
// immediately, both can race against the single-instance JS StreamDecoder
|
// immediately, both can race against the single-instance JS StreamDecoder
|
||||||
// and corrupt decode state. Draining here is the load-bearing guarantee.
|
// and corrupt decode state. Draining here is the load-bearing guarantee.
|
||||||
_streamingCancellation?.Cancel();
|
//
|
||||||
await DrainActiveStreamingTaskAsync();
|
// Invariant: any caller that supersedes a load WITHOUT wanting the load's
|
||||||
_streamingCancellation?.Dispose();
|
// 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();
|
var seekCts = new CancellationTokenSource();
|
||||||
_streamingCancellation = seekCts;
|
_streamingCancellation = seekCts;
|
||||||
|
oldCts?.Cancel();
|
||||||
|
await DrainActiveStreamingTaskAsync();
|
||||||
|
oldCts?.Dispose();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user