diff --git a/DeepDrftPublic.Client/Clients/ReleaseClient.cs b/DeepDrftPublic.Client/Clients/ReleaseClient.cs
new file mode 100644
index 0000000..a24e7c1
--- /dev/null
+++ b/DeepDrftPublic.Client/Clients/ReleaseClient.cs
@@ -0,0 +1,101 @@
+using DeepDrftModels.DTOs;
+using Models.Common;
+using NetBlocks.Models;
+using System.Text.Json;
+using Microsoft.AspNetCore.Http;
+
+namespace DeepDrftPublic.Client.Clients;
+
+///
+/// HTTP client for the release read surface (Phase 9). Uses the named "DeepDrft.API"
+/// client like : on WASM it points at the public host and proxies
+/// through ReleaseProxyController; on SSR prerender it points directly at DeepDrftAPI.
+/// All routes are unauthenticated reads. Responses deserialize as bare DTOs (no ApiResultDto
+/// envelope), matching the API's Ok(value) shape.
+///
+public class ReleaseClient
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
+
+ private readonly HttpClient _http;
+
+ public ReleaseClient(IHttpClientFactory httpClientFactory)
+ {
+ _http = httpClientFactory.CreateClient("DeepDrft.API");
+ }
+
+ public async Task>> GetPaged(
+ string? medium,
+ int page,
+ int pageSize,
+ string? sortColumn = null,
+ bool sortDescending = false)
+ {
+ var queryArgs = new Dictionary
+ {
+ ["page"] = page.ToString(),
+ ["pageSize"] = pageSize.ToString()
+ };
+
+ if (!string.IsNullOrEmpty(medium))
+ queryArgs["medium"] = medium;
+
+ if (!string.IsNullOrEmpty(sortColumn))
+ queryArgs["sortColumn"] = sortColumn;
+
+ if (sortDescending)
+ queryArgs["sortDescending"] = "true";
+
+ string query = QueryString.Create(queryArgs).ToString();
+
+ var response = await _http.GetAsync($"api/release{query}");
+
+ if (!response.IsSuccessStatusCode)
+ return ApiResult>.CreateFailResult($"HTTP {(int)response.StatusCode}");
+
+ var json = await response.Content.ReadAsStringAsync();
+ var paged = JsonSerializer.Deserialize>(json, JsonOptions);
+
+ return paged is not null
+ ? ApiResult>.CreatePassResult(paged)
+ : ApiResult>.CreateFailResult("Failed to deserialize response");
+ }
+
+ public async Task> GetById(long id)
+ {
+ var response = await _http.GetAsync($"api/release/{id}");
+
+ if (!response.IsSuccessStatusCode)
+ return ApiResult.CreateFailResult($"HTTP {(int)response.StatusCode}");
+
+ var json = await response.Content.ReadAsStringAsync();
+ var release = JsonSerializer.Deserialize(json, JsonOptions);
+
+ return release is not null
+ ? ApiResult.CreatePassResult(release)
+ : ApiResult.CreateFailResult("Failed to deserialize response");
+ }
+
+ ///
+ /// Fetches the high-res waveform datum for a Mix release. A 404 means no datum is stored
+ /// (not yet generated, or not a Mix) — a valid state, so it returns a pass result with a
+ /// null value. Any other non-success status is a genuine failure.
+ ///
+ public async Task> GetMixWaveform(long id)
+ {
+ var response = await _http.GetAsync($"api/release/{id}/mix/waveform");
+
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ return ApiResult.CreatePassResult(null);
+
+ if (!response.IsSuccessStatusCode)
+ return ApiResult.CreateFailResult($"HTTP {(int)response.StatusCode}");
+
+ var json = await response.Content.ReadAsStringAsync();
+ var profile = JsonSerializer.Deserialize(json, JsonOptions);
+
+ return profile is not null
+ ? ApiResult.CreatePassResult(profile)
+ : ApiResult.CreateFailResult("Failed to deserialize response");
+ }
+}
diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor
new file mode 100644
index 0000000..c3eb75d
--- /dev/null
+++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor
@@ -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. *@
+
+
+ @if (_profile is not null)
+ {
+
+ }
+
diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs
new file mode 100644
index 0000000..c886803
--- /dev/null
+++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.cs
@@ -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;
+
+///
+/// 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(' ');
+ }
+}
diff --git a/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.css b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.css
new file mode 100644
index 0000000..f1ba9dd
--- /dev/null
+++ b/DeepDrftPublic.Client/Controls/MixWaveformVisualizer.razor.css
@@ -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);
+}
diff --git a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor
new file mode 100644
index 0000000..4b91381
--- /dev/null
+++ b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor
@@ -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. *@
+
+
+
+
+ ← @BackLabel
+
+
+
+
+ @Title
+ @Artist
+
+
+ @* Play + share only make sense once a playable track is resolved. *@
+ @if (Track is not null)
+ {
+
+
+
+
+ }
+
+
+ @Hero
+
+ @if (MetaContent is not null && ShowMeta)
+ {
+
+
+ @MetaContent
+
+ }
+
+
diff --git a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs
new file mode 100644
index 0000000..cbabae0
--- /dev/null
+++ b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs
@@ -0,0 +1,56 @@
+using DeepDrftModels.DTOs;
+using DeepDrftPublic.Client.Services;
+using Microsoft.AspNetCore.Components;
+
+namespace DeepDrftPublic.Client.Controls;
+
+///
+/// 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.
+///
+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";
+
+ /// Medium-specific hero visual (cover art, hero image, or waveform background).
+ [Parameter] public RenderFragment? Hero { get; set; }
+
+ /// Optional medium-specific metadata block, rendered under a divider when present.
+ [Parameter] public RenderFragment? MetaContent { get; set; }
+
+ ///
+ /// Gate for the metadata block. Lets a consumer supply a fragment but
+ /// suppress the divider + block when its data is empty (slot fragments cannot be conditionally
+ /// attached inline). Defaults to shown.
+ ///
+ [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);
+ }
+ }
+}
diff --git a/DeepDrftPublic.Client/Controls/ReleaseGallery.razor b/DeepDrftPublic.Client/Controls/ReleaseGallery.razor
new file mode 100644
index 0000000..0dd1ede
--- /dev/null
+++ b/DeepDrftPublic.Client/Controls/ReleaseGallery.razor
@@ -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. *@
+
+
+ @if (navPage.HasChildren)
+ {
+ @* Dual-role node: the parent anchor navigates to its own route on click,
+ while hover/focus reveals the child dropdown (pure CSS, no JS). *@
+