diff --git a/.gitignore b/.gitignore index e34cf60..b7422ad 100644 --- a/.gitignore +++ b/.gitignore @@ -311,4 +311,7 @@ __pycache__/ Database/Vaults/* # TypeScript output -**/wwwroot/js/* \ No newline at end of file +**/wwwroot/js/* +# ...except hand-authored client JS modules (not TS compile output). +!DeepDrftPublic.Client/wwwroot/js/ +!DeepDrftPublic.Client/wwwroot/js/*.js \ No newline at end of file diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor index d9cff22..9902438 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor @@ -26,7 +26,6 @@ else private double DisplayTime => _isSeeking ? _seekPosition : (PlayerService?.CurrentTime ?? 0); - /// - /// 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. - /// - private bool CanSeek => IsLoaded && Duration.HasValue && Duration.Value > 0; - protected override void OnParametersSet() { // PlayerService is cascaded by AudioPlayerProvider; once it arrives, diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerSeekZone.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerSeekZone.razor index 48b8b80..2794227 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerSeekZone.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerSeekZone.razor @@ -1,18 +1,9 @@ @namespace DeepDrftPublic.Client.Controls.AudioPlayerBar -
- -
+
diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerSeekZone.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerSeekZone.razor.cs index d0e08ee..b26e59e 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerSeekZone.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerSeekZone.razor.cs @@ -3,48 +3,17 @@ using Microsoft.AspNetCore.Components; namespace DeepDrftPublic.Client.Controls.AudioPlayerBar; /// -/// 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 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 +/// (whose wiring is unchanged) and renders the timestamp. /// 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 OnSeekEnd { get; set; } [Parameter] public EventCallback 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); - } } diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor new file mode 100644 index 0000000..645911d --- /dev/null +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor @@ -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. *@ +
+ + @* Played layer: every bar in the accent colour. *@ +
+ @for (var i = 0; i < _renderedBars.Length; i++) + { +
+ } +
+ + @* 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. *@ +
+ @for (var i = 0; i < _renderedBars.Length; i++) + { +
+ } +
+ + @* Playhead rule at the split point. *@ +
+ + @* Hover preview line + time tooltip (suppressed while dragging). *@ + @if (_showHover && !_isSeeking) + { +
+
+ @FormatTime(_hoverTime) +
+ } +
diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.cs new file mode 100644 index 0000000..25b2e50 --- /dev/null +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.cs @@ -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; + +/// +/// 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; + + // 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 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; + + // 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("getWidth", _seekerElement); + if (width > 0 && Math.Abs(width - _elementWidth) > 1) + { + _elementWidth = width; + RebuildBars(); + 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; + + // 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; + } + } +} diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.css b/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.css new file mode 100644 index 0000000..0b4fdaa --- /dev/null +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/WaveformSeeker.razor.css @@ -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; + } +} diff --git a/DeepDrftPublic.Client/Services/AudioPlayerService.cs b/DeepDrftPublic.Client/Services/AudioPlayerService.cs index ca91536..df43100 100644 --- a/DeepDrftPublic.Client/Services/AudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/AudioPlayerService.cs @@ -33,6 +33,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable /// public TrackDto? CurrentTrack { get; protected set; } + /// + public double[]? WaveformProfile { get; protected set; } + // Events public EventCallback? OnStateChanged { get; set; } public EventCallback? OnTrackSelected { get; set; } diff --git a/DeepDrftPublic.Client/Services/IPlayerService.cs b/DeepDrftPublic.Client/Services/IPlayerService.cs index 2234d16..1844429 100644 --- a/DeepDrftPublic.Client/Services/IPlayerService.cs +++ b/DeepDrftPublic.Client/Services/IPlayerService.cs @@ -19,6 +19,14 @@ public interface IPlayerService string? ErrorMessage { get; } TrackDto? CurrentTrack { get; } + /// + /// 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; fires once it arrives. + /// + double[]? WaveformProfile { get; } + // Events for UI updates EventCallback? OnStateChanged { get; set; } EventCallback? OnTrackSelected { get; set; } diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs index 12a394c..499e732 100644 --- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs @@ -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 } } + /// + /// 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 null, which the + /// WaveformSeeker renders as a flat-but-seekable fallback. Never throws into the load path. + /// + 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); + } + } + + /// + /// Decodes a (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. + /// + 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; diff --git a/DeepDrftPublic.Client/wwwroot/js/waveformSeeker.js b/DeepDrftPublic.Client/wwwroot/js/waveformSeeker.js new file mode 100644 index 0000000..dbf08e5 --- /dev/null +++ b/DeepDrftPublic.Client/wwwroot/js/waveformSeeker.js @@ -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); +}