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) + { + + + + + + + + @* Base silhouette. *@ + + + @* Played-portion wash: a full-height rect clipped to the silhouette, width tracking + PlaybackPosition. Clamped to [0, 1]. *@ + + + } +
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 (Loading) + { + + @foreach (var _ in Enumerable.Range(0, 8)) + { + +
+ +
+
+ } +
+ } + else if (Releases.Count == 0) + { + + } + else + { + + @foreach (var release in Releases) + { + + + + } + + } +
+
+ +@code { + [Parameter] public required IReadOnlyList Releases { get; set; } + [Parameter] public bool Loading { get; set; } + + /// Route segment for a card's detail page; a card links to /{DetailRoute}/{id}. + [Parameter] public required string DetailRoute { get; set; } + + [Parameter] public string EmptyMessage { get; set; } = "Nothing here yet"; +} diff --git a/DeepDrftPublic.Client/Controls/ReleaseGallery.razor.css b/DeepDrftPublic.Client/Controls/ReleaseGallery.razor.css new file mode 100644 index 0000000..fddb791 --- /dev/null +++ b/DeepDrftPublic.Client/Controls/ReleaseGallery.razor.css @@ -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; +} diff --git a/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor b/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor index 5f2f39e..4c638f7 100644 --- a/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor +++ b/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor @@ -10,21 +10,33 @@
- @* *@
@@ -35,13 +47,6 @@ Deep DRFT
- @* *@