Merge branch 'waveform-w2-seeker' into dev

This commit is contained in:
daniel-c-harvey
2026-06-05 17:37:01 -04:00
12 changed files with 516 additions and 56 deletions
+4 -1
View File
@@ -311,4 +311,7 @@ __pycache__/
Database/Vaults/*
# 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"
Duration="Duration"
CanSeek="CanSeek"
OnSeekStart="@OnSeekStart"
OnSeekEnd="@OnSeekEnd"
OnSeekChange="@OnSeekChange"
@@ -29,12 +29,6 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
/// </summary>
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()
{
// PlayerService is cascaded by AudioPlayerProvider; once it arrives,
@@ -1,18 +1,9 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
<MudStack Row="false" Spacing="0" Class="@Class">
<div class="mx-3"
@onpointerdown="HandlePointerDown"
@onpointerup="HandlePointerUp"
@onpointerleave="HandlePointerLeave">
<MudSlider T="double"
Min="0"
Max="@(Duration ?? 0D)"
Step="0.1"
Value="@DisplayTime"
ValueChanged="HandleValueChanged"
Immediate="true"
Disabled="@(!CanSeek)"/>
</div>
<WaveformSeeker OnSeekStart="OnSeekStart"
OnSeekChange="OnSeekChange"
OnSeekEnd="OnSeekEnd"
Class="seek-waveform"/>
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
</MudStack>
@@ -3,48 +3,17 @@ using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
/// <summary>
/// Centre zone of the player: seek slider over the spectrum visualizer.
/// Owns the pointer-gesture seek logic (drag-to-seek) in one place so it is no
/// longer duplicated inline between the desktop and mobile branches of the parent.
/// Centre zone of the player: the <see cref="WaveformSeeker"/> over a timestamp label.
/// The seeker owns the pointer-gesture seek logic and reads playback state off the cascaded
/// player service directly; this zone just forwards the seek callbacks up to
/// <see cref="AudioPlayerBar"/> (whose wiring is unchanged) and renders the timestamp.
/// </summary>
public partial class PlayerSeekZone : ComponentBase
{
private double _seekPosition;
private bool _isSeeking = false;
[Parameter] public double DisplayTime { get; set; }
[Parameter] public double? Duration { get; set; }
[Parameter] public bool CanSeek { get; set; }
[Parameter] public EventCallback OnSeekStart { get; set; }
[Parameter] public EventCallback<double> OnSeekEnd { get; set; }
[Parameter] public EventCallback<double> OnSeekChange { 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>
public TrackDto? CurrentTrack { get; protected set; }
/// <inheritdoc />
public double[]? WaveformProfile { get; protected set; }
// Events
public EventCallback? OnStateChanged { get; set; }
public EventCallback? OnTrackSelected { get; set; }
@@ -19,6 +19,14 @@ public interface IPlayerService
string? ErrorMessage { 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
EventCallback? OnStateChanged { get; set; }
EventCallback? OnTrackSelected { get; set; }
@@ -77,6 +77,11 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// Create new cancellation token for this streaming operation
_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
{
// 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)
{
byte[]? buffer = null;
@@ -438,6 +504,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
LoadProgress = 0;
ErrorMessage = null;
CurrentTrack = null;
WaveformProfile = null;
// 4. Reset streaming-specific state
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);
}