diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.cs index 2341c53..753b161 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.cs @@ -183,12 +183,20 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable { if (!CanSeek) return; - // Set seeking state BEFORE the async capturePointer so a fast mobile tap that fires - // pointerup before capturePointer returns doesn't miss the commit. _isSeeking = true; _seekFraction = FractionFromOffset(e.OffsetX); - // Capture the pointer so a drag that leaves the element keeps tracking until release. + // Fire seek-start notifications BEFORE awaiting capturePointer. In Blazor WASM a JS + // interop await yields to the browser event loop. A fast click can fire pointerup + // during that window: HandlePointerUp runs (OnSeekEnd, _isSeeking = false), then + // HandlePointerDown resumes and calls OnSeekStart (_isSeeking stuck true, display + // frozen). Notifying first ensures the ordering is always Start → End, never End → Start. + if (Duration is not > 0) { _isSeeking = false; return; } + await OnSeekStart.InvokeAsync(); + await OnSeekChange.InvokeAsync(_seekFraction * Duration.Value); + + // Capture AFTER seek-start is notified so a fast pointerup cannot reorder + // OnSeekEnd before OnSeekStart in AudioPlayerBar. if (_jsModule is not null) { try @@ -197,13 +205,9 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable } catch { - // Capture is a UX nicety; if it fails the gesture still works within the element bounds. + // Capture is a UX nicety; gesture still works within element bounds. } } - - if (Duration is not > 0) { _isSeeking = false; return; } - await OnSeekStart.InvokeAsync(); - await OnSeekChange.InvokeAsync(_seekFraction * Duration.Value); } private async Task HandlePointerMove(PointerEventArgs e) diff --git a/DeepDrftPublic/Interop/audio/AudioPlayer.ts b/DeepDrftPublic/Interop/audio/AudioPlayer.ts index 8e2dbf8..22d8c8a 100644 --- a/DeepDrftPublic/Interop/audio/AudioPlayer.ts +++ b/DeepDrftPublic/Interop/audio/AudioPlayer.ts @@ -274,14 +274,18 @@ export class AudioPlayer { return { success: false, error: 'Invalid seek position' }; } - // Get buffered duration (accounting for playback offset) - const bufferedDuration = this.scheduler.getTotalDuration() + this.scheduler.getPlaybackOffset(); + const bufferStart = this.scheduler.getPlaybackOffset(); + const bufferEnd = this.scheduler.getTotalDuration() + bufferStart; - // Check if seeking within buffered content - if (position <= bufferedDuration) { + // Position must be within [bufferStart, bufferEnd] to use buffered content. + // A lower-bound check is required: after a seek-beyond-buffer, bufferStart is + // set to the prior seek position. Seeking to a position below bufferStart would + // produce a negative bufferRelativePosition in seekWithinBuffer, silently + // clamping to position 0 of the offset buffer instead of the requested time. + if (position >= bufferStart && position <= bufferEnd) { return this.seekWithinBuffer(position); } else { - // Seeking beyond buffer - signal C# to fetch new stream + // Seeking outside buffered window - signal C# to fetch new stream return this.seekBeyondBuffer(position); } }