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;
}
}
}