5cdd69d7d9
- 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(() => {})
305 lines
11 KiB
C#
305 lines
11 KiB
C#
using DeepDrftPublic.Client.Services;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.Web;
|
|
using Microsoft.JSInterop;
|
|
|
|
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
|
|
|
/// <summary>
|
|
/// Loudness-waveform seekbar. Renders the current track's profile (off the cascaded player
|
|
/// service) as a high-density bar chart and serves as the seek surface. The played/unplayed
|
|
/// split is a CSS clip overlay (<c>--played-fraction</c>), so seeking never re-renders bars.
|
|
/// Seekability never depends on the profile: with no profile it draws flat floor-height bars
|
|
/// that are still fully seekable.
|
|
/// </summary>
|
|
public partial class WaveformSeeker : ComponentBase, IAsyncDisposable
|
|
{
|
|
[Inject] public required IJSRuntime JS { get; set; }
|
|
|
|
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
|
|
|
[Parameter] public EventCallback OnSeekStart { get; set; }
|
|
[Parameter] public EventCallback<double> OnSeekChange { get; set; }
|
|
[Parameter] public EventCallback<double> OnSeekEnd { get; set; }
|
|
[Parameter] public string? Class { get; set; }
|
|
|
|
/// <summary>Cap on rendered bars; actual count is min(profile length, this, width-derived).</summary>
|
|
[Parameter] public int MaxBars { get; set; } = 200;
|
|
|
|
/// <summary>Minimum bar height as a fraction of full height, so silence stays a visible hairline.</summary>
|
|
private const double HeightFloor = 0.02;
|
|
|
|
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.
|
|
private double[] _renderedBars = Array.Empty<double>();
|
|
// _lastProfileRef: sole tracker for profile-change detection in OnPlayerStateChanged.
|
|
// _lastWidth: sole tracker for width-change detection in OnAfterRenderAsync.
|
|
// Kept separate so a width rebuild cannot mask a pending profile change.
|
|
private double[]? _lastProfileRef;
|
|
private double _elementWidth;
|
|
|
|
// Seek/hover gesture state.
|
|
private bool _isSeeking;
|
|
private double _seekFraction;
|
|
private bool _showHover;
|
|
private double _hoverFraction;
|
|
private double _hoverTime;
|
|
private string HoverPercent => $"{_hoverFraction * 100.0}%";
|
|
|
|
private double? Duration => PlayerService?.Duration;
|
|
private bool CanSeek => (PlayerService?.IsLoaded ?? false) && Duration is > 0;
|
|
|
|
/// <summary>Fraction of the track that reads as "played" — the drag position while seeking,
|
|
/// otherwise live playback position. Drives the clip overlay and the playhead.</summary>
|
|
private double PlayedFraction
|
|
{
|
|
get
|
|
{
|
|
if (_isSeeking) return _seekFraction;
|
|
if (PlayerService is null || Duration is not > 0) return 0;
|
|
return Math.Clamp(PlayerService.CurrentTime / Duration.Value, 0, 1);
|
|
}
|
|
}
|
|
|
|
protected override void OnInitialized()
|
|
{
|
|
// Seed flat fallback bars so the control reads as a seekbar on the very first paint,
|
|
// before the width is measured (OnAfterRender) or a profile arrives (StateChanged).
|
|
RebuildBars();
|
|
}
|
|
|
|
protected override void OnParametersSet()
|
|
{
|
|
// The cascade is IsFixed, so subscribe to the multicast side-channel to re-render when
|
|
// playback position or the fetched profile changes — same pattern as SpectrumVisualizer.
|
|
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
|
|
{
|
|
if (_subscribedService != null)
|
|
_subscribedService.StateChanged -= OnPlayerStateChanged;
|
|
|
|
PlayerService.StateChanged += OnPlayerStateChanged;
|
|
_subscribedService = PlayerService;
|
|
}
|
|
}
|
|
|
|
private void OnPlayerStateChanged() => InvokeAsync(() =>
|
|
{
|
|
// Rebuild bars only if the profile reference changed; position ticks just re-render the
|
|
// clip overlay (a CSS var), which StateHasChanged already covers.
|
|
var profile = PlayerService?.WaveformProfile;
|
|
if (!ReferenceEquals(profile, _lastProfileRef))
|
|
{
|
|
_lastProfileRef = profile;
|
|
RebuildBars();
|
|
}
|
|
StateHasChanged();
|
|
});
|
|
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
if (firstRender)
|
|
{
|
|
_jsModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/waveformSeeker.js");
|
|
}
|
|
|
|
if (_jsModule is null) return;
|
|
|
|
if (firstRender)
|
|
{
|
|
var width = await _jsModule.InvokeAsync<double>("getWidth", _seekerElement);
|
|
if (width > 0 && Math.Abs(width - _elementWidth) > 1)
|
|
{
|
|
_elementWidth = width;
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recomputes the drawn bar set: count derived from available width (capped by MaxBars and the
|
|
/// source length), heights downsampled from the profile by peak. With no profile, emits flat
|
|
/// floor-height bars so the control still reads as — and behaves as — a seekbar.
|
|
/// </summary>
|
|
private void RebuildBars()
|
|
{
|
|
var profile = PlayerService?.WaveformProfile;
|
|
|
|
var widthBars = _elementWidth > 0 ? (int)(_elementWidth / BarPitchPx) : MaxBars;
|
|
var barCount = Math.Clamp(widthBars, 1, MaxBars);
|
|
|
|
if (profile is null || profile.Length == 0)
|
|
{
|
|
// Flat fallback — floor-height bars, fully seekable.
|
|
var flat = new double[barCount];
|
|
Array.Fill(flat, HeightFloor);
|
|
_renderedBars = flat;
|
|
return;
|
|
}
|
|
|
|
barCount = Math.Min(barCount, profile.Length);
|
|
var bars = new double[barCount];
|
|
for (var i = 0; i < barCount; i++)
|
|
{
|
|
// Source range [start, end) for this rendered bar; peak (max) over it for the punchy look.
|
|
var start = (int)((long)i * profile.Length / barCount);
|
|
var end = (int)((long)(i + 1) * profile.Length / barCount);
|
|
if (end <= start) end = start + 1;
|
|
|
|
var peak = 0.0;
|
|
for (var j = start; j < end && j < profile.Length; j++)
|
|
{
|
|
if (profile[j] > peak) peak = profile[j];
|
|
}
|
|
bars[i] = Math.Max(HeightFloor, peak);
|
|
}
|
|
_renderedBars = bars;
|
|
}
|
|
|
|
/// <summary>Bar + gap pitch in px, matched to SpectrumVisualizer (≈4px min bar + 2px gap).</summary>
|
|
private const double BarPitchPx = 6.0;
|
|
|
|
private async Task HandlePointerDown(PointerEventArgs e)
|
|
{
|
|
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)
|
|
{
|
|
try
|
|
{
|
|
await _jsModule.InvokeVoidAsync("capturePointer", _seekerElement, e.PointerId);
|
|
}
|
|
catch
|
|
{
|
|
// Capture is a UX nicety; if it fails the gesture still works within the 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)
|
|
{
|
|
if (!CanSeek) return;
|
|
if (Duration is not > 0) return;
|
|
|
|
var fraction = FractionFromOffset(e.OffsetX);
|
|
|
|
if (_isSeeking)
|
|
{
|
|
_seekFraction = fraction;
|
|
await OnSeekChange.InvokeAsync(fraction * Duration.Value);
|
|
}
|
|
else
|
|
{
|
|
_showHover = true;
|
|
_hoverFraction = fraction;
|
|
_hoverTime = fraction * Duration.Value;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private async Task HandlePointerUp(PointerEventArgs e)
|
|
{
|
|
if (!_isSeeking) return;
|
|
_isSeeking = false;
|
|
|
|
if (Duration is not > 0) return;
|
|
_seekFraction = FractionFromOffset(e.OffsetX);
|
|
await OnSeekEnd.InvokeAsync(_seekFraction * Duration.Value);
|
|
}
|
|
|
|
private async Task HandlePointerLeave()
|
|
{
|
|
// Pointer capture keeps a drag alive past the edge, so a leave during seeking means the
|
|
// gesture genuinely ended without a pointerup (rare). Commit at the last known position.
|
|
if (_isSeeking)
|
|
{
|
|
_isSeeking = false;
|
|
if (Duration is > 0)
|
|
await OnSeekEnd.InvokeAsync(_seekFraction * Duration.Value);
|
|
}
|
|
|
|
if (_showHover)
|
|
{
|
|
_showHover = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private double FractionFromOffset(double offsetX)
|
|
{
|
|
if (_elementWidth <= 0) return 0;
|
|
return Math.Clamp(offsetX / _elementWidth, 0, 1);
|
|
}
|
|
|
|
private static string FormatHeight(double normalized) =>
|
|
$"{(normalized * 100).ToString("F1", System.Globalization.CultureInfo.InvariantCulture)}%";
|
|
|
|
private static string FormatTime(double seconds)
|
|
{
|
|
if (double.IsNaN(seconds) || seconds < 0) seconds = 0;
|
|
var ts = TimeSpan.FromSeconds(seconds);
|
|
return $"{(int)ts.TotalMinutes}:{ts.Seconds:D2}";
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_subscribedService != null)
|
|
{
|
|
_subscribedService.StateChanged -= OnPlayerStateChanged;
|
|
_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
|
|
{
|
|
await _jsModule.DisposeAsync();
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
// The circuit/runtime is already gone (navigation/teardown); nothing to release.
|
|
}
|
|
_jsModule = null;
|
|
}
|
|
}
|
|
}
|