Phase 9 Wave 4: ARCHIVE nav + Cuts/Sessions/Mixes pages + MixWaveformVisualizer
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).
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
@namespace DeepDrftPublic.Client.Controls
|
||||
|
||||
@* Full-page background waveform for a Mix release. Deliberately NOT the player-bar peak-bar idiom
|
||||
(SpectrumVisualizer / LevelMeterFab own that): this renders a single continuous mirrored
|
||||
silhouette filling the viewport behind the detail content. Fetches its own datum from
|
||||
api/release/{id}/mix/waveform. The played portion is washed with the progress overlay driven by
|
||||
PlaybackPosition; the click-to-seek seam (OnSeek + bindable PlaybackPosition) is wired here for a
|
||||
future wave even though click handling does not ship yet. *@
|
||||
|
||||
<div class="mix-waveform-bg @(_profile is null ? "mix-waveform-bg--empty" : null)">
|
||||
@if (_profile is not null)
|
||||
{
|
||||
<svg class="mix-waveform-svg"
|
||||
viewBox="0 0 @ViewBoxWidth 100"
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="Waveform">
|
||||
<defs>
|
||||
<clipPath id="@_clipId">
|
||||
<path d="@_silhouettePath" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
@* Base silhouette. *@
|
||||
<path d="@_silhouettePath" class="mix-waveform-fill" />
|
||||
|
||||
@* Played-portion wash: a full-height rect clipped to the silhouette, width tracking
|
||||
PlaybackPosition. Clamped to [0, 1]. *@
|
||||
<rect x="0" y="0"
|
||||
width="@(ClampedPosition * ViewBoxWidth)" height="100"
|
||||
class="mix-waveform-played"
|
||||
clip-path="url(#@_clipId)" />
|
||||
</svg>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,132 @@
|
||||
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(' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* Full-viewport fixed backdrop. Sits behind page content (negative-ish z-index within the
|
||||
detail layout) and never intercepts pointer events until click-to-seek ships. */
|
||||
.mix-waveform-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mix-waveform-bg--empty {
|
||||
/* No datum: nothing to draw. Kept as a hook for a future flat-line fallback. */
|
||||
}
|
||||
|
||||
.mix-waveform-svg {
|
||||
width: 100%;
|
||||
height: 60vh;
|
||||
margin: auto 0;
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
/* Native SVG elements — scoped CSS stamps these directly, no ::deep needed. */
|
||||
.mix-waveform-fill {
|
||||
fill: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.mix-waveform-played {
|
||||
fill: var(--mud-palette-primary);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@namespace DeepDrftPublic.Client.Controls
|
||||
|
||||
@* Invariant trio shared by every medium's detail page: a back link, a masthead (title + artist),
|
||||
a play/share affordance row wired to the streaming player, and slots for the medium-specific
|
||||
hero visual and metadata block. TrackDetail and the Session/Mix detail pages all compose this;
|
||||
per-medium variance rides the Hero and MetaContent render fragments. *@
|
||||
|
||||
<div class="deepdrft-track-detail-container">
|
||||
|
||||
<MudLink Href="@BackHref" Typo="Typo.body2" Class="deepdrft-track-detail-back">
|
||||
← @BackLabel
|
||||
</MudLink>
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h3">@Title</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
|
||||
</div>
|
||||
|
||||
@* Play + share only make sense once a playable track is resolved. *@
|
||||
@if (Track is not null)
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<SharePopover EntryKey="@Track.EntryKey" />
|
||||
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@Hero
|
||||
|
||||
@if (MetaContent is not null && ShowMeta)
|
||||
{
|
||||
<MudDivider />
|
||||
<div class="deepdrft-track-detail-meta">
|
||||
@MetaContent
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Shared detail-page chrome for any release medium: back link, masthead, play/share affordance,
|
||||
/// and hero/meta slots. Owns the play-toggle wiring against the cascaded streaming player so each
|
||||
/// detail page supplies only its data and medium-specific visuals. Extracted from the original
|
||||
/// TrackDetail page, which is now a thin consumer of this scaffold.
|
||||
/// </summary>
|
||||
public partial class ReleaseDetailScaffold : ComponentBase
|
||||
{
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
|
||||
[Parameter] public required string Title { get; set; }
|
||||
[Parameter] public string? Artist { get; set; }
|
||||
|
||||
// The playable track for this release. Null while unresolved (or when a release has no
|
||||
// streamable track yet) — the play/share row is hidden in that case.
|
||||
[Parameter] public TrackDto? Track { get; set; }
|
||||
|
||||
[Parameter] public string BackHref { get; set; } = "/archive";
|
||||
[Parameter] public string BackLabel { get; set; } = "Archive";
|
||||
|
||||
/// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
|
||||
[Parameter] public RenderFragment? Hero { get; set; }
|
||||
|
||||
/// <summary>Optional medium-specific metadata block, rendered under a divider when present.</summary>
|
||||
[Parameter] public RenderFragment? MetaContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate for the metadata block. Lets a consumer supply a <see cref="MetaContent"/> fragment but
|
||||
/// suppress the divider + block when its data is empty (slot fragments cannot be conditionally
|
||||
/// attached inline). Defaults to shown.
|
||||
/// </summary>
|
||||
[Parameter] public bool ShowMeta { get; set; } = true;
|
||||
|
||||
private async Task PlayTrack()
|
||||
{
|
||||
if (Track is null || PlayerService is null) return;
|
||||
|
||||
// Toggle if this track is already active (playing or paused); otherwise start a fresh
|
||||
// stream. SelectTrackStreaming is the live entry point — the buffered path is dead.
|
||||
var isThisTrack = PlayerService.CurrentTrack?.Id == Track.Id;
|
||||
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrackStreaming(Track);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
@namespace DeepDrftPublic.Client.Controls
|
||||
|
||||
@* Card grid of releases that open their own detail page (/{DetailRoute}/{id}). Shared by the
|
||||
Sessions and Mixes browse pages. Cuts intentionally do not use this — they open the track
|
||||
gallery filtered by album, a different navigation target. Fully controlled by the parent:
|
||||
loading and item state are passed in. *@
|
||||
|
||||
<div>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="release-gallery-container">
|
||||
@if (Loading)
|
||||
{
|
||||
<MudGrid Spacing="6" Justify="Justify.Center">
|
||||
@foreach (var _ in Enumerable.Range(0, 8))
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="release-card-center">
|
||||
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
else if (Releases.Count == 0)
|
||||
{
|
||||
<div class="release-gallery-empty">
|
||||
<MudText Typo="Typo.h6">@EmptyMessage</MudText>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="6" Justify="Justify.Center">
|
||||
@foreach (var release in Releases)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="release-card-center">
|
||||
<a href="@($"/{DetailRoute}/{release.Id}")" class="release-card-link">
|
||||
<div class="release-card">
|
||||
@if (!string.IsNullOrEmpty(release.ImagePath))
|
||||
{
|
||||
<div class="release-card-cover"
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(release.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="release-card-cover release-card-cover--fallback"></div>
|
||||
}
|
||||
|
||||
<div class="release-card-body">
|
||||
<MudText Typo="Typo.subtitle1" Class="release-card-title text-truncate">
|
||||
@release.Title
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="release-card-artist text-truncate">
|
||||
@release.Artist
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public required IReadOnlyList<DeepDrftModels.DTOs.ReleaseDto> Releases { get; set; }
|
||||
[Parameter] public bool Loading { get; set; }
|
||||
|
||||
/// <summary>Route segment for a card's detail page; a card links to /{DetailRoute}/{id}.</summary>
|
||||
[Parameter] public required string DetailRoute { get; set; }
|
||||
|
||||
[Parameter] public string EmptyMessage { get; set; } = "Nothing here yet";
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.release-gallery-container {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.release-card-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.release-card-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.release-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 200px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.release-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.release-card-cover {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.release-card-cover--fallback {
|
||||
background-color: var(--mud-palette-dark, #1a2238);
|
||||
}
|
||||
|
||||
.release-card-body {
|
||||
padding: 8px 4px 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* release-card-title / release-card-artist ride on MudText (child Razor component); ::deep
|
||||
pierces into its output since Blazor isolation does not scope-stamp child component roots. */
|
||||
::deep .release-card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::deep .release-card-artist {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.release-gallery-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
Reference in New Issue
Block a user