From 0dd33a5dfc71f6f92363ccb0721d6ca8f9ce5f16 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 6 Jun 2026 16:33:57 -0400 Subject: [PATCH 1/2] Add track detail page with clickable cards --- DeepDrftPublic.Client/Pages/TrackDetail.razor | 106 ++++++++++++++++++ .../Pages/TrackDetail.razor.cs | 92 +++++++++++++++ DeepDrftPublic.Client/Startup.cs | 1 + .../ViewModels/TrackDetailViewModel.cs | 45 ++++++++ .../wwwroot/styles/deepdrft-styles.css | 76 +++++++++++++ .../Components/TrackCard.razor | 62 ++++++++-- 6 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 DeepDrftPublic.Client/Pages/TrackDetail.razor create mode 100644 DeepDrftPublic.Client/Pages/TrackDetail.razor.cs create mode 100644 DeepDrftPublic.Client/ViewModels/TrackDetailViewModel.cs diff --git a/DeepDrftPublic.Client/Pages/TrackDetail.razor b/DeepDrftPublic.Client/Pages/TrackDetail.razor new file mode 100644 index 0000000..09befe3 --- /dev/null +++ b/DeepDrftPublic.Client/Pages/TrackDetail.razor @@ -0,0 +1,106 @@ +@page "/track/{EntryKey}" +@rendermode InteractiveWebAssembly + +@(ViewModel.Track?.TrackName ?? "Track") - DeepDrft + +@if (ViewModel.IsLoading) +{ +
+
+ +
+
+ + +
+
+ + +
+
+} +else if (ViewModel.NotFound) +{ +
+
+ Track not found. + + This track may have been moved or removed. + +
+ + All tracks + +
+
+
+} +else if (ViewModel.Track is not null) +{ + var track = ViewModel.Track; + var isThisTrackPlaying = PlayerService.CurrentTrack?.Id == track.Id + && PlayerService.IsPlaying + && !PlayerService.IsPaused; + var hasMeta = track.Album is not null || track.Genre is not null || track.ReleaseDate is not null; + +
+ + + ← All tracks + + +
+ + + +
+ +
+ @track.TrackName + @track.Artist +
+ +
+ +
+ + @if (hasMeta) + { + + +
+ @if (track.Album is not null) + { +
+ Album + @track.Album +
+ } + + @if (track.Genre is not null) + { + + @track.Genre + + } + + @if (track.ReleaseDate is not null) + { +
+ Released + @track.ReleaseDate.Value.ToString("MMMM yyyy") +
+ } +
+ } + +
+} diff --git a/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs b/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs new file mode 100644 index 0000000..1c1e4c0 --- /dev/null +++ b/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs @@ -0,0 +1,92 @@ +using DeepDrftModels.DTOs; +using DeepDrftPublic.Client.Services; +using DeepDrftPublic.Client.ViewModels; +using Microsoft.AspNetCore.Components; + +namespace DeepDrftPublic.Client.Pages; + +public partial class TrackDetail : ComponentBase, IDisposable +{ + private const string PersistKey = "track-detail"; + + [Parameter] public required string EntryKey { get; set; } + [Inject] public required TrackDetailViewModel ViewModel { get; set; } + [Inject] public required PersistentComponentState PersistentState { get; set; } + [CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; } + + private IStreamingPlayerService? _subscribedService; + private PersistingComponentStateSubscription _persistingSubscription; + + protected override async Task OnInitializedAsync() + { + // Carry the prerendered track across the prerender -> interactive (WASM) seam. + // Without this, the WASM pass gets a fresh scoped ViewModel, re-renders the + // skeleton, and re-fetches. Mirror the TracksView bridge: persist on the way + // out of prerender, restore on the interactive pass, and only fetch on a miss. + _persistingSubscription = PersistentState.RegisterOnPersisting(PersistTrack); + + if (PersistentState.TryTakeFromJson(PersistKey, out var restored) && restored is not null) + { + ViewModel.Track = restored; + ViewModel.IsLoading = false; + } + else + { + await ViewModel.Load(EntryKey); + } + } + + protected override void OnParametersSet() + { + // The play button's icon reads off the player's live state (CurrentTrack / + // IsPlaying / IsPaused), which mutates outside this component's render path. + // The cascade is IsFixed, so the provider's re-render never reaches us — + // subscribe to the multicast side-channel and re-render on every state change. + if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService)) + { + if (_subscribedService != null) + _subscribedService.StateChanged -= OnPlayerStateChanged; + + PlayerService.StateChanged += OnPlayerStateChanged; + _subscribedService = PlayerService; + } + } + + private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged); + + private Task PersistTrack() + { + if (ViewModel.Track is not null) + { + PersistentState.PersistAsJson(PersistKey, ViewModel.Track); + } + return Task.CompletedTask; + } + + private async Task PlayTrack() + { + if (ViewModel.Track is null) return; + + // Resume the current track if it's merely paused; otherwise stream the new selection. + // SelectTrackStreaming is the live entry point — the buffered SelectTrack path is dead. + if (PlayerService.CurrentTrack?.Id == ViewModel.Track.Id && PlayerService.IsPaused) + { + await PlayerService.TogglePlayPause(); + } + else + { + await PlayerService.SelectTrackStreaming(ViewModel.Track); + } + } + + public void Dispose() + { + _persistingSubscription.Dispose(); + + if (_subscribedService != null) + { + _subscribedService.StateChanged -= OnPlayerStateChanged; + _subscribedService = null; + } + } +} diff --git a/DeepDrftPublic.Client/Startup.cs b/DeepDrftPublic.Client/Startup.cs index fa8ed89..b93c098 100644 --- a/DeepDrftPublic.Client/Startup.cs +++ b/DeepDrftPublic.Client/Startup.cs @@ -19,6 +19,7 @@ public static class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress) diff --git a/DeepDrftPublic.Client/ViewModels/TrackDetailViewModel.cs b/DeepDrftPublic.Client/ViewModels/TrackDetailViewModel.cs new file mode 100644 index 0000000..a6b6aeb --- /dev/null +++ b/DeepDrftPublic.Client/ViewModels/TrackDetailViewModel.cs @@ -0,0 +1,45 @@ +using DeepDrftModels.DTOs; +using DeepDrftPublic.Client.Services; + +namespace DeepDrftPublic.Client.ViewModels; + +public class TrackDetailViewModel +{ + public ITrackDataService TrackData { get; } + + public TrackDto? Track { get; set; } + public bool IsLoading { get; set; } = true; + public bool NotFound { get; set; } + + public TrackDetailViewModel(ITrackDataService trackData) + { + TrackData = trackData; + } + + public async Task Load(string entryKey) + { + // Idempotent across navigations: the scoped instance may be reused, so reset + // every flag before the fetch rather than relying on construction defaults. + IsLoading = true; + NotFound = false; + Track = null; + + try + { + var result = await TrackData.GetTrack(entryKey); + + if (result.Success && result.Value is not null) + { + Track = result.Value; + } + else + { + NotFound = true; + } + } + finally + { + IsLoading = false; + } + } +} diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css index c4616b4..733d61f 100644 --- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css +++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css @@ -271,3 +271,79 @@ h2, h3, h4, h5, h6, } } +/* ============================================================================= + 14. TRACK DETAIL PAGE + ============================================================================= */ + +.deepdrft-track-detail-container { + max-width: 760px; + margin: 0 auto; + padding: 3rem 1.5rem 4rem; +} + +.deepdrft-track-detail-back { + display: inline-flex; + align-items: center; + gap: 4px; + margin-bottom: 1.5rem; + opacity: 0.65; + transition: opacity 0.15s ease; +} + +.deepdrft-track-detail-back:hover { + opacity: 1; +} + +/* Square cover frame — the placeholder MudPaper fills it. */ +.deepdrft-track-detail-cover { + aspect-ratio: 1 / 1; + max-width: 360px; + margin: 0 auto; + overflow: hidden; + box-shadow: 0 8px 28px color-mix(in srgb, var(--mud-palette-text-secondary) 18%, transparent); +} + +/* Stat-card parallel: elevated surface with a soft secondary wash, album icon centered. */ +.deepdrft-track-detail-cover-placeholder { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--mud-palette-surface); +} + +.deepdrft-track-detail-cover-placeholder .mud-icon-root { + font-size: 72px; +} + +.deepdrft-track-detail-masthead { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 2rem 0 1.5rem; +} + +.deepdrft-track-detail-meta { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1.5rem; +} + +/* Small-caps mono labels match the caption/overline typography override. */ +.deepdrft-track-detail-meta .mud-typography-overline { + font-variant: small-caps; + opacity: 0.6; + font-family: var(--deepdrft-font-mono) !important; +} + +/* display:contents so the anchor wraps the card's cover and title without + introducing its own box — the container's positioning context and the + content column's flex layout are both preserved. */ +.deepdrft-track-card-link { + display: contents; + text-decoration: none; + color: inherit; +} + diff --git a/DeepDrftShared.Client/Components/TrackCard.razor b/DeepDrftShared.Client/Components/TrackCard.razor index 379d565..18fa9b4 100644 --- a/DeepDrftShared.Client/Components/TrackCard.razor +++ b/DeepDrftShared.Client/Components/TrackCard.razor @@ -1,6 +1,27 @@ +@{ + var hasLink = !string.IsNullOrEmpty(TrackModel?.EntryKey); + var trackHref = hasLink ? $"/track/{TrackModel!.EntryKey}" : null; +} +
- @if (!string.IsNullOrEmpty(TrackModel?.ImagePath)) + @* Cover and title/artist link to the detail page; the play button (below, outside any + anchor) stays the sole playback entry point. display:contents keeps the grid intact. *@ + @if (hasLink) + { + + @if (!string.IsNullOrEmpty(TrackModel?.ImagePath)) + { +
+
+ } + else + { +
+ } +
+ } + else if (!string.IsNullOrEmpty(TrackModel?.ImagePath)) {
@@ -12,17 +33,36 @@
- + + } + else + { +
+ + @TrackModel?.TrackName + + + + @TrackModel?.Artist + +
+ }
@if (!string.IsNullOrEmpty(TrackModel?.Album)) From 93d9b47a6725c439dcb7810ec24524c8c39a82fb Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 6 Jun 2026 16:45:07 -0400 Subject: [PATCH 2/2] fix: TrackDetail render mode, pause, and secondary text color --- DeepDrftPublic.Client/Pages/TrackDetail.razor | 3 +-- DeepDrftPublic.Client/Pages/TrackDetail.razor.cs | 9 ++++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/DeepDrftPublic.Client/Pages/TrackDetail.razor b/DeepDrftPublic.Client/Pages/TrackDetail.razor index 09befe3..33c0d14 100644 --- a/DeepDrftPublic.Client/Pages/TrackDetail.razor +++ b/DeepDrftPublic.Client/Pages/TrackDetail.razor @@ -1,5 +1,4 @@ @page "/track/{EntryKey}" -@rendermode InteractiveWebAssembly @(ViewModel.Track?.TrackName ?? "Track") - DeepDrft @@ -24,7 +23,7 @@ else if (ViewModel.NotFound)
Track not found. - + This track may have been moved or removed.
diff --git a/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs b/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs index 1c1e4c0..5111d20 100644 --- a/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs +++ b/DeepDrftPublic.Client/Pages/TrackDetail.razor.cs @@ -67,9 +67,12 @@ public partial class TrackDetail : ComponentBase, IDisposable { if (ViewModel.Track is null) return; - // Resume the current track if it's merely paused; otherwise stream the new selection. - // SelectTrackStreaming is the live entry point — the buffered SelectTrack path is dead. - if (PlayerService.CurrentTrack?.Id == ViewModel.Track.Id && PlayerService.IsPaused) + var isThisTrack = PlayerService.CurrentTrack?.Id == ViewModel.Track.Id; + + // Toggle play/pause if this track is already the active one (playing or paused); + // otherwise start a fresh stream. SelectTrackStreaming is the live entry point — + // the buffered SelectTrack path is dead. + if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused)) { await PlayerService.TogglePlayPause(); }