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:
+4
-1
@@ -311,4 +311,7 @@ __pycache__/
|
|||||||
Database/Vaults/*
|
Database/Vaults/*
|
||||||
|
|
||||||
# TypeScript output
|
# TypeScript output
|
||||||
**/wwwroot/js/*
|
**/wwwroot/js/*
|
||||||
|
# ...except hand-authored client JS modules (not TS compile output).
|
||||||
|
!DeepDrftPublic.Client/wwwroot/js/
|
||||||
|
!DeepDrftPublic.Client/wwwroot/js/*.js
|
||||||
@@ -26,7 +26,6 @@ else
|
|||||||
|
|
||||||
<PlayerSeekZone DisplayTime="DisplayTime"
|
<PlayerSeekZone DisplayTime="DisplayTime"
|
||||||
Duration="Duration"
|
Duration="Duration"
|
||||||
CanSeek="CanSeek"
|
|
||||||
OnSeekStart="@OnSeekStart"
|
OnSeekStart="@OnSeekStart"
|
||||||
OnSeekEnd="@OnSeekEnd"
|
OnSeekEnd="@OnSeekEnd"
|
||||||
OnSeekChange="@OnSeekChange"
|
OnSeekChange="@OnSeekChange"
|
||||||
|
|||||||
@@ -29,12 +29,6 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private double DisplayTime => _isSeeking ? _seekPosition : (PlayerService?.CurrentTime ?? 0);
|
private double DisplayTime => _isSeeking ? _seekPosition : (PlayerService?.CurrentTime ?? 0);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Seek is enabled once track is loaded AND duration is known (from WAV header).
|
|
||||||
/// This allows seeking even during streaming, including seeking beyond buffered content.
|
|
||||||
/// </summary>
|
|
||||||
private bool CanSeek => IsLoaded && Duration.HasValue && Duration.Value > 0;
|
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
// PlayerService is cascaded by AudioPlayerProvider; once it arrives,
|
// PlayerService is cascaded by AudioPlayerProvider; once it arrives,
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
|
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||||
|
|
||||||
<MudStack Row="false" Spacing="0" Class="@Class">
|
<MudStack Row="false" Spacing="0" Class="@Class">
|
||||||
<div class="mx-3"
|
<WaveformSeeker OnSeekStart="OnSeekStart"
|
||||||
@onpointerdown="HandlePointerDown"
|
OnSeekChange="OnSeekChange"
|
||||||
@onpointerup="HandlePointerUp"
|
OnSeekEnd="OnSeekEnd"
|
||||||
@onpointerleave="HandlePointerLeave">
|
Class="seek-waveform"/>
|
||||||
<MudSlider T="double"
|
|
||||||
Min="0"
|
|
||||||
Max="@(Duration ?? 0D)"
|
|
||||||
Step="0.1"
|
|
||||||
Value="@DisplayTime"
|
|
||||||
ValueChanged="HandleValueChanged"
|
|
||||||
Immediate="true"
|
|
||||||
Disabled="@(!CanSeek)"/>
|
|
||||||
</div>
|
|
||||||
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
|
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|||||||
@@ -3,48 +3,17 @@ using Microsoft.AspNetCore.Components;
|
|||||||
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Centre zone of the player: seek slider over the spectrum visualizer.
|
/// Centre zone of the player: the <see cref="WaveformSeeker"/> over a timestamp label.
|
||||||
/// Owns the pointer-gesture seek logic (drag-to-seek) in one place so it is no
|
/// The seeker owns the pointer-gesture seek logic and reads playback state off the cascaded
|
||||||
/// longer duplicated inline between the desktop and mobile branches of the parent.
|
/// player service directly; this zone just forwards the seek callbacks up to
|
||||||
|
/// <see cref="AudioPlayerBar"/> (whose wiring is unchanged) and renders the timestamp.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class PlayerSeekZone : ComponentBase
|
public partial class PlayerSeekZone : ComponentBase
|
||||||
{
|
{
|
||||||
private double _seekPosition;
|
|
||||||
private bool _isSeeking = false;
|
|
||||||
|
|
||||||
[Parameter] public double DisplayTime { get; set; }
|
[Parameter] public double DisplayTime { get; set; }
|
||||||
[Parameter] public double? Duration { get; set; }
|
[Parameter] public double? Duration { get; set; }
|
||||||
[Parameter] public bool CanSeek { get; set; }
|
|
||||||
[Parameter] public EventCallback OnSeekStart { get; set; }
|
[Parameter] public EventCallback OnSeekStart { get; set; }
|
||||||
[Parameter] public EventCallback<double> OnSeekEnd { get; set; }
|
[Parameter] public EventCallback<double> OnSeekEnd { get; set; }
|
||||||
[Parameter] public EventCallback<double> OnSeekChange { get; set; }
|
[Parameter] public EventCallback<double> OnSeekChange { get; set; }
|
||||||
[Parameter] public string? Class { get; set; }
|
[Parameter] public string? Class { get; set; }
|
||||||
|
|
||||||
private async Task HandlePointerDown()
|
|
||||||
{
|
|
||||||
_isSeeking = true;
|
|
||||||
_seekPosition = DisplayTime;
|
|
||||||
await OnSeekStart.InvokeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandlePointerUp()
|
|
||||||
{
|
|
||||||
_isSeeking = false;
|
|
||||||
await OnSeekEnd.InvokeAsync(_seekPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandlePointerLeave()
|
|
||||||
{
|
|
||||||
if (_isSeeking)
|
|
||||||
{
|
|
||||||
_isSeeking = false;
|
|
||||||
await OnSeekEnd.InvokeAsync(_seekPosition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleValueChanged(double value)
|
|
||||||
{
|
|
||||||
_seekPosition = value;
|
|
||||||
await OnSeekChange.InvokeAsync(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||||
|
|
||||||
|
@* High-density loudness-waveform seekbar. Replaces the MudSlider in PlayerSeekZone.
|
||||||
|
Bars render once; the played/unplayed split is a single clip-width animation on the
|
||||||
|
muted overlay (see .razor.css), so a seek never re-renders the 200 bar divs. *@
|
||||||
|
<div class="@($"waveform-seeker {Class}".TrimEnd())"
|
||||||
|
style="--played-fraction: @PlayedFraction.ToString("F4", System.Globalization.CultureInfo.InvariantCulture);"
|
||||||
|
@ref="_seekerElement"
|
||||||
|
@onpointerdown="HandlePointerDown"
|
||||||
|
@onpointermove="HandlePointerMove"
|
||||||
|
@onpointerup="HandlePointerUp"
|
||||||
|
@onpointercancel="HandlePointerUp"
|
||||||
|
@onpointerleave="HandlePointerLeave">
|
||||||
|
|
||||||
|
@* Played layer: every bar in the accent colour. *@
|
||||||
|
<div class="waveform-layer waveform-layer-played">
|
||||||
|
@for (var i = 0; i < _renderedBars.Length; i++)
|
||||||
|
{
|
||||||
|
<div class="waveform-bar" style="--bar-height: @FormatHeight(_renderedBars[i]);"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Unplayed overlay: an identical bar set in the muted colour, clipped to the
|
||||||
|
unplayed portion. Only its clip width changes on seek — one CSS property. *@
|
||||||
|
<div class="waveform-layer waveform-layer-unplayed">
|
||||||
|
@for (var i = 0; i < _renderedBars.Length; i++)
|
||||||
|
{
|
||||||
|
<div class="waveform-bar" style="--bar-height: @FormatHeight(_renderedBars[i]);"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Playhead rule at the split point. *@
|
||||||
|
<div class="waveform-playhead"></div>
|
||||||
|
|
||||||
|
@* Hover preview line + time tooltip (suppressed while dragging). *@
|
||||||
|
@if (_showHover && !_isSeeking)
|
||||||
|
{
|
||||||
|
<div class="waveform-hover-line"
|
||||||
|
style="left: @_hoverFraction.ToString("P2", System.Globalization.CultureInfo.InvariantCulture);"></div>
|
||||||
|
<div class="waveform-hover-time"
|
||||||
|
style="left: @_hoverFraction.ToString("P2", System.Globalization.CultureInfo.InvariantCulture);">
|
||||||
|
@FormatTime(_hoverTime)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/* Geometry only. Colours come from shared --deepdrft-* theme tokens (deepdrft-tokens.css). */
|
||||||
|
|
||||||
|
.waveform-seeker {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: none; /* let pointer-capture drag own the gesture, not the browser's scroll */
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The two bar layers stack in the same box. The played layer paints all bars in the accent
|
||||||
|
colour; the unplayed layer paints an identical set in muted and is clipped to the unplayed
|
||||||
|
portion, so the only thing that moves on seek is one clip-path inset (--played-fraction). */
|
||||||
|
.waveform-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-bar {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 2px;
|
||||||
|
max-width: 6px;
|
||||||
|
height: var(--bar-height, 2%);
|
||||||
|
min-height: 2px;
|
||||||
|
border-radius: 1px 1px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-layer-played .waveform-bar {
|
||||||
|
background: var(--deepdrft-green-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-layer-unplayed {
|
||||||
|
/* Reveal only the unplayed (right) portion; the played portion shows the accent layer beneath. */
|
||||||
|
clip-path: inset(0 0 0 calc(var(--played-fraction, 0) * 100%));
|
||||||
|
transition: clip-path 0.08s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-layer-unplayed .waveform-bar {
|
||||||
|
background: var(--deepdrft-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Playhead rule at the split point. */
|
||||||
|
.waveform-playhead {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: calc(var(--played-fraction, 0) * 100%);
|
||||||
|
width: 2px;
|
||||||
|
margin-left: -1px;
|
||||||
|
background: var(--deepdrft-green-accent);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: left 0.08s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover preview line — faint vertical rule under the cursor. */
|
||||||
|
.waveform-hover-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
margin-left: -0.5px;
|
||||||
|
background: var(--deepdrft-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover time tooltip. */
|
||||||
|
.waveform-hover-time {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 2px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--deepdrft-white);
|
||||||
|
background: var(--deepdrft-navy-mid);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.waveform-seeker {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public TrackDto? CurrentTrack { get; protected set; }
|
public TrackDto? CurrentTrack { get; protected set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public double[]? WaveformProfile { get; protected set; }
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
public EventCallback? OnStateChanged { get; set; }
|
public EventCallback? OnStateChanged { get; set; }
|
||||||
public EventCallback? OnTrackSelected { get; set; }
|
public EventCallback? OnTrackSelected { get; set; }
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ public interface IPlayerService
|
|||||||
string? ErrorMessage { get; }
|
string? ErrorMessage { get; }
|
||||||
TrackDto? CurrentTrack { get; }
|
TrackDto? CurrentTrack { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalized loudness profile for the current track, each value in [0, 1], or null when no
|
||||||
|
/// profile is available (no track loaded, or the track has no stored profile). The seek zone
|
||||||
|
/// renders this as a waveform; a null profile drives the flat-but-seekable fallback. Fetched on
|
||||||
|
/// track select and cleared on unload/stop; <see cref="StateChanged"/> fires once it arrives.
|
||||||
|
/// </summary>
|
||||||
|
double[]? WaveformProfile { get; }
|
||||||
|
|
||||||
// Events for UI updates
|
// Events for UI updates
|
||||||
EventCallback? OnStateChanged { get; set; }
|
EventCallback? OnStateChanged { get; set; }
|
||||||
EventCallback? OnTrackSelected { get; set; }
|
EventCallback? OnTrackSelected { get; set; }
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
// Create new cancellation token for this streaming operation
|
// Create new cancellation token for this streaming operation
|
||||||
_streamingCancellation = new CancellationTokenSource();
|
_streamingCancellation = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// Fetch the waveform profile alongside the audio. Fire-and-forget against the same
|
||||||
|
// streaming token so a track switch abandons it; it only updates display state and must
|
||||||
|
// never gate or fail the audio load (a missing profile yields the flat-seekbar fallback).
|
||||||
|
_ = LoadWaveformProfileAsync(track.EntryKey, _streamingCancellation.Token);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Set state to indicate loading has started
|
// Set state to indicate loading has started
|
||||||
@@ -152,6 +157,67 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches and decodes the track's waveform loudness profile, then notifies state so the
|
||||||
|
/// seek zone re-renders with real bars. Best-effort: a 404 (no stored profile) or any other
|
||||||
|
/// failure simply leaves <see cref="AudioPlayerService.WaveformProfile"/> null, which the
|
||||||
|
/// WaveformSeeker renders as a flat-but-seekable fallback. Never throws into the load path.
|
||||||
|
/// </summary>
|
||||||
|
private async Task LoadWaveformProfileAsync(string entryKey, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
WaveformProfile = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _trackMediaClient.GetWaveformProfileAsync(entryKey, cancellationToken);
|
||||||
|
if (cancellationToken.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
if (result.Success && result.Value is { } dto)
|
||||||
|
{
|
||||||
|
WaveformProfile = DecodeWaveformProfile(dto);
|
||||||
|
await NotifyStateChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Track switched or stopped before the profile arrived — nothing to surface.
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A failed profile fetch must not disturb playback; log and fall back to flat bars.
|
||||||
|
_logger.LogDebug(ex, "Failed to load waveform profile for {EntryKey}", entryKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decodes a <see cref="WaveformProfileDto"/> (base64 of byte[BucketCount], each 0..255) into
|
||||||
|
/// a normalized double[] in [0, 1]. Returns null if the payload is malformed so callers treat
|
||||||
|
/// it as "no profile" rather than rendering garbage bars.
|
||||||
|
/// </summary>
|
||||||
|
private static double[]? DecodeWaveformProfile(WaveformProfileDto dto)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(dto.Data)) return null;
|
||||||
|
|
||||||
|
byte[] bytes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = Convert.FromBase64String(dto.Data);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.Length == 0) return null;
|
||||||
|
|
||||||
|
var profile = new double[bytes.Length];
|
||||||
|
for (var i = 0; i < bytes.Length; i++)
|
||||||
|
{
|
||||||
|
profile[i] = bytes[i] / 255.0;
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task StreamAudioWithEarlyPlayback(TrackMediaResponse audio, CancellationToken cancellationToken)
|
private async Task StreamAudioWithEarlyPlayback(TrackMediaResponse audio, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
byte[]? buffer = null;
|
byte[]? buffer = null;
|
||||||
@@ -438,6 +504,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
LoadProgress = 0;
|
LoadProgress = 0;
|
||||||
ErrorMessage = null;
|
ErrorMessage = null;
|
||||||
CurrentTrack = null;
|
CurrentTrack = null;
|
||||||
|
WaveformProfile = null;
|
||||||
|
|
||||||
// 4. Reset streaming-specific state
|
// 4. Reset streaming-specific state
|
||||||
IsStreamingMode = false;
|
IsStreamingMode = false;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Geometry helpers for WaveformSeeker. Deliberately tiny and standalone — this is NOT part of
|
||||||
|
// the TypeScript audio bundle (DeepDrftPublic/Interop/audio → wwwroot/js/audio). Loaded lazily as
|
||||||
|
// an ES module by the component via IJSObjectReference, so it adds no global script tag.
|
||||||
|
|
||||||
|
export function getWidth(element) {
|
||||||
|
return element ? element.getBoundingClientRect().width : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capturePointer(element, pointerId) {
|
||||||
|
// setPointerCapture keeps a drag tracking even when the pointer leaves the element bounds.
|
||||||
|
element?.setPointerCapture?.(pointerId);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user