Files
daniel-c-harvey 2af0d8650b fix(telemetry): first-party fetch for play/share, beacon only on unload
Route normal play closes (end/switch/stop) and all shares through a same-origin
HttpClient POST so privacy-hardened browsers stop blocking them; keep sendBeacon
for the tab-unload edge. Rename the JS module off telemetry/beacon to session/
lifecycle so the retained fallback isn't name-matched. No new data or identifiers.
2026-06-26 21:11:43 -04:00

56 KiB
Raw Permalink Blame History

CLAUDE.md - DeepDrftPublic.Client

Guidance for working in the DeepDrftPublic.Client project (the Blazor WebAssembly assembly).

See the root CLAUDE.md for full architecture overview. This file covers what is specific to this project.

One-line purpose

All interactive UI for the site. Blazor WebAssembly. Pages, controls, the streaming audio player stack, theme/dark-mode plumbing, HTTP clients for both backends.

Actual structure

  • Pages/: Routable components. Home.razor (hero/about), SessionDetail.razor (session detail — hero-dominant overlay composition rendered via <ReleaseHeroOverlay>: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses MudContainer MaxWidth="Large" with .dd-detail-fill so the ambient visualizer reads full-screen and the footer is pushed below the fold; does not compose ReleaseDetailScaffoldPlayTrack is wired directly in its own @code block; mounts <WaveformVisualizer> ambient engine + <WaveformVisualizerControlPopover> directly; Phase 20: top action row carries <TheaterModeToggle Available="ShowTheaterToggle" /> immediately left of the lava-lamp popover in a .dd-detail-top-actions cluster — the toggle only appears when this page's release is the one currently playing (ShowTheaterToggle from ReleaseDetailBase folds in the subsystem gate + release-playing check); hero overlay and <ReleaseDescription> are wrapped in a .dd-theater-collapsible / .dd-theater-collapsible-inner pair that gets .dd-theater-collapsed when IsContentHidden is true — eased collapse via grid-template-rows: 1fr → 0fr + opacity + visibility (no hard @if pop); renders <ReleaseDescription> below the hero for the release's description blurb), MixDetail.razor (mix detail — composes ReleaseDetailScaffold with TopRightAction lava-lamp <WaveformVisualizerControlPopover>; hero+meta rendered via <ReleaseHeroOverlay Class="mix-hero"> in the scaffold's Hero slot with ShowHeader="false" suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed <WaveformVisualizer> is the mode-A centerpiece mounted by the page directly; the foreground container carries .dd-detail-fill; renders <ReleaseDescription> below the hero for the release's description blurb; Phase 20: TopRightAction slot holds <TheaterModeToggle Available="ShowTheaterToggle" /> + lava-lamp popover in a .dd-detail-top-actions cluster — toggle only appears when this Mix is the playing release; hero overlay and description are wrapped in .dd-theater-collapsible / .dd-theater-collapsed eased collapse driven by IsContentHidden), CutDetail.razor (album detail — composes ReleaseDetailScaffold with the Ambient slot carrying <WaveformVisualizer> + <WaveformVisualizerControlPopover> for mode-B ambient layer; the scaffold is wrapped in a .dd-detail-fill div; renders <ReleaseDescription> below the hero for the release's description blurb; each track row carries a per-track <SharePopover EntryKey="@track.EntryKey" /> aligned far-right as the last flex child of .cut-detail-track-row; Phase 20: TopRightAction slot holds <TheaterModeToggle Available="ShowTheaterToggle" /> + lava-lamp popover in a .dd-detail-top-actions cluster — toggle only appears when this Cut is the playing release; header and track-list body are each wrapped in a .dd-theater-collapsible / .dd-theater-collapsed eased collapse driven by IsContentHidden, replacing the prior hard @if), FramePlayer.razor (embeddable iframe player at /FramePlayer, uses EmbedLayout; two mutually-exclusive modes via query params: TrackEntryKey stages a single track as before; ReleaseEntryKey resolves the release's ordered tracks via FramePlayerViewModel, stages track 0 via PlayerService.StageTrack, and arms the queue via Queue.Arm — no JS interop in either path, so both run safely during prerender; the first play gesture in AudioPlayerBar routes through Queue.Start() which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). No demo pages (Counter.razor, Weather.razor do not exist).
  • Layout/: MainLayout.razor (root layout, wraps in AudioPlayerProvider, hosts theme switcher), DeepDrftMenu.razor (branded menu bar), NavMenu.razor (nav list), Pages.cs (centralised nav index — MenuPages for header, AllPages for exhaustive list), DeepDrftFooter.razor (site footer — logo, nav links, copyright; contains a "Privacy" button that opens a screen-centered tinted modal via MudOverlay (DarkBackground="true", Modal="true") carrying the anonymous-listener privacy note; trigger-button styling in the co-located DeepDrftFooter.razor.css, overlay chrome in the global deepdrft-styles.css; follows the QueueOverlay/WaveformVisualizerControlPopover MudOverlay idiom — scrim-click closes, panel stops propagation).
  • Controls/: Reusable components.
    • TrackCard.razor: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via IsPaused parameter.
    • TracksGallery.razor: Responsive grid of TrackCard items (MudBlazor MudGrid with breakpoints). Fully controlled by parent; derives active-track state from cascaded player service.
    • AppNavLink.razor: Nav link with active-page highlight.
    • AudioPlayerProvider.razor: Cascading host for IStreamingPlayerService. Everything inside it gets the player via [CascadingParameter].
    • StreamNowButton.razor: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming — routes through IQueueService.PlayTrack (deque PLAY semantics) when the queue cascade is present, falls back to IStreamingPlayerService.SelectTrackStreaming when absent. Accepts ButtonClass and ButtonLabel for distinct visual presentations; OnStreamStarted EventCallback for post-stream side effects (e.g., mobile menu close).
    • AudioPlayerBar.razor: Dock UI at the bottom (play/pause/seek/volume). In Fixed (embed) mode, renders an always-shown read-only queue panel below the controls when ShowFixedPanel && _fixedPanelOpen (release embeds only; single-track embeds stay panel-free). The Queue button in Fixed mode toggles _fixedPanelOpen and triggers a postHeight call via embed-frame.ts so the host page can resize the outer iframe. TypeScript counterpart for the resize handshake: DeepDrftPublic/Interop/embed/embed-frame.ts — reads EmbedId from window.location.search, exports postHeight(element) which measures the player element and posts {type:"deepdrft-embed-resize", height, embedId?} to window.parent; no-ops when not framed (compiled output gitignored). Phase 20: injects WaveformVisualizerControlState and subscribes to Changed (added alongside the existing IPlayerService.StateChanged subscription — same reference-guard + dispose pattern); mounts <NowShowingPanel Release="CurrentTrack.Release" /> above the transport controls when CurrentTrack?.Release is not null — the panel is kept always mounted whenever a release is playing and wrapped in the shared .dd-theater-collapsible / .dd-theater-collapsible-inner pair; it gets .dd-theater-collapsed when Theater Mode is OFF, so the bar grows/shrinks via the same eased collapse that the detail-page content regions use rather than popping via @if (Phase 20 Wave 2).
    • AudioPlayerBar/PlayerControls.razor: Play/pause/stop buttons in the transport zone. Renders via <PlayStateIcon>. In embedded (Fixed) mode, skip-previous and skip-next render when !Fixed || HasPrevious || HasNext — so a release embed (which has a queue) shows forward/back navigation while a single-track embed (no queue) hides them; the Stop button is hidden in all embed contexts (!Fixed only).
    • AudioPlayerBar/TrackMetaLabel.razor: Now-playing track-title + artist row. Takes [Parameter] bool Fixed (passed from AudioPlayerBar.razor). When Fixed (embedded iframe), the track-title anchor renders with target="_blank" rel="noopener noreferrer" so clicking it opens the release detail page in a new tab; the docked (non-embedded) player keeps same-tab nav. When no release is attached the title renders unlinked in both modes.
    • AudioPlayerBar/NowShowingPanel.razor: Phase 20 "now showing" presentational band rendered by AudioPlayerBar only when VisualizerControlState.TheaterMode && CurrentTrack?.Release is not null. Carries the release identity the hidden detail page would otherwise show: cover art thumbnail (deepdrft-track-detail-cover-art / deepdrft-gradient-soft-secondary placeholder), release title linked via ReleaseRoutes.DetailHref(Release), and a release-mode SharePopover (ReleaseEntryKey + ReleaseMedium) wrapped in .dd-accent-icon. [Parameter, EditorRequired] ReleaseDto Release — non-null by the bar's mount gate. Purely presentational: owns no player logic, no Theater state, and no data fetch. Layout CSS lives in AudioPlayerBar.razor.css (.now-showing / .now-showing-cover / .now-showing-cover-art / .now-showing-cover-placeholder / .now-showing-title-link / .now-showing-title / .now-showing-share); all surface/text binds --deepdrft-page-* theme-aware aliases — no new dark overrides.
    • AudioPlayerBar/PlayStateIcon.razor: Icon button encapsulating service subscription + transport-state icon selection. Injects IPlayerService, subscribes to StateChanged, calls PlaybackIcons.Resolve() to determine icon and active state.
    • AudioPlayerBar/LevelMeterFab.razor: Floating-action button replacing the static FAB in the minimized dock. Renders a continuous vertical fill inside the music-note silhouette that tracks live audio level (0100%), with fixed three-zone gradient (green 060%, yellow 6085%, orange 85100%). Note silhouette always visible at 25% opacity; idle when paused/stopped. Reuses spectrum-callback infrastructure.
    • SpectrumVisualizer.razor: Bar-graph spectrum display, driven by getSpectrumData JS callback.
    • ReleaseHeroOverlay.razor: Shared presentational overlay shell consumed by both SessionDetail and MixDetail. Renders a background-image hero region with genre/date/share overlaid at the top and title/artist/play at the bottom. Parameters: HeroImageKey, PlaceholderIcon, CoverThumbKey (optional cover thumb in bottom row), Title, Artist, Genre, ReleaseDate, ShareContent (slot), PlayContent (slot), Class (per-page aspect/sizing override). Owns no player logic or data fetch; each consuming page passes its own play and share slots. Overlay shell is plain <div>s; background-image surface is a <div class="release-hero-img"> (no MudPaper).
    • ReleaseDescription.razor: Shared presentational blurb block for release detail pages. Renders a header row (mono uppercase eyebrow label "Notes" in .deepdrft-release-description-label + thin horizontal divider in .deepdrft-release-description-rule, inside .deepdrft-release-description-header) above the release's short Description paragraph (.deepdrft-release-description-text, display-serif weight 300), all wrapped in .deepdrft-release-description, placed just below the hero/header. The header row mirrors the home page's .section-label/.divider-line motif. Purely presentational — owns no data fetch or player wiring. A null/whitespace Description renders nothing at all (no block, no divider, no whitespace artifact; the guard lives here so every consumer gets it for free). Parameter: Description (string?).
    • WaveformVisualizer.razor: The single WebGL2 lava-lamp visualizer engine. Hosts the waveform of whatever track is currently playing/selected. Three hosting modes: mode A (Mix detail — full-bleed centerpiece), mode B (Cut/Session detail — ambient layer behind hero+content via ReleaseDetailScaffold's Ambient slot), mode C (NowPlaying hero panel — full-bleed background for the home hero's right side, mounted by NowPlaying.razor inside .np-visualizer-bg). [Parameter] bool Fill switches from fixed-viewport positioning to container-relative sizing (CSS-only; the renderer is identical in both modes). The bridge resolves the current track's EntryKey and re-fetches the high-res datum on track change. Subscribes to WaveformVisualizerControlState.Changed and pushes each updated dial to the WebGL module via JS interop. Follows the live playing track (keys on host TrackId match OR shared host ReleaseEntryKey).
    • WaveformVisualizerControls.razor: The waveform visualizer control panel (content hosted by WaveformVisualizerControlPopover). Phase 15 re-layout: a deterministic three-row sectioned layout encoding the visualizer's two subsystems. Row 1 (MODE, always visible): two iconographic lamp toggles (lava on/off, waveform on/off) left-aligned + collisions knob (conditional — only when both subsystems on) + color knob pinned far-right. Row 2 (LAVA, visible only when LavaEnabled): "LAVA:" section label + Gravity / Heat / FluidAmount / FluidViscosity knobs. Row 3 (WAVE, visible only when WaveformEnabled): "WAVE:" section label + scroll-speed MudSlider (not a knob) + width knob pinned far-right. Total: two lamp toggles, seven RadialKnobs, one MudSlider. Colour principle: lamp toggles / knob arcs / slider are green (Color.Primary — interactive); section labels / knob caption icons are light (static). Each control has a playful MudTooltip. [Parameter] bool PanelChrome scopes panel chrome (NowPlayingCard look — square corners, lighter-navy, thin border) to the popover mount; chrome classes live in the global deepdrft-styles.css (CSS isolation cannot reach portaled overlay content). [Parameter] bool Visible gates the rows via @if while the container holds reserved min-height. Owns no JS interop: mutates the injected WaveformVisualizerControlState and raises Changed. No control is a seek surface (read-only contract).
    • WaveformVisualizerControlPopover.razor: Pairs the lava-lamp icon button with WaveformVisualizerControls as a screen-centered tinted modal (Phase 15). The primitive is MudOverlay (DarkBackground="true", Modal="true") — not MudPopover; AnchorOrigin/TransformOrigin parameters do not exist (a centered modal has no anchor). Clicking the lava-lamp icon opens the overlay; clicking the scrim closes it (knob-drag-safe: RadialKnob's position:fixed capture div sits above the scrim during a drag, so releasing outside the panel never fires the close handler). The panel stops click propagation so an inside click is not a dismissal. [Parameter] Size IconSize controls the trigger-icon size (default Large). This is the unit every host places — one icon anywhere gives the full control panel centered on screen, regardless of where the icon sits. Placed identically on Mix, Cut, Session, and the NowPlaying hero panel (full parity; in NowPlaying it sits in .np-visualizer-controls at the panel's top-right corner, not inside NowPlayingCard).
    • TheaterModeToggle.razor: Phase 20 Theater-Mode toggle button. Visible only when Available && (State.LavaEnabled || State.WaveformEnabled) — no visualizer subsystem active → no theater to enter; Available is false when this page's release is not the one currently playing (Phase 20 Wave 2). Disabled until interactive (!RendererInfo.IsInteractive), same guard as Play and the lava-lamp trigger. On click: flips WaveformVisualizerControlState.TheaterMode and calls NotifyChanged(). Shows an on/off aria-pressed active state. Glyph: Material Theaters. .dd-accent-icon container gives the green-accent glyph in both themes with zero new CSS — same treatment as WaveformVisualizerControlPopover. Subscribes to State.Changed in OnInitialized and unsubscribes on Dispose to re-render when another observer (e.g. CoerceTheaterMode()) flips the state. [Parameter] Size IconSize (default Large) matches the adjacent lava-lamp trigger. [Parameter] bool Available (default true) — the page passes its ShowTheaterToggle predicate here so the toggle is scoped to the playing release; surfaces with no release-scoping pass the default true. Placed immediately left of the lava-lamp popover on all three detail pages inside a .dd-detail-top-actions cluster.
    • WaveformZoomMapping.cs: Maps the WaveformVisualizerControlState.Resolution fraction to an integer zoom level for the WebGL renderer.
    • NowPlayingCard.razor: Home-page text panel showing the currently playing track (label, title, sub-line). Renders label/"Now Playing" dot, track name, and artist·release sub-line from the cascaded IStreamingPlayerService. Subscribes to IPlayerService.StateChanged in OnParametersSet (reference-guarded, idempotent) and unsubscribes on dispose to re-render on track/state change. No visualizer or popover; those moved to NowPlaying.razor.
    • NowPlayingStats.razor: Home hero stat row. Three cards: Studio Cuts (total Cut-medium track count + zero-suppressed per-ReleaseType Cut release breakdown), Mixes (MixReleaseCount labelled "Sets" + hh:mm total mix runtime via RuntimeFormat), and Plays (live TotalPlays odometer in .hero-stat-odometer + UniqueListeners "N listeners" secondary line via .hero-stat-sub — Phase 16 wave 16.5). All three cards read from the same HomeStatsDto round-trip; no extra fetch path. Fetches via IStatsDataService on init; bridges the prerender fetch across the WASM seam with PersistentComponentState (persists only on a successful load, matching the medium-browse bridge pattern). Implements IDisposable to release the PersistingComponentStateSubscription.
    • NowPlaying.razor: Owns the home hero's right-side panel (.now-playing-panel — the outer wrapper formerly called .hero-right in Home.razor). Mounts <WaveformVisualizer Fill="true"> as a full-bleed background inside .np-visualizer-bg, <WaveformVisualizerControlPopover> in .np-visualizer-controls (top-right corner), the three pulsing .circle-deco rings, and the content layer (hosts <NowPlayingCard> + <NowPlayingStats>). Home.razor's MudItem renders <NowPlaying /> directly with no wrapper. Subscribes to IPlayerService.StateChanged in OnParametersSet (reference-guarded, idempotent) and unsubscribes on dispose — needed because the player cascade is IsFixed (the provider's own re-render does not reach NowPlaying), so the subscription is the only way to re-render and re-propagate ReleaseEntryKey/TrackId/TrackEntryKey into <WaveformVisualizer> when the playing track changes.
    • QueueList.razor: Shared presentational queue-list component (Phase 17 wave 17.1). Renders Items as an ordered list with the current track marked; Editable flag gates drag-reorder handles (drag handle icon + MudDropContainer/MudDropZone for reorder) and per-row remove controls. The remove (×) control is suppressed on the currently-playing row (Editable && !isCurrent) — the current track cannot be removed via the UI (wave 17.2; reorder of the current row is still permitted). When not editable, renders a plain <div> — the read-only state for the embed's fixed-order shared queue. Reorder, remove, and row-jump are surfaced to the parent as EventCallback<(int FromIndex, int ToIndex)> OnReorder, EventCallback<int> OnRemove, and EventCallback<int> OnJump; the component calls no IQueueService method itself (purely presentational, no data fetch, no player wiring). Both view modes (docked overlay 17.2, embedded panel 17.3) consume this single component differing only in hosting context and the Editable flag. Runs during prerender without JS interop (drag work is client-only and inert when no drag occurs).
    • QueueOverlay.razor: Screen-centered tinted modal hosting the docked-player editable queue (Phase 17 wave 17.2). Borrows the WaveformVisualizerControlPopover MudOverlay idiom (DarkBackground="true", Modal="true"): the panel stops click propagation; scrim-click closes the overlay; drag-safe (the panel's capture div sits above the scrim during a drag so releasing outside the panel never fires the close handler). Auto-closes when a removal empties the queue. Hosts QueueList in Editable="true" mode. Opened/closed by the Queue toggle button in PlayerTransportZone (shown only when !Fixed && Items.Count > 0; QueueMusic glyph, active state when open).
    • AddToQueueButton.razor: Append-only Add-to-Queue button shared across detail-page play sites (Phase 17 wave 17.4). Two modes: track mode (calls IQueueService.Enqueue with a single TrackDto) and release mode (calls IQueueService.EnqueueRange with an ordered track list). Material PlaylistAdd glyph; tooltip "Add to queue" (track mode) / "Add release to queue" (release mode). Reads the cascaded IQueueService; disabled until interactive or when the cascade is absent. Append-only — does not play, does not navigate. Placed at: CutDetail header (release mode, TrackNumber-ordered list), CutDetail track rows (track mode), SessionDetail hero play (track mode), MixDetail hero play (track mode). Excluded from StreamNowButton (OQ9) and ReleaseGallery cards (OQ10, deferred).
    • ReleaseDetailScaffold.razor: Shared scaffold for release detail pages. Gained an optional Ambient RenderFragment slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts <WaveformVisualizer> + <WaveformVisualizerControlPopover> here; Mix uses its own full-bleed mount outside the scaffold. The scaffold's default masthead PLAY (PlayTrack) routes through IQueueService.PlayTrack (deque PLAY semantics — prepends the track to the queue front) when the queue cascade is present, falling back to IStreamingPlayerService.SelectTrackStreaming when absent; toggle-pause is handled directly via IStreamingPlayerService.TogglePlayPause when this track is already active.
    • SharePopover.razor: Share affordance serving both track and release surfaces from one clipboard/popover-chrome source. Track mode (EntryKey set): copies the track's canonical URL and offers an iframe embed snippet pointing at FramePlayer?TrackEntryKey=…. Release mode (ReleaseEntryKey + ReleaseMedium set): copies the release's canonical detail URL (via ReleaseRoutes.DetailHref) and offers an iframe embed snippet pointing at FramePlayer?ReleaseEntryKey=…, which queues and auto-advances through the release's tracks on first play. Both modes offer the embed affordance — release mode no longer suppresses it. The iframe snippet is built by EmbedSnippetBuilder. A transient "Copied!" confirmation resets after a short delay.
    • SeoHead.razor: Purely presentational SEO head emitter (Phase 22). Renders a <PageTitle> + <HeadContent> block from a single SeoModel parameter — standard meta (description, robots), canonical, Open Graph, Twitter Card, and schema.org JSON-LD. Owns no data fetch; each page wires it in one line and supplies the model from its already-bridged ViewModel state. Wired on Home, About, Cut/Session/Mix detail (incl. not-found branches → noindex), browse views, and the 404 page.
  • Helpers/: Utilities and mapper functions.
    • PlaybackIcons.cs: Static Resolve(isPlaying, isPaused, trackId, currentTrackId) method — the sole glyph-mapping source for transport icons across all surfaces. Returns (Icon, IsActive, IsPaused) tuple.
    • RuntimeFormat.cs: Static ToHoursMinutes(double totalSeconds) helper. Formats a seconds value as h:mm (hours not zero-padded, minutes always two digits). Negative / non-finite inputs return "0:00". Used by NowPlayingStats for the mix runtime figure.
    • EmbedSnippetBuilder.cs: Static helper that builds the iframe embed snippet the share popover copies. Two targets diverge in height and content (Phase 17 wave 17.3): ForTrack(baseUri, trackEntryKey) → compact <iframe> at 196 px (no queue panel, no script, unchanged from before 17.3). ForRelease(baseUri, releaseEntryKey) → taller <iframe> at 384 px plus a host-side <script> resize listener; mints a fresh random token per call (8 hex chars from Guid.NewGuid().ToString("N")[..8]) used as the iframe id (deepdrft-embed-{token}) and threaded into the iframe src as &EmbedId={token} — the in-iframe embed-frame.ts reads this token and includes it in postMessage payloads so the host listener can route resize messages to the correct iframe when multiple release embeds share a host page. The script matches on embedId and applies iframe.style.height; degrades safely (panel still works inside the iframe) if the host strips the script. Pure string composition — unit-testable without rendering. TypeScript counterpart: DeepDrftPublic/Interop/embed/embed-frame.ts (compiled output gitignored).
  • Services/: Audio player + dark-mode services.
    • IPlayerService / IStreamingPlayerService: Contracts exposed to UI.
    • AudioPlayerService: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
    • StreamingAudioPlayerService: Production implementation. Chunked stream from TrackMediaClient, adaptive 1664 KB buffer, early-playback, seek-beyond-buffer via offset request to the content API.
    • AudioInteropService: JS interop wrapper over window.DeepDrftAudio. Manages DotNetObjectReference lifetimes for progress, end-of-playback, spectrum callbacks.
    • Dark-mode services: DarkModeServiceBase (cookie name constant), DarkModeCookieService (JS cookie read/write).
    • WaveformVisualizerControlState: Scoped session-persistent holder for the visualizer's eight continuous control positions, two subsystem on/off toggles (Phase 15), and one Theater-Mode flag (Phase 20): ScrollSpeed, GradientRotationSpeed, LavaGravity, LavaHeat, FluidAmount (wax count/volume), FluidViscosity (cohesion — the second half of the Phase 10 "bubbles" split; BlobDensity is gone), CollisionStrength, WaveformWidth, LavaEnabled (bool, default true), WaveformEnabled (bool, default true), TheaterMode (bool, default falseDefaultTheaterMode). Each has a matching Default* const. Changed event is the decoupling seam — controls mutate state + raise Changed; the bridge (WaveformVisualizer) subscribes and pushes the affected uniform or subsystem-enable; the Theater observers (the three detail pages and AudioPlayerBar) subscribe to react to TheaterMode. CoerceTheaterMode(): enforces the invariant that Theater Mode cannot remain on when both subsystems are off — called from WaveformVisualizerControls.ToggleLava/ToggleWaveform before NotifyChanged() so all observers see a consistent, coerced state in the same Changed cycle. ApplyCapabilityDefault(bool hardwareAccelerated): one-time scoped capability default (guarded by _capabilityDefaultApplied; never re-applies on SPA navigation, never overrides an explicit in-session toggle). When hardwareAccelerated is false (positive software-renderer match from hwAccel.ts's UNMASKED_RENDERER_WEBGL probe, or total WebGL failure) sets LavaEnabled = false while leaving WaveformEnabled at its default on, then calls CoerceTheaterMode() + NotifyChanged() once so all observers see the default in a single cycle. Called by the visualizer bridge on first interactive render once JS interop (the HW-accel probe via detectHardwareAcceleration() exported from WaveformVisualizer.ts) is available; a no-op when HW accel is present. TheaterMode is a page-chrome presentation flag; the visualizer bridge ignores it. Scoped DI so state survives SPA nav within a session and resets on fresh page load. Phase 20 Wave 2 — playing-release predicates live in ReleaseDetailBase / CutDetailBase (not in this state holder): IsThisReleasePlaying (PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey), IsContentHidden (TheaterMode && IsThisReleasePlaying), ShowTheaterToggle ((LavaEnabled || WaveformEnabled) && IsThisReleasePlaying). Both base classes also subscribe to IStreamingPlayerService.StateChanged (idempotent, reference-guarded, disposed) so the predicates re-evaluate live when playback moves between releases.
    • PlayTracker: Per-session play-session tracker (Phase 16 wave 16.1). Opens on playback start, advances a high-water position on each progress tick (from StreamingAudioPlayerService — not the HTTP layer, so seek-beyond-buffer re-fetches are the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3 s OR ≥5% of duration. Three-bucket classification (partial/sampled/complete). Emits at most one event per session via IPlayEventSink. No player or JS dependency — testable against a fake sink.
    • ShareTracker: Per-session share tracker (Phase 16 wave 16.1). Called by SharePopover after a successful clipboard write; applies a 60-second per-(target, channel) debounce. A share is always a live-page user interaction (never a tab-unload), so it sends via the first-party IEventPoster fetch only — no sendBeacon arm. Scoped so debounce memory resets on fresh page load. Wave 16.3: injects IAnonIdProvider; attaches _anonId.Current to ShareEventDto.AnonId (omitted when null).
    • IEventPoster / HttpEventPoster: First-party same-origin event transport (telemetry transport-resilience). PostAsync(url, json) POSTs an application/json body to the host's own api/event/* proxy via a default IHttpClientFactory client, best-effort and non-throwing. A first-party fetch is not name-matched by tracking/fingerprinting heuristics the way a telemetry/beacon sendBeacon module is — this is the transport for normal play closes (end/switch/stop) and every share. One seam so the play sink and share tracker share it and tests capture the wire payload behind a fake.
    • BeaconInterop: navigator.sendBeacon JS interop wrapper over window.DeepDrftLifecycle (served from js/session/lifecycle.js). After the transport-resilience split it is the unload-edge transport only: it fires the play payload via sendBeacon on page tear-down (where an awaited fetch would be cancelled) and wires the page-unload handler. Normal closes go over IEventPoster. Named off the former telemetry/beacon path so the retained fallback isn't name-matched either.
    • BeaconPlayEventSink: Production IPlayEventSink (Phase 16 wave 16.1; transport-resilience split). Serializes the play classification once and dispatches down the arm the close chose: EmitPlayAsync over the first-party IEventPoster (normal close: organic end / track-switch / stop, page alive) and EmitPlayOnUnload over BeaconInterop sendBeacon (tab-unload edge). Both arms send byte-identical payloads. PlayTracker.Close(bool viaUnload) selects the arm — OnPageUnload passes viaUnload: true, every other close defaults to fetch. Wave 16.3: injects IAnonIdProvider; reads _anonId.Current synchronously at emit time and sets PlayEventDto.AnonId (omitted when null via WhenWritingNull).
    • IAnonIdProvider / AnonIdProvider: Wave 16.3 anonymous-listener id seam. IAnonIdProvider exposes string? Current (synchronous cached read, safe on the unload path) and ValueTask EnsureLoadedAsync() (warms the cache from localStorage via window.DeepDrftAnonId.get JS interop — idempotent, never throws). AnonIdProvider is the production implementation; degrades to null when localStorage is unavailable (private mode / blocked storage). The token itself outlives the session in localStorage; the in-process cache is scoped (resets on fresh page load). Callers warm the cache when going interactive, then read Current synchronously on the close/unload path with no extra JS hop. TypeScript interop: DeepDrftPublic/Interop/session/anonid.ts (exposes window.DeepDrftAnonId, served from js/session/anonid.js; mints GUID on first visit, returns null without throwing when storage is unavailable).
    • IQueueService / QueueService: Two-level deque orchestrator above the single-slot player. The deque has two entry ends. PLAY (manual) enters the FRONT: PlayTrack(track) and PlayRelease(tracks, startIndex) prepend the played track/release in order, remove the previously-current track, make the new front current, start streaming it, and leave whatever sat after the old current intact behind the prepend (a whole release prepends in order in one op). The detail pages (Cut header/row, Session/Mix hero) and StreamNowButton route their PLAY through these. Add-to-queue enters the BACK: Enqueue/EnqueueRange append to the end without interrupting the current track (AddToQueueButton). Next/Previous advance or step back, walking CurrentIndex and leaving played tracks behind so Previous can reach them; JumpTo(index) moves the pointer to a queued row and streams it once (the playlist panel's row-jump — it does NOT prepend or stream the intervening rows). End-of-track: auto-advance (TrackEnded) advances when there is a next track; when the last track ends naturally the queue empties and goes dormant (bug #2) rather than stranding the finished track. Clear empties the queue. Bug #3 (dormant-seed): the first Enqueue/EnqueueRange into a dormant queue while a track is already playing externally (via the attached player, not through the queue) seeds the head with that now-playing track and then appends — yielding [now-playing, added] (even when adding the same track). The queue learns the externally-playing track through the existing Attach(player) seam (_player.CurrentTrack) — no new dependency, no IServiceProvider. Armed-idle state (prerender-safe release embeds): Arm(tracks) replaces the queue at index 0 with no JS interop; IsArmed signals armed-but-not-streaming; Start() streams the current track and clears IsArmed. AudioPlayerBar reads IsArmed to route the embed's first play gesture through Start(). QueueChanged fires on all list/position changes; cascaded via AudioPlayerProvider. Move/RemoveAt are interop-free reorder/remove mutations that adjust CurrentIndex and never re-stream. ClearUpcoming() keeps the current track and drops the up-next. Bug #4 (reactivity): AudioPlayerBar.QueueItems caches QueueService.Items as a _queueItemsCache snapshot (the service exposes its backing list by reference); the cache is invalidated and set to null in OnQueueChanged, so every real mutation hands QueueList a new list reference while frequent progress-tick re-renders reuse the cached one without allocating. QueueList.OnParametersSet calls _dropContainer?.Refresh() so the MudDropContainer re-reads the new list and the open panel re-flows immediately. Bug #1 (label): the docked QueueOverlay panel header reads "Playlist" (the current track stays listed). PlayRelease materializes tracks.ToList() before mutating so it can never alias the service's own Items list.
  • Clients/: HTTP API clients (both target DeepDrftAPI).
    • TrackClient: SQL metadata API. Uses named IHttpClientFactory client "DeepDrft.API". Sends page param (not pageNumber). Deserializes response as bare PagedResult<TrackDto> (not wrapped in ApiResultDto envelope).
    • TrackMediaClient: Content API. Uses named IHttpClientFactory client "DeepDrft.Content". Methods like GetAudioStreamAsync(trackId, byteOffset?)Stream with optional Range header support for seek-beyond-buffer.
    • StatsClient: Home stats API. Uses named IHttpClientFactory client "DeepDrft.API". Single method GetHomeStats()ApiResult<HomeStatsDto> (calls GET api/stats/home; response is a bare DTO, no ApiResultDto envelope). Registered scoped; consumed via IStatsDataService.
  • Services/ITrackDataService: Contract used by the visualizer bridge and other consumers. Includes GetTrackWaveform(entryKey) → high-res WaveformProfileDto (calls GET api/track/{entryKey}/waveform/high-res); used by WaveformVisualizer to re-fetch the datum on track change.
  • Services/IStatsDataService / StatsClientDataService: Home-stats read abstraction. IStatsDataService.GetHomeStats()ApiResult<HomeStatsDto>. StatsClientDataService is the single implementation (delegates to StatsClient); registered scoped. Components inject IStatsDataService so they do not branch on render mode — mirrors IReleaseDataService.
  • ViewModels/: Component state.
    • TracksViewModel: Scoped. Holds current page, page size, sort column, descending flag. SetPage(pageNumber) calls TrackClient.GetPageAsync and updates. Registered in Startup.ConfigureDomainServices.
    • TrackDetailViewModel: Scoped. Holds loaded track, loading flag, not-found flag. Load(entryKey) fetches via ITrackDataService and resets all flags per call (prevents cross-navigation bleed). Registered in Startup.ConfigureDomainServices.
    • FramePlayerViewModel: Scoped. Resolves the ordered track list for a release embed (FramePlayer?ReleaseEntryKey=…). Load(releaseEntryKey) calls IReleaseDataService.GetByEntryKeyrelease.IdITrackDataService.GetPage(sortColumn:"TrackNumber", releaseId:…), mirroring CutDetailViewModel.Load exactly so an embedded release queues the same ordered list the Cut detail page plays. Owns no playback or staging — FramePlayer.razor uses the loaded Tracks to stage and arm. Registered scoped in Startup.ConfigureDomainServices.
  • Common/: Shared utilities.
    • DarkModeSettings.cs: [PersistentState]-annotated class (single source of truth for dark mode in the client). Registered scoped.
    • ReleaseRoutes.cs: Static helper. DetailHref(long id, ReleaseMedium) returns the canonical public detail route for a release; consumed by Archive, AlbumsView, player bar, and TrackRedirect (11.B).
    • SeoModel.cs: Typed per-page SEO input (Phase 22). Named factories: ForRelease (medium-dispatched — Cut → MusicAlbum, Session → MusicAlbum/LiveAlbum, Mix → MusicRecording), ForHome, ForAbout, ForBrowse, ForNotFound. Encodes the medium→schema.org mapping in one place. SeoModel.Robots overrides the environment-default (see SeoEnvironment).
    • SeoJsonLd.cs: Typed schema.org JSON-LD builders (Phase 22). Types: MusicGroup (home/about, with sameAs: ["https://instagram.com/deepdrft.music"]), MusicAlbum/LiveAlbum (cuts/sessions, with ordered MusicRecording track list and per-release byArtist), MusicRecording (mixes, with ISO-8601 duration), CollectionPage (browse). All serialized output is inline-safe-escaped (</>/&\uXXXX) to prevent script-breakout from CMS-authored text.
    • SeoOptions.cs: Site-wide SEO config (Phase 22). BaseUrl (https://deepdrft.com), title suffix (Deep DRFT, middot separator), default OG image seam (uses ImageProxyController route), IG handle in sameAs, no Twitter handle. Registered via the static Startup seam (both server and WASM Program.cs). BaseUrl is config, not window.location — no window at server prerender, and the origin cannot be derived reliably behind the nginx proxy.
    • SeoUrls.cs: URL helpers for canonical and og:image construction from SeoOptions.BaseUrl (Phase 22).
    • SeoEnvironment.cs: Scoped [PersistentState] bridge for the server environment flag (Phase 22). Seeded in DeepDrftPublic/Components/App.razor from IWebHostEnvironment.IsProduction() — mirrors the DarkModeSettings bridge pattern. Default robots is index,follow only in Production; noindex,nofollow in every non-production environment so the beta/staging site stays uncrawled. Explicit per-page SeoModel.Robots overrides this default. Fail-safe default is noindex.
  • Program.cs: WASM entry point. Calls Startup.ConfigureApiHttpClient, ConfigureContentServices, ConfigureDomainServices.
  • _Imports.razor: Global using statements and component imports.

Two HTTP clients pattern

Both clients are configured in Startup.cs (static methods called from both server and WASM Program.cs):

  • TrackClient uses "DeepDrft.API" (base address from appsettings.json ApiUrls:SqlApi, points to DeepDrftAPI). Fetches paginated metadata.
  • TrackMediaClient uses "DeepDrft.Content" (base address from appsettings.json ApiUrls:ContentApi, points to DeepDrftAPI). Streams audio bytes, optionally with offset.

Both are configured with JSON serializer settings (case-insensitive property matching). The dual-client pattern keeps concerns separated: one for structured data, one for binary streaming. Both target DeepDrftAPI (they may share the same host URL in production).

Audio player stack (deepest part of the codebase)

Contracts

  • IPlayerService: Initialize, SelectTrack, Play, Pause, Stop, Seek, SetVolume. Sync interface. Owns EventCallback? OnStateChanged (single, provider-owned) and event Action? StateChanged (multicast, for cascade consumers).
  • IStreamingPlayerService: Extends above. SelectTrackStreaming(track) starts the chunked stream flow.

Implementation

  • AudioPlayerService (abstract base): Lifecycle. Stores current track, playback state, volume. SelectTrack throws NotSupportedException (buffered path is dead); derived classes override SelectTrackStreaming.
  • StreamingAudioPlayerService (production): Constructor takes TrackMediaClient, AudioInteropService, logger. SelectTrackStreaming:
    1. Calls TrackMediaClient.GetAudioStreamAsync(trackId), which returns a response object including ContentType (e.g., audio/wav, audio/mpeg, audio/flac).
    2. StreamingAudioPlayerService.StreamAudioAsync reads chunks (1664 KB adaptive), pushes each via AudioInteropService.ProcessStreamingChunkAsync(contentType, chunk) (JS interop call with format hint).
    3. TypeScript StreamDecoder is format-agnostic; delegates format-specific header parsing and chunked decoding to the appropriate IFormatDecoder implementation (e.g., WavFormatDecoder for WAV, TBD MP3/FLAC decoders for other formats). Decoder parses header (first chunk), decodes subsequent chunks to AudioBuffers.
    4. PlaybackScheduler schedules buffers on Web Audio AudioContext.
    5. Playback starts as soon as a configurable min buffer count is queued.
    6. Seek beyond buffer: if seek target is past the decoded range, Seek(position) calls TrackMediaClient.GetAudioStreamAsync(trackId, byteOffset) with a file-absolute byte offset. Client sends Range: bytes={offset}-; server responds 206 with raw bytes (same format as original file); decoder retains the parsed header and feeds the continuation directly into the decode pipeline.

Interop bridge

  • AudioInteropService.CreatePlayerAsync polls DeepDrftAudio.isReady() before proceeding; index.ts sets ready = true after attaching the API to window. This guards against slow WASM boot / cache misses.
  • AudioInteropService.ProcessStreamingChunkAsync(contentType, chunk) calls JS window.DeepDrftAudio.processStreamingChunk(contentType, chunk) and awaits the Promise. The contentType parameter is passed through to the format-decoder factory.
  • AudioInteropService also manages callback registrations for progress (fired by PlaybackScheduler), end-of-playback (fired by PlaybackScheduler), and spectrum data (fired by SpectrumAnalyzer). Each callback is a DotNetObjectReference to a delegate.

Format decoders (TypeScript)

New modules in DeepDrftPublic/Interop/audio/:

  • IFormatDecoder.ts: Interface. Defines contract for format-specific decoders: parseHeader(chunk, offset) → header metadata; decodeChunk(chunk, offset)AudioBuffer; getAlignedSegmentSize(chunk, offset, rawData?) → frame-aligned segment boundary (optional rawData parameter for format-specific frame-boundary scanning).
  • WavFormatDecoder.ts: Concrete WAV implementation (active). Parses RIFF/WAVE structure, fmt and data chunks. All WAV-specific byte-parsing logic lives here. Exported as the default WAV decoder.
  • Mp3FormatDecoder.ts: Concrete MP3 implementation (implemented, not yet wired). Implements IFormatDecoder for MP3: ID3v2 skip, MPEG Layer III frame-sync + header decode (MPEG1/2/2.5), Xing/Info/VBRI VBR-header detection (frame count + 100-entry TOC for seek), CBR frame-aligned segment sizing, VBR TOC-interpolation seek (calculateByteOffset), zero-copy wrapSegment (raw MP3 frames are self-contained). CBR sub-frame tail guard prevents over-read.
  • FlacFormatDecoder.ts: Concrete FLAC implementation (implemented, not yet wired). Implements IFormatDecoder for FLAC: scans all metadata blocks (STREAMINFO mandatory, SEEKTABLE optional), extracts 20-bit sample rate / 3-bit channels / 5-bit bitsPerSample / 36-bit total-samples from bit-packed STREAMINFO, builds 38-byte synthetic STREAMINFO block for per-segment wrapping, binary-search SEEKTABLE for seek. wrapSegment prepends fLaC + STREAMINFO to each audio segment so decodeAudioData sees a valid FLAC stream. getAlignedSegmentSize scans backward through peek bytes for the 0xFF/(0xF8|0xF9) FLAC frame sync so each segment ends on a real frame boundary.

StreamDecoder.ts remains the orchestrator — it accepts the first chunk, selects the right format decoder via factory (based on contentType), peeks candidate bytes before calling getAlignedSegmentSize (non-destructive read), passes them as rawData, and uses zero-copy subarray for the actual segment. It delegates all format-specific work to the decoder and chains subsequent chunks through the same decoder instance. Mp3FormatDecoder and FlacFormatDecoder are implemented modules but not yet wired into AudioPlayer.createFormatDecoder factory (Wave 3 pending).

Component integration

  • AudioPlayerProvider.razor is the cascading host. It injects IStreamingPlayerService (resolved to StreamingAudioPlayerService in DI), stores it in a cascade with IsFixed="true", and keeps it alive across navigation.
  • AudioPlayerBar.razor is the dock UI. It cascades the player, binds buttons to Play() / Pause() / Seek() / SetVolume(), and displays current time / duration / progress bar. Minimize-state mutations (Expand, ToggleMinimized, Close) all route through a private SetMinimized(bool value) mutator, which guards no-ops, fires the OnMinimized callback, and calls StateHasChanged(). Subscribes to IPlayerService.StateChanged in OnParametersSet (reference-guarded, idempotent) and unsubscribes on dispose to re-render itself when the cascade updates.
  • SpectrumVisualizer.razor calls AudioInteropService.GetSpectrumData() on a timer, receives bar heights, renders via MudBlazor MudChart or custom canvas.
  • TracksView.razor injects TracksViewModel + cascaded IStreamingPlayerService. PlayTrack(track) calls PlayerService.SelectTrackStreaming(track). Subscribes to IPlayerService.StateChanged in OnParametersSet and calls StateHasChanged() unconditionally on any state change, ensuring the gallery correctly reflects play/pause/track-change transitions. Active-track state is derived from PlayerService.CurrentTrack and PlayerService.IsPlaying (no local _selectedTrack field).

Dark-mode plumbing

  • DarkModeSettings (Common/): [PersistentState]-annotated class with IsDarkMode property. Registered scoped in Startup.ConfigureDomainServices. Single source of truth in the client.
  • DarkModeServiceBase: Holds the cookie name constant ("darkMode").
  • DarkModeCookieService: Reads/writes the cookie via JS (document.cookie interop). Calls DarkModeSettings.IsDarkMode = value when the cookie changes or user toggles the button.
  • Server-side DarkModeService (in DeepDrftPublic, not here): Reads the cookie during prerender, seeds the DarkModeSettings instance, rounds it through PersistentComponentState to the client.
  • MainLayout.razor: Wraps entire layout in CascadingValue of DarkModeSettings, so all children see the current dark-mode state. The dark-mode toggle button (hand-rolled lit/unlit gas-lamp icon from DeepDrftShared.Client/Common/DDIcons.cs) calls DarkModeCookieService.ToggleDarkModeAsync().

The flow ensures the first paint uses the correct theme (no flash), and toggling the button persists the setting to a 365-day cookie.

SeoEnvironment follows the same [PersistentState] bridge pattern (Phase 22). It is seeded server-side in DeepDrftPublic/Components/App.razor from IWebHostEnvironment.IsProduction() and bridged to the WASM client. Consumers (SeoHead) read SeoEnvironment.IsProduction to gate the default robots directive (index,follow in Production, noindex,nofollow elsewhere). The pattern is identical to DarkModeSettings — one server-side seed, one PersistentComponentState round-trip, one scoped client read.

MVVM convention

Component state lives in ViewModels (registered scoped in DI). Components render and dispatch only.

  • TracksViewModel: Holds page number, page size, sort column, descending flag. SetPage(pageNumber) is the command. TracksView.razor injects it and calls SetPage.
  • New VMs go in ViewModels/ and register in Startup.ConfigureDomainServices.

Theming convention

  • Bespoke PaletteLight / PaletteDark defined inline in MainLayout.razor (MudBlazor theme objects).
  • CSS classes prefixed deepdrft- live in DeepDrftPublic/wwwroot/styles/deepdrft-styles.css (shared across server and client).
  • Custom SVG icons: DeepDrftShared.Client/Common/DDIcons.cs (hand-rolled gas-lamp, lava-lamp, etc. — shared across public and CMS surfaces).

Interactive-accent icons (.dd-accent-icon / .dd-accent-fill)

Green-accent interactive icon affordances (Play / Share / Add-to-Queue / lava-lamp trigger, etc.) use a single reusable treatment in deepdrft-styles.css, not per-site dark overrides. Wrap the affordance(s) in a container carrying .dd-accent-icon; the rule colours the inner .mud-icon-root glyph green-accent (--deepdrft-green-accent, the brand constant — same value in both palettes) in both themes. Add .dd-accent-fill to the same container when it also holds a filled Color.Secondary MudButton whose fill must go green-accent in dark (dark-only — light already renders green fill + white text).

Two reasons this is needed and why it's a class, not a palette colour: (1) no MudBlazor Color enum is green in both themes (Dark.Secondary is off-white), so palette-only solutions can't express "green in both"; (2) MudBlazor stamps the standalone rule .mud-secondary-text { color: …secondary !important } (0,1,0) on the glyph <svg>, so wrapper-level overrides never reach it — the reusable rule targets .dd-accent-icon .mud-icon-button .mud-icon-root (0,3,0) !important, which beats it on specificity alone; source order is not load-bearing for the glyph clause. The Session/Mix release-detail hero Share/Play glyphs use this class too: they were already green-accent in light (via Color.SecondaryLight.Secondary), so folding them in keeps light pixel-identical while fixing the dark over-image glyphs — they are not actually theme-divergent. Add new green-accent icon affordances by applying this class, not by spawning a new dark override.

Self-themed components are authoritative over .dd-accent-icon. PlayStateIcon owns its glyph colour inside .icon-container and must beat a surrounding .dd-accent-icon in dark — its scoped CSS rule targets .mud-icon-root at (0,5,0) !important (after Blazor's scope attribute is applied), which outranks the consolidation rule's (0,3,0) !important. Do not wrap a PlayStateIcon in .dd-accent-icon expecting to recolor its play-chip glyph — the play chip always shows navy (--deepdrft-play-glyph) against the moss-green chip in dark.

Layout-only cluster class: .dd-detail-top-actions. When two or more icon affordances sit together in a top-action row (e.g. the Theater toggle + lava-lamp popover on the three detail pages), wrap them in .dd-detail-top-actions — a layout-only display:flex; align-items:center; gap:0.25rem class in deepdrft-styles.css. No colour; prevents the SpaceBetween row from spreading the icons apart. Each affordance inside still carries its own .dd-accent-icon wrapper independently.

Full-screen detail body: .dd-detail-fill. Phase 20 Wave 2. Applied to each detail page's foreground content container (the <div> or <MudContainer> that wraps the scaffold/hero); sets min-height: calc(100vh - var(--deepdrft-nav-height, 88px)) so the ambient/full-bleed visualizer reads as genuinely full-screen and the site footer is pushed below the fold, independent of Theater Mode. Reuses --deepdrft-nav-height (88px desktop / 72px mobile) so the clearance tracks the nav bar height across breakpoints; no new layout token. Defined in deepdrft-styles.css.

Eased Theater Mode collapse: .dd-theater-collapsible / .dd-theater-collapsed. Phase 20 Wave 2. Used wherever Theater Mode should ease content in/out rather than pop via @if. The outer wrapper carries .dd-theater-collapsible (always present); its single direct child carries .dd-theater-collapsible-inner; adding .dd-theater-collapsed to the outer collapses the region. Technique: grid-template-rows: 1fr → 0fr (real-height interpolation), opacity, and visibility: hidden + transition-behavior: allow-discrete (visibility flip deferred to end of ease-out so collapsed content is removed from the tab order once the animation completes; immediately re-shown on expand). A prefers-reduced-motion block collapses instantly. Used on the release content regions in all three detail pages (IsContentHidden predicate) and on the player-bar NowShowingPanel band (collapsed when !TheaterMode). Defined in deepdrft-styles.css.

Gas-lamp toggle is self-colored in its SVG. DDIcons.GasLampLit (dark-mode icon) carries fill="#2A5C4F" directly on its frame path — no CSS colour override is needed. The former dark nav rule (.deepdrft-theme-dark .dd-nav-actions .mud-icon-button) has been removed as dead. DDIcons.GasLamp (light-mode icon) continues to use currentColor and inherits nav text colour in light (the unlit toggle is theme-divergent by design).

Development commands

# The client runs as part of the DeepDrftPublic host:
dotnet run --project DeepDrftPublic

# Watch during development (rebuilds WASM as you change .cs/.razor/.ts files):
dotnet watch run --project DeepDrftPublic

# Build just the client (for verification):
dotnet build DeepDrftPublic.Client

# Run client-specific tests (if any; currently none exist):
dotnet test DeepDrftTests/

Configuration

  • Program.cs: Entry point. Calls Startup.ConfigureApiHttpClient (registers named clients), ConfigureContentServices (same), ConfigureDomainServices (registers services like TracksViewModel, DarkModeSettings, AudioPlayerService).
  • Both Startup methods are static and called from both the server DeepDrftPublic/Program.cs and the client Program.cs, ensuring prerender and runtime DI are identical.
  • No appsettings.json in the WASM assembly — config comes from the server appsettings.json via HTTP or is hardcoded.

Important patterns

  • Cascading parameters: AudioPlayerProvider cascades IStreamingPlayerService. All children (including MainLayout and pages) access it via [CascadingParameter] IStreamingPlayerService Player { get; set; }.
  • Result types: Clients return ApiResult<T> from NetBlocks. UI checks Success before using Value.
  • Async/await: All operations are async.
  • Stream consumption: TrackMediaClient.GetAudioStreamAsync returns a Stream (not fully buffered). StreamingAudioPlayerService reads it in chunks to avoid memory pressure on large files.
  • Detail pages under InteractiveAuto must load in OnParametersSetAsync, not OnInitializedAsync: Blazor reuses a scoped component instance across same-template navigations (e.g. /mixes/5/mixes/8), firing OnParametersSet/OnParametersSetAsync rather than re-running OnInitialized. If load logic is in OnInitialized only, the ViewModel retains the prior track and Play will stream the wrong item. Capture the route parameter (id/key) synchronously at the top of OnParametersSetAsync before any await — after an await the route state may have advanced. Guard PersistentComponentState restores on id/key equality to prevent cross-item bleed when the prerender and WASM-boot passes disagree on which item is current.

When working with this project, maintain the separation between presentation (Razor components) and logic (ViewModels/Clients), follow the established audio player architecture, and respect the dark-mode round-trip (cookie → DarkModeSettings → PersistentComponentState → client).