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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user