From 5cdd69d7d905f40d28771b4313bb8ce02f255fc7 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 7 Jun 2026 09:00:10 -0400 Subject: [PATCH] fix: WaveformSeeker resize drift and mobile fast-tap crash - Add ResizeObserver (JS observeResize/unobserveResize + C# OnWidthChanged) so _elementWidth stays current after window resize, fixing hover indicator drift - Move _isSeeking = true before capturePointer await so a fast mobile tap that fires pointerup mid-await still commits the seek - Replace all Duration!.Value null-forgiving dereferences with explicit Duration is > 0 guards in all four pointer event handlers - Silence post-dispose resize callback rejections with .catch(() => {}) --- .../AudioPlayerBar/WaveformSeeker.razor.cs | 57 ++++++++++++++----- .../wwwroot/js/waveformSeeker.js | 15 +++++ 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.cs index 77cb1b7..2341c53 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.cs @@ -32,6 +32,8 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable private ElementReference _seekerElement; private IStreamingPlayerService? _subscribedService; private IJSObjectReference? _jsModule; + private IJSObjectReference? _resizeObserver; + private DotNetObjectReference? _dotNetRef; // Bars currently drawn (normalized [0,1] heights). Recomputed only when the source profile // identity or the rendered width changes — not on every seek/progress tick. @@ -108,12 +110,7 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable if (_jsModule is null) return; - // Gate the interop call: only measure width on the first render or when the cached width - // has been reset to zero (e.g. after a track change resets state). Subsequent renders - // driven by position ticks or hover events don't need a width measurement — the element - // size hasn't changed. ResizeObserver integration is deferred; for now first-render-only - // measurement is sufficient since the bar is a fixed-height dock widget. - if (firstRender || _elementWidth == 0) + if (firstRender) { var width = await _jsModule.InvokeAsync("getWidth", _seekerElement); if (width > 0 && Math.Abs(width - _elementWidth) > 1) @@ -122,6 +119,20 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable RebuildBars(); StateHasChanged(); } + + _dotNetRef = DotNetObjectReference.Create(this); + _resizeObserver = await _jsModule.InvokeAsync("observeResize", _seekerElement, _dotNetRef); + } + } + + [JSInvokable] + public void OnWidthChanged(double width) + { + if (width > 0 && Math.Abs(width - _elementWidth) > 1) + { + _elementWidth = width; + RebuildBars(); + InvokeAsync(StateHasChanged); } } @@ -172,6 +183,11 @@ 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. if (_jsModule is not null) { @@ -185,28 +201,28 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable } } - _isSeeking = true; - _seekFraction = FractionFromOffset(e.OffsetX); + if (Duration is not > 0) { _isSeeking = false; return; } await OnSeekStart.InvokeAsync(); - await OnSeekChange.InvokeAsync(_seekFraction * Duration!.Value); + await OnSeekChange.InvokeAsync(_seekFraction * Duration.Value); } private async Task HandlePointerMove(PointerEventArgs e) { if (!CanSeek) return; + if (Duration is not > 0) return; var fraction = FractionFromOffset(e.OffsetX); if (_isSeeking) { _seekFraction = fraction; - await OnSeekChange.InvokeAsync(fraction * Duration!.Value); + await OnSeekChange.InvokeAsync(fraction * Duration.Value); } else { _showHover = true; _hoverFraction = fraction; - _hoverTime = fraction * Duration!.Value; + _hoverTime = fraction * Duration.Value; StateHasChanged(); } } @@ -214,10 +230,11 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable private async Task HandlePointerUp(PointerEventArgs e) { if (!_isSeeking) return; - - _seekFraction = FractionFromOffset(e.OffsetX); _isSeeking = false; - await OnSeekEnd.InvokeAsync(_seekFraction * Duration!.Value); + + if (Duration is not > 0) return; + _seekFraction = FractionFromOffset(e.OffsetX); + await OnSeekEnd.InvokeAsync(_seekFraction * Duration.Value); } private async Task HandlePointerLeave() @@ -227,7 +244,8 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable if (_isSeeking) { _isSeeking = false; - await OnSeekEnd.InvokeAsync(_seekFraction * Duration!.Value); + if (Duration is > 0) + await OnSeekEnd.InvokeAsync(_seekFraction * Duration.Value); } if (_showHover) @@ -261,6 +279,15 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable _subscribedService = null; } + if (_resizeObserver is not null) + { + try { await (_jsModule?.InvokeVoidAsync("unobserveResize", _resizeObserver) ?? ValueTask.CompletedTask); } catch { } + try { await _resizeObserver.DisposeAsync(); } catch { } + _resizeObserver = null; + } + _dotNetRef?.Dispose(); + _dotNetRef = null; + if (_jsModule is not null) { try diff --git a/DeepDrftPublic.Client/wwwroot/js/waveformSeeker.js b/DeepDrftPublic.Client/wwwroot/js/waveformSeeker.js index dbf08e5..0695f8c 100644 --- a/DeepDrftPublic.Client/wwwroot/js/waveformSeeker.js +++ b/DeepDrftPublic.Client/wwwroot/js/waveformSeeker.js @@ -10,3 +10,18 @@ export function capturePointer(element, pointerId) { // setPointerCapture keeps a drag tracking even when the pointer leaves the element bounds. element?.setPointerCapture?.(pointerId); } + +export function observeResize(element, dotNetRef) { + if (!element || typeof ResizeObserver === 'undefined') return null; + const observer = new ResizeObserver(entries => { + for (const entry of entries) { + dotNetRef.invokeMethodAsync('OnWidthChanged', entry.contentRect.width).catch(() => {}); + } + }); + observer.observe(element); + return observer; +} + +export function unobserveResize(observer) { + observer?.disconnect(); +}