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(() => {})
This commit is contained in:
daniel-c-harvey
2026-06-07 09:00:10 -04:00
parent 6dfb3a2f23
commit 5cdd69d7d9
2 changed files with 57 additions and 15 deletions
@@ -32,6 +32,8 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable
private ElementReference _seekerElement;
private IStreamingPlayerService? _subscribedService;
private IJSObjectReference? _jsModule;
private IJSObjectReference? _resizeObserver;
private DotNetObjectReference<WaveformSeeker>? _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<double>("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<IJSObjectReference>("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
@@ -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();
}