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:
daniel-c-harvey
2026-06-05 17:35:11 -04:00
parent 7c89220667
commit 8de7342352
12 changed files with 516 additions and 56 deletions
@@ -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;