using DeepDrftPublic.Client.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.JSInterop; namespace DeepDrftPublic.Client.Controls.AudioPlayerBar; /// /// 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 (--played-fraction), 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. /// 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 OnSeekChange { get; set; } [Parameter] public EventCallback OnSeekEnd { get; set; } [Parameter] public string? Class { get; set; } /// Cap on rendered bars; actual count is min(profile length, this, width-derived). [Parameter] public int MaxBars { get; set; } = 200; /// Minimum bar height as a fraction of full height, so silence stays a visible hairline. private const double HeightFloor = 0.02; 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. private double[] _renderedBars = Array.Empty(); // _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; /// Fraction of the track that reads as "played" — the drag position while seeking, /// otherwise live playback position. Drives the clip overlay and the playhead. 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("import", "./js/waveformSeeker.js"); } if (_jsModule is null) return; if (firstRender) { var width = await _jsModule.InvokeAsync("getWidth", _seekerElement); if (width > 0 && Math.Abs(width - _elementWidth) > 1) { _elementWidth = width; 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); } } /// /// 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. /// 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; } /// Bar + gap pitch in px, matched to SpectrumVisualizer (≈4px min bar + 2px gap). private const double BarPitchPx = 6.0; private async Task HandlePointerDown(PointerEventArgs e) { if (!CanSeek) return; _isSeeking = true; _seekFraction = FractionFromOffset(e.OffsetX); // 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 { await _jsModule.InvokeVoidAsync("capturePointer", _seekerElement, e.PointerId); } catch { // Capture is a UX nicety; gesture still works within element bounds. } } } 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; } } }