af724ce570
Replaces flat RELEASES/SESSIONS/MIXES nav with ARCHIVE dropdown (PageRoute.Children,
one-level cap, dual-role node). Adds /archive overview, /cuts (AlbumsView + medium
filter; /albums redirects), /sessions + /sessions/{id} (hero-dominant), /mixes +
/mixes/{id} (MixWaveformVisualizer full-page background). Extracts ReleaseDetailScaffold
from TrackDetail (invariant trio). PersistentComponentState bridge on all new pages.
Click-to-seek seam designed on MixWaveformVisualizer (inert until wired).
133 lines
5.1 KiB
C#
133 lines
5.1 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Renders a Mix release's stored loudness profile as a full-page background silhouette. Standalone
|
|
/// and reusable: give it a <see cref="ReleaseId"/> 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.
|
|
/// </summary>
|
|
public partial class MixWaveformVisualizer : ComponentBase
|
|
{
|
|
[Inject] public required IReleaseDataService ReleaseData { get; set; }
|
|
[Inject] public required ILogger<MixWaveformVisualizer> Logger { get; set; }
|
|
|
|
/// <summary>The Mix release whose waveform datum to fetch and render.</summary>
|
|
[Parameter] public required long ReleaseId { get; set; }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Parameter] public double PlaybackPosition { get; set; }
|
|
|
|
[Parameter] public EventCallback<double> PlaybackPositionChanged { get; set; }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Parameter] public EventCallback<double> 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(' ');
|
|
}
|
|
}
|