using System.Globalization; using System.Text; using DeepDrftModels.DTOs; using DeepDrftPublic.Client.Services; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging; namespace DeepDrftPublic.Client.Controls; /// /// Renders a Mix release's stored loudness profile as a full-page background silhouette. Standalone /// and reusable: give it a and it fetches its own datum. Visually distinct /// from the player-bar spectrum/level idiom by design — this is a single continuous mirrored wave, /// not discrete peak bars. /// public partial class MixWaveformVisualizer : ComponentBase { [Inject] public required IReleaseDataService ReleaseData { get; set; } [Inject] public required ILogger Logger { get; set; } /// The Mix release whose waveform datum to fetch and render. [Parameter] public required long ReleaseId { get; set; } /// /// Normalized playback head in [0, 1]. Two-way bindable so a future click-to-seek can write back /// through it; today it is read-only input that drives the played-portion wash. The seam exists /// now so wiring click-to-seek later is a pure addition, not a signature change. /// [Parameter] public double PlaybackPosition { get; set; } [Parameter] public EventCallback PlaybackPositionChanged { get; set; } /// /// Fired when the user seeks by interacting with the waveform. Unused until click-to-seek ships; /// present now to lock the seek seam into the public contract. /// [Parameter] public EventCallback OnSeek { get; set; } // Fixed SVG coordinate width. The path is computed in this space, then stretched to the // viewport via preserveAspectRatio="none". private const int ViewBoxWidth = 1000; private readonly string _clipId = $"mix-wf-clip-{Guid.NewGuid():N}"; private WaveformProfileDto? _profile; private string _silhouettePath = string.Empty; private long? _loadedReleaseId; private double ClampedPosition => Math.Clamp(PlaybackPosition, 0d, 1d); protected override async Task OnParametersSetAsync() { // ReleaseId is the only fetch input; fetch once per id. A PlaybackPosition update re-renders // but must not refetch — and a release with no datum must not refetch either, so the guard // keys on the fetched id, not on whether a profile came back. if (_loadedReleaseId == ReleaseId) return; _loadedReleaseId = ReleaseId; var result = await ReleaseData.GetMixWaveform(ReleaseId); if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0) { _profile = profile; try { _silhouettePath = BuildSilhouettePath(profile); } catch (Exception ex) { Logger.LogWarning(ex, "MixWaveformVisualizer: failed to decode waveform profile for release {ReleaseId}; rendering empty backdrop.", ReleaseId); _profile = null; _silhouettePath = string.Empty; } } else { // No datum (not generated yet, or not a Mix) — leave the background empty; the detail // page still renders its content over a plain backdrop. _profile = null; _silhouettePath = string.Empty; } } // Builds a closed, vertically mirrored silhouette path across the buckets. Loudness bytes are // [0, 255]; mapped to a half-height amplitude around the vertical midline (y=50). The top edge // runs left-to-right, the bottom edge mirrors right-to-left, and the path closes — yielding a // filled continuous wave shape rather than separate bars. private static string BuildSilhouettePath(WaveformProfileDto profile) { var data = Convert.FromBase64String(profile.Data); int n = data.Length; if (n == 0) return string.Empty; const double midline = 50d; const double maxAmplitude = 48d; // leave a 2-unit margin top and bottom double step = n > 1 ? (double)ViewBoxWidth / (n - 1) : ViewBoxWidth; var sb = new StringBuilder(); // Top edge, left to right. for (int i = 0; i < n; i++) { double x = i * step; double amp = data[i] / 255d * maxAmplitude; double y = midline - amp; sb.Append(i == 0 ? 'M' : 'L'); AppendPoint(sb, x, y); } // Bottom edge, right to left (mirror). for (int i = n - 1; i >= 0; i--) { double x = i * step; double amp = data[i] / 255d * maxAmplitude; double y = midline + amp; sb.Append('L'); AppendPoint(sb, x, y); } sb.Append('Z'); return sb.ToString(); } private static void AppendPoint(StringBuilder sb, double x, double y) { sb.Append(x.ToString("0.##", CultureInfo.InvariantCulture)); sb.Append(' '); sb.Append(y.ToString("0.##", CultureInfo.InvariantCulture)); sb.Append(' '); } }