Replace MudSlider seekbar with WaveformSeeker loudness-waveform control
DOM bar chart with clip-overlay progress split; pointer-capture drag; WaveformProfile fetched on load (fire-and-forget, cancellable); flat fallback when no profile; small lazily-loaded waveformSeeker.js for getBoundingClientRect and setPointerCapture.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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;
|
||||
|
||||
// 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 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;
|
||||
|
||||
// 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)
|
||||
{
|
||||
var width = await _jsModule.InvokeAsync<double>("getWidth", _seekerElement);
|
||||
if (width > 0 && Math.Abs(width - _elementWidth) > 1)
|
||||
{
|
||||
_elementWidth = width;
|
||||
RebuildBars();
|
||||
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;
|
||||
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
_isSeeking = true;
|
||||
_seekFraction = FractionFromOffset(e.OffsetX);
|
||||
await OnSeekStart.InvokeAsync();
|
||||
await OnSeekChange.InvokeAsync(_seekFraction * Duration!.Value);
|
||||
}
|
||||
|
||||
private async Task HandlePointerMove(PointerEventArgs e)
|
||||
{
|
||||
if (!CanSeek) 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;
|
||||
|
||||
_seekFraction = FractionFromOffset(e.OffsetX);
|
||||
_isSeeking = false;
|
||||
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;
|
||||
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 (_jsModule is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _jsModule.DisposeAsync();
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// The circuit/runtime is already gone (navigation/teardown); nothing to release.
|
||||
}
|
||||
_jsModule = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user