54 KiB
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; usesMudContainer MaxWidth="Large"with.dd-detail-fillso the ambient visualizer reads full-screen and the footer is pushed below the fold; does not composeReleaseDetailScaffold—PlayTrackis wired directly in its own@codeblock; 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-actionscluster — the toggle only appears when this page's release is the one currently playing (ShowTheaterTogglefromReleaseDetailBasefolds in the subsystem gate + release-playing check); hero overlay and<ReleaseDescription>are wrapped in a.dd-theater-collapsible/.dd-theater-collapsible-innerpair that gets.dd-theater-collapsedwhenIsContentHiddenis true — eased collapse viagrid-template-rows: 1fr → 0fr+opacity+visibility(no hard@ifpop); renders<ReleaseDescription>below the hero for the release's description blurb),MixDetail.razor(mix detail — composesReleaseDetailScaffoldwithTopRightActionlava-lamp<WaveformVisualizerControlPopover>; hero+meta rendered via<ReleaseHeroOverlay Class="mix-hero">in the scaffold'sHeroslot withShowHeader="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:TopRightActionslot holds<TheaterModeToggle Available="ShowTheaterToggle" />+ lava-lamp popover in a.dd-detail-top-actionscluster — toggle only appears when this Mix is the playing release; hero overlay and description are wrapped in.dd-theater-collapsible/.dd-theater-collapsedeased collapse driven byIsContentHidden),CutDetail.razor(album detail — composesReleaseDetailScaffoldwith theAmbientslot carrying<WaveformVisualizer>+<WaveformVisualizerControlPopover>for mode-B ambient layer; the scaffold is wrapped in a.dd-detail-filldiv; 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:TopRightActionslot holds<TheaterModeToggle Available="ShowTheaterToggle" />+ lava-lamp popover in a.dd-detail-top-actionscluster — 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-collapsedeased collapse driven byIsContentHidden, replacing the prior hard@if),FramePlayer.razor(embeddable iframe player at/FramePlayer, usesEmbedLayout; two mutually-exclusive modes via query params:TrackEntryKeystages a single track as before;ReleaseEntryKeyresolves the release's ordered tracks viaFramePlayerViewModel, stages track 0 viaPlayerService.StageTrack, and arms the queue viaQueue.Arm— no JS interop in either path, so both run safely during prerender; the first play gesture inAudioPlayerBarroutes throughQueue.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.razordo not exist).Layout/:MainLayout.razor(root layout, wraps inAudioPlayerProvider, hosts theme switcher),DeepDrftMenu.razor(branded menu bar),NavMenu.razor(nav list),Pages.cs(centralised nav index —MenuPagesfor header,AllPagesfor exhaustive list),DeepDrftFooter.razor(site footer — logo, nav links, copyright; contains a "Privacy" button that opens a screen-centered tinted modal viaMudOverlay(DarkBackground="true",Modal="true") carrying the anonymous-listener privacy note; trigger-button styling in the co-locatedDeepDrftFooter.razor.css, overlay chrome in the globaldeepdrft-styles.css; follows theQueueOverlay/WaveformVisualizerControlPopoverMudOverlayidiom — 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 viaIsPausedparameter.TracksGallery.razor: Responsive grid ofTrackCarditems (MudBlazorMudGridwith 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 forIStreamingPlayerService. 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 throughIQueueService.PlayTrack(deque PLAY semantics) when the queue cascade is present, falls back toIStreamingPlayerService.SelectTrackStreamingwhen absent. AcceptsButtonClassandButtonLabelfor distinct visual presentations;OnStreamStartedEventCallback 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 whenShowFixedPanel && _fixedPanelOpen(release embeds only; single-track embeds stay panel-free). The Queue button in Fixed mode toggles_fixedPanelOpenand triggers apostHeightcall viaembed-frame.tsso the host page can resize the outer iframe. TypeScript counterpart for the resize handshake:DeepDrftPublic/Interop/embed/embed-frame.ts— readsEmbedIdfromwindow.location.search, exportspostHeight(element)which measures the player element and posts{type:"deepdrft-embed-resize", height, embedId?}towindow.parent; no-ops when not framed (compiled output gitignored). Phase 20: injectsWaveformVisualizerControlStateand subscribes toChanged(added alongside the existingIPlayerService.StateChangedsubscription — same reference-guard + dispose pattern); mounts<NowShowingPanel Release="CurrentTrack.Release" />above the transport controls whenCurrentTrack?.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-innerpair; it gets.dd-theater-collapsedwhen 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 (!Fixedonly).AudioPlayerBar/TrackMetaLabel.razor: Now-playing track-title + artist row. Takes[Parameter] bool Fixed(passed fromAudioPlayerBar.razor). WhenFixed(embedded iframe), the track-title anchor renders withtarget="_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 byAudioPlayerBaronly whenVisualizerControlState.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-secondaryplaceholder), release title linked viaReleaseRoutes.DetailHref(Release), and a release-modeSharePopover(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 inAudioPlayerBar.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. InjectsIPlayerService, subscribes toStateChanged, callsPlaybackIcons.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 (0–100%), with fixed three-zone gradient (green 0–60%, yellow 60–85%, orange 85–100%). Note silhouette always visible at 25% opacity; idle when paused/stopped. Reuses spectrum-callback infrastructure.SpectrumVisualizer.razor: Bar-graph spectrum display, driven bygetSpectrumDataJS callback.ReleaseHeroOverlay.razor: Shared presentational overlay shell consumed by bothSessionDetailandMixDetail. 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">(noMudPaper).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 shortDescriptionparagraph (.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-linemotif. Purely presentational — owns no data fetch or player wiring. A null/whitespaceDescriptionrenders 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 viaReleaseDetailScaffold'sAmbientslot), mode C (NowPlaying hero panel — full-bleed background for the home hero's right side, mounted byNowPlaying.razorinside.np-visualizer-bg).[Parameter] bool Fillswitches from fixed-viewport positioning to container-relative sizing (CSS-only; the renderer is identical in both modes). The bridge resolves the current track'sEntryKeyand re-fetches the high-res datum on track change. Subscribes toWaveformVisualizerControlState.Changedand pushes each updated dial to the WebGL module via JS interop. Follows the live playing track (keys on hostTrackIdmatch OR shared hostReleaseEntryKey).WaveformVisualizerControls.razor: The waveform visualizer control panel (content hosted byWaveformVisualizerControlPopover). 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 whenLavaEnabled): "LAVA:" section label + Gravity / Heat / FluidAmount / FluidViscosity knobs. Row 3 (WAVE, visible only whenWaveformEnabled): "WAVE:" section label + scroll-speedMudSlider(not a knob) + width knob pinned far-right. Total: two lamp toggles, sevenRadialKnobs, oneMudSlider. Colour principle: lamp toggles / knob arcs / slider are green (Color.Primary— interactive); section labels / knob caption icons are light (static). Each control has a playfulMudTooltip.[Parameter] bool PanelChromescopes panel chrome (NowPlayingCard look — square corners, lighter-navy, thin border) to the popover mount; chrome classes live in the globaldeepdrft-styles.css(CSS isolation cannot reach portaled overlay content).[Parameter] bool Visiblegates the rows via@ifwhile the container holds reserved min-height. Owns no JS interop: mutates the injectedWaveformVisualizerControlStateand raisesChanged. No control is a seek surface (read-only contract).WaveformVisualizerControlPopover.razor: Pairs the lava-lamp icon button withWaveformVisualizerControlsas a screen-centered tinted modal (Phase 15). The primitive isMudOverlay(DarkBackground="true",Modal="true") — notMudPopover;AnchorOrigin/TransformOriginparameters 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'sposition:fixedcapture 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 IconSizecontrols the trigger-icon size (defaultLarge). 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-controlsat the panel's top-right corner, not insideNowPlayingCard).TheaterModeToggle.razor: Phase 20 Theater-Mode toggle button. Visible only whenAvailable && (State.LavaEnabled || State.WaveformEnabled)— no visualizer subsystem active → no theater to enter;Availableis 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: flipsWaveformVisualizerControlState.TheaterModeand callsNotifyChanged(). Shows an on/offaria-pressedactive state. Glyph: MaterialTheaters..dd-accent-iconcontainer gives the green-accent glyph in both themes with zero new CSS — same treatment asWaveformVisualizerControlPopover. Subscribes toState.ChangedinOnInitializedand unsubscribes onDisposeto re-render when another observer (e.g.CoerceTheaterMode()) flips the state.[Parameter] Size IconSize(defaultLarge) matches the adjacent lava-lamp trigger.[Parameter] bool Available(defaulttrue) — the page passes itsShowTheaterTogglepredicate here so the toggle is scoped to the playing release; surfaces with no release-scoping pass the defaulttrue. Placed immediately left of the lava-lamp popover on all three detail pages inside a.dd-detail-top-actionscluster.WaveformZoomMapping.cs: Maps theWaveformVisualizerControlState.Resolutionfraction 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 cascadedIStreamingPlayerService. Subscribes toIPlayerService.StateChangedinOnParametersSet(reference-guarded, idempotent) and unsubscribes on dispose to re-render on track/state change. No visualizer or popover; those moved toNowPlaying.razor.NowPlayingStats.razor: Home hero stat row. Three cards: Studio Cuts (total Cut-medium track count + zero-suppressed per-ReleaseTypeCut release breakdown), Mixes (MixReleaseCountlabelled "Sets" +hh:mmtotal mix runtime viaRuntimeFormat), and Plays (liveTotalPlaysodometer in.hero-stat-odometer+UniqueListeners"N listeners" secondary line via.hero-stat-sub— Phase 16 wave 16.5). All three cards read from the sameHomeStatsDtoround-trip; no extra fetch path. Fetches viaIStatsDataServiceon init; bridges the prerender fetch across the WASM seam withPersistentComponentState(persists only on a successful load, matching the medium-browse bridge pattern). ImplementsIDisposableto release thePersistingComponentStateSubscription.NowPlaying.razor: Owns the home hero's right-side panel (.now-playing-panel— the outer wrapper formerly called.hero-rightinHome.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-decorings, and the content layer (hosts<NowPlayingCard>+<NowPlayingStats>).Home.razor'sMudItemrenders<NowPlaying />directly with no wrapper. Subscribes toIPlayerService.StateChangedinOnParametersSet(reference-guarded, idempotent) and unsubscribes on dispose — needed because the player cascade isIsFixed(the provider's own re-render does not reachNowPlaying), so the subscription is the only way to re-render and re-propagateReleaseEntryKey/TrackId/TrackEntryKeyinto<WaveformVisualizer>when the playing track changes.QueueList.razor: Shared presentational queue-list component (Phase 17 wave 17.1). RendersItemsas an ordered list with the current track marked;Editableflag gates drag-reorder handles (drag handle icon +MudDropContainer/MudDropZonefor 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 asEventCallback<(int FromIndex, int ToIndex)> OnReorder,EventCallback<int> OnRemove, andEventCallback<int> OnJump; the component calls noIQueueServicemethod 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 theEditableflag. 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 theWaveformVisualizerControlPopoverMudOverlayidiom (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. HostsQueueListinEditable="true"mode. Opened/closed by the Queue toggle button inPlayerTransportZone(shown only when!Fixed && Items.Count > 0;QueueMusicglyph, 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 (callsIQueueService.Enqueuewith a singleTrackDto) and release mode (callsIQueueService.EnqueueRangewith an ordered track list). MaterialPlaylistAddglyph; tooltip "Add to queue" (track mode) / "Add release to queue" (release mode). Reads the cascadedIQueueService; disabled until interactive or when the cascade is absent. Append-only — does not play, does not navigate. Placed at:CutDetailheader (release mode,TrackNumber-ordered list),CutDetailtrack rows (track mode),SessionDetailhero play (track mode),MixDetailhero play (track mode). Excluded fromStreamNowButton(OQ9) andReleaseGallerycards (OQ10, deferred).ReleaseDetailScaffold.razor: Shared scaffold for release detail pages. Gained an optionalAmbientRenderFragmentslot (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 throughIQueueService.PlayTrack(deque PLAY semantics — prepends the track to the queue front) when the queue cascade is present, falling back toIStreamingPlayerService.SelectTrackStreamingwhen absent; toggle-pause is handled directly viaIStreamingPlayerService.TogglePlayPausewhen this track is already active.SharePopover.razor: Share affordance serving both track and release surfaces from one clipboard/popover-chrome source. Track mode (EntryKeyset): copies the track's canonical URL and offers an iframe embed snippet pointing atFramePlayer?TrackEntryKey=…. Release mode (ReleaseEntryKey+ReleaseMediumset): copies the release's canonical detail URL (viaReleaseRoutes.DetailHref) and offers an iframe embed snippet pointing atFramePlayer?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 byEmbedSnippetBuilder. 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 singleSeoModelparameter — 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: StaticResolve(isPlaying, isPaused, trackId, currentTrackId)method — the sole glyph-mapping source for transport icons across all surfaces. Returns(Icon, IsActive, IsPaused)tuple.RuntimeFormat.cs: StaticToHoursMinutes(double totalSeconds)helper. Formats a seconds value ash:mm(hours not zero-padded, minutes always two digits). Negative / non-finite inputs return"0:00". Used byNowPlayingStatsfor 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 fromGuid.NewGuid().ToString("N")[..8]) used as the iframe id (deepdrft-embed-{token}) and threaded into the iframe src as&EmbedId={token}— the in-iframeembed-frame.tsreads this token and includes it inpostMessagepayloads so the host listener can route resize messages to the correct iframe when multiple release embeds share a host page. The script matches onembedIdand appliesiframe.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 fromTrackMediaClient, adaptive 16–64 KB buffer, early-playback, seek-beyond-buffer via offset request to the content API.AudioInteropService: JS interop wrapper overwindow.DeepDrftAudio. ManagesDotNetObjectReferencelifetimes 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;BlobDensityis gone),CollisionStrength,WaveformWidth,LavaEnabled(bool, defaulttrue),WaveformEnabled(bool, defaulttrue),TheaterMode(bool, defaultfalse—DefaultTheaterMode). Each has a matchingDefault*const.Changedevent is the decoupling seam — controls mutate state + raiseChanged; the bridge (WaveformVisualizer) subscribes and pushes the affected uniform or subsystem-enable; the Theater observers (the three detail pages andAudioPlayerBar) subscribe to react toTheaterMode.CoerceTheaterMode(): enforces the invariant that Theater Mode cannot remain on when both subsystems are off — called fromWaveformVisualizerControls.ToggleLava/ToggleWaveformbeforeNotifyChanged()so all observers see a consistent, coerced state in the sameChangedcycle.TheaterModeis 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 inReleaseDetailBase/CutDetailBase(not in this state holder):IsThisReleasePlaying(PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey),IsContentHidden(TheaterMode && IsThisReleasePlaying),ShowTheaterToggle((LavaEnabled || WaveformEnabled) && IsThisReleasePlaying). Both base classes also subscribe toIStreamingPlayerService.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 (fromStreamingAudioPlayerService— 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 viaIPlayEventSink. No player or JS dependency — testable against a fake sink.ShareTracker: Per-session share tracker (Phase 16 wave 16.1). Called bySharePopoverafter a successful clipboard write; applies a 60-second per-(target, channel) debounce. Sends viaBeaconInterop. Scoped so debounce memory resets on fresh page load. Wave 16.3: injectsIAnonIdProvider; attaches_anonId.CurrenttoShareEventDto.AnonId(omitted when null).BeaconInterop:navigator.sendBeaconJS interop wrapper (Phase 16 wave 16.1). Fires JSON payloads toapi/event/{play,share}fire-and-forget. Also wires a page-unload handler that flushes any pending play event when the page is torn down.BeaconPlayEventSink: ProductionIPlayEventSink(Phase 16 wave 16.1). Serializes the play classification and fires it viaBeaconInteroptoapi/event/play. Synchronous (EmitPlaycannot await — it is called from the player close path and the page-unload handler). Wave 16.3: injectsIAnonIdProvider; reads_anonId.Currentsynchronously at emit time and setsPlayEventDto.AnonId(omitted when null viaWhenWritingNull).IAnonIdProvider/AnonIdProvider: Wave 16.3 anonymous-listener id seam.IAnonIdProviderexposesstring? Current(synchronous cached read, safe on the unload path) andValueTask EnsureLoadedAsync()(warms the cache fromlocalStorageviawindow.DeepDrftAnonId.getJS interop — idempotent, never throws).AnonIdProvideris the production implementation; degrades to null whenlocalStorageis unavailable (private mode / blocked storage). The token itself outlives the session inlocalStorage; the in-process cache is scoped (resets on fresh page load). Callers warm the cache when going interactive, then readCurrentsynchronously on the close/unload path with no extra JS hop. TypeScript interop:DeepDrftPublic/Interop/telemetry/anonid.ts(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)andPlayRelease(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) andStreamNowButtonroute their PLAY through these. Add-to-queue enters the BACK:Enqueue/EnqueueRangeappend to the end without interrupting the current track (AddToQueueButton).Next/Previousadvance or step back, walkingCurrentIndexand leaving played tracks behind soPreviouscan 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.Clearempties the queue. Bug #3 (dormant-seed): the firstEnqueue/EnqueueRangeinto 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 existingAttach(player)seam (_player.CurrentTrack) — no new dependency, noIServiceProvider. Armed-idle state (prerender-safe release embeds):Arm(tracks)replaces the queue at index 0 with no JS interop;IsArmedsignals armed-but-not-streaming;Start()streams the current track and clearsIsArmed.AudioPlayerBarreadsIsArmedto route the embed's first play gesture throughStart().QueueChangedfires on all list/position changes; cascaded viaAudioPlayerProvider.Move/RemoveAtare interop-free reorder/remove mutations that adjustCurrentIndexand never re-stream.ClearUpcoming()keeps the current track and drops the up-next. Bug #4 (reactivity):AudioPlayerBar.QueueItemscachesQueueService.Itemsas a_queueItemsCachesnapshot (the service exposes its backing list by reference); the cache is invalidated and set tonullinOnQueueChanged, so every real mutation handsQueueLista new list reference while frequent progress-tick re-renders reuse the cached one without allocating.QueueList.OnParametersSetcalls_dropContainer?.Refresh()so theMudDropContainerre-reads the new list and the open panel re-flows immediately. Bug #1 (label): the dockedQueueOverlaypanel header reads "Playlist" (the current track stays listed).PlayReleasematerializestracks.ToList()before mutating so it can never alias the service's ownItemslist.
Clients/: HTTP API clients (both target DeepDrftAPI).TrackClient: SQL metadata API. Uses namedIHttpClientFactoryclient"DeepDrft.API". Sendspageparam (notpageNumber). Deserializes response as barePagedResult<TrackDto>(not wrapped in ApiResultDto envelope).TrackMediaClient: Content API. Uses namedIHttpClientFactoryclient"DeepDrft.Content". Methods likeGetAudioStreamAsync(trackId, byteOffset?)→Streamwith optional Range header support for seek-beyond-buffer.StatsClient: Home stats API. Uses namedIHttpClientFactoryclient"DeepDrft.API". Single methodGetHomeStats()→ApiResult<HomeStatsDto>(callsGET api/stats/home; response is a bare DTO, noApiResultDtoenvelope). Registered scoped; consumed viaIStatsDataService.
Services/ITrackDataService: Contract used by the visualizer bridge and other consumers. IncludesGetTrackWaveform(entryKey)→ high-resWaveformProfileDto(callsGET api/track/{entryKey}/waveform/high-res); used byWaveformVisualizerto re-fetch the datum on track change.Services/IStatsDataService/StatsClientDataService: Home-stats read abstraction.IStatsDataService.GetHomeStats()→ApiResult<HomeStatsDto>.StatsClientDataServiceis the single implementation (delegates toStatsClient); registered scoped. Components injectIStatsDataServiceso they do not branch on render mode — mirrorsIReleaseDataService.ViewModels/: Component state.TracksViewModel: Scoped. Holds current page, page size, sort column, descending flag.SetPage(pageNumber)callsTrackClient.GetPageAsyncand updates. Registered inStartup.ConfigureDomainServices.TrackDetailViewModel: Scoped. Holds loaded track, loading flag, not-found flag.Load(entryKey)fetches viaITrackDataServiceand resets all flags per call (prevents cross-navigation bleed). Registered inStartup.ConfigureDomainServices.FramePlayerViewModel: Scoped. Resolves the ordered track list for a release embed (FramePlayer?ReleaseEntryKey=…).Load(releaseEntryKey)callsIReleaseDataService.GetByEntryKey→release.Id→ITrackDataService.GetPage(sortColumn:"TrackNumber", releaseId:…), mirroringCutDetailViewModel.Loadexactly so an embedded release queues the same ordered list the Cut detail page plays. Owns no playback or staging —FramePlayer.razoruses the loadedTracksto stage and arm. Registered scoped inStartup.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.Robotsoverrides the environment-default (seeSeoEnvironment).SeoJsonLd.cs: Typed schema.org JSON-LD builders (Phase 22). Types:MusicGroup(home/about, withsameAs: ["https://instagram.com/deepdrft.music"]),MusicAlbum/LiveAlbum(cuts/sessions, with orderedMusicRecordingtrack list and per-releasebyArtist),MusicRecording(mixes, with ISO-8601duration),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 (usesImageProxyControllerroute), IG handle insameAs, no Twitter handle. Registered via the staticStartupseam (both server and WASMProgram.cs).BaseUrlis config, notwindow.location— nowindowat server prerender, and the origin cannot be derived reliably behind the nginx proxy.SeoUrls.cs: URL helpers for canonical andog:imageconstruction fromSeoOptions.BaseUrl(Phase 22).SeoEnvironment.cs: Scoped[PersistentState]bridge for the server environment flag (Phase 22). Seeded inDeepDrftPublic/Components/App.razorfromIWebHostEnvironment.IsProduction()— mirrors theDarkModeSettingsbridge pattern. Default robots isindex,followonly in Production;noindex,nofollowin every non-production environment so the beta/staging site stays uncrawled. Explicit per-pageSeoModel.Robotsoverrides this default. Fail-safe default isnoindex.
Program.cs: WASM entry point. CallsStartup.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):
TrackClientuses"DeepDrft.API"(base address fromappsettings.jsonApiUrls:SqlApi, points to DeepDrftAPI). Fetches paginated metadata.TrackMediaClientuses"DeepDrft.Content"(base address fromappsettings.jsonApiUrls: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. OwnsEventCallback? OnStateChanged(single, provider-owned) andevent 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.SelectTrackthrowsNotSupportedException(buffered path is dead); derived classes overrideSelectTrackStreaming.StreamingAudioPlayerService(production): Constructor takesTrackMediaClient,AudioInteropService, logger.SelectTrackStreaming:- Calls
TrackMediaClient.GetAudioStreamAsync(trackId), which returns a response object includingContentType(e.g.,audio/wav,audio/mpeg,audio/flac). StreamingAudioPlayerService.StreamAudioAsyncreads chunks (16–64 KB adaptive), pushes each viaAudioInteropService.ProcessStreamingChunkAsync(contentType, chunk)(JS interop call with format hint).- TypeScript
StreamDecoderis format-agnostic; delegates format-specific header parsing and chunked decoding to the appropriateIFormatDecoderimplementation (e.g.,WavFormatDecoderfor WAV, TBD MP3/FLAC decoders for other formats). Decoder parses header (first chunk), decodes subsequent chunks toAudioBuffers. PlaybackSchedulerschedules buffers on Web AudioAudioContext.- Playback starts as soon as a configurable min buffer count is queued.
- Seek beyond buffer: if seek target is past the decoded range,
Seek(position)callsTrackMediaClient.GetAudioStreamAsync(trackId, byteOffset)with a file-absolute byte offset. Client sendsRange: 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.
- Calls
Interop bridge
AudioInteropService.CreatePlayerAsyncpollsDeepDrftAudio.isReady()before proceeding;index.tssetsready = trueafter attaching the API towindow. This guards against slow WASM boot / cache misses.AudioInteropService.ProcessStreamingChunkAsync(contentType, chunk)calls JSwindow.DeepDrftAudio.processStreamingChunk(contentType, chunk)and awaits the Promise. ThecontentTypeparameter is passed through to the format-decoder factory.AudioInteropServicealso manages callback registrations for progress (fired byPlaybackScheduler), end-of-playback (fired byPlaybackScheduler), and spectrum data (fired bySpectrumAnalyzer). Each callback is aDotNetObjectReferenceto 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 (optionalrawDataparameter 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). ImplementsIFormatDecoderfor 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-copywrapSegment(raw MP3 frames are self-contained). CBR sub-frame tail guard prevents over-read.FlacFormatDecoder.ts: Concrete FLAC implementation (implemented, not yet wired). ImplementsIFormatDecoderfor 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.wrapSegmentprependsfLaC + STREAMINFOto each audio segment sodecodeAudioDatasees a valid FLAC stream.getAlignedSegmentSizescans backward through peek bytes for the0xFF/(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.razoris the cascading host. It injectsIStreamingPlayerService(resolved toStreamingAudioPlayerServicein DI), stores it in a cascade withIsFixed="true", and keeps it alive across navigation.AudioPlayerBar.razoris the dock UI. It cascades the player, binds buttons toPlay()/Pause()/Seek()/SetVolume(), and displays current time / duration / progress bar. Minimize-state mutations (Expand,ToggleMinimized,Close) all route through a privateSetMinimized(bool value)mutator, which guards no-ops, fires theOnMinimizedcallback, and callsStateHasChanged(). Subscribes toIPlayerService.StateChangedinOnParametersSet(reference-guarded, idempotent) and unsubscribes on dispose to re-render itself when the cascade updates.SpectrumVisualizer.razorcallsAudioInteropService.GetSpectrumData()on a timer, receives bar heights, renders via MudBlazorMudChartor custom canvas.TracksView.razorinjectsTracksViewModel+ cascadedIStreamingPlayerService.PlayTrack(track)callsPlayerService.SelectTrackStreaming(track). Subscribes toIPlayerService.StateChangedinOnParametersSetand callsStateHasChanged()unconditionally on any state change, ensuring the gallery correctly reflects play/pause/track-change transitions. Active-track state is derived fromPlayerService.CurrentTrackandPlayerService.IsPlaying(no local_selectedTrackfield).
Dark-mode plumbing
DarkModeSettings(Common/):[PersistentState]-annotated class withIsDarkModeproperty. Registered scoped inStartup.ConfigureDomainServices. Single source of truth in the client.DarkModeServiceBase: Holds the cookie name constant ("darkMode").DarkModeCookieService: Reads/writes the cookie via JS (document.cookieinterop). CallsDarkModeSettings.IsDarkMode = valuewhen the cookie changes or user toggles the button.- Server-side
DarkModeService(inDeepDrftPublic, not here): Reads the cookie during prerender, seeds theDarkModeSettingsinstance, rounds it throughPersistentComponentStateto the client. MainLayout.razor: Wraps entire layout inCascadingValueofDarkModeSettings, so all children see the current dark-mode state. The dark-mode toggle button (hand-rolled lit/unlit gas-lamp icon fromDeepDrftShared.Client/Common/DDIcons.cs) callsDarkModeCookieService.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.razorinjects it and callsSetPage.- New VMs go in
ViewModels/and register inStartup.ConfigureDomainServices.
Theming convention
- Bespoke
PaletteLight/PaletteDarkdefined inline inMainLayout.razor(MudBlazor theme objects). - CSS classes prefixed
deepdrft-live inDeepDrftPublic/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.Secondary → Light.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. CallsStartup.ConfigureApiHttpClient(registers named clients),ConfigureContentServices(same),ConfigureDomainServices(registers services likeTracksViewModel,DarkModeSettings,AudioPlayerService).- Both
Startupmethods are static and called from both the serverDeepDrftPublic/Program.csand the clientProgram.cs, ensuring prerender and runtime DI are identical. - No
appsettings.jsonin the WASM assembly — config comes from the serverappsettings.jsonvia HTTP or is hardcoded.
Important patterns
- Cascading parameters:
AudioPlayerProvidercascadesIStreamingPlayerService. All children (includingMainLayoutand pages) access it via[CascadingParameter] IStreamingPlayerService Player { get; set; }. - Result types: Clients return
ApiResult<T>from NetBlocks. UI checksSuccessbefore usingValue. - Async/await: All operations are async.
- Stream consumption:
TrackMediaClient.GetAudioStreamAsyncreturns aStream(not fully buffered).StreamingAudioPlayerServicereads it in chunks to avoid memory pressure on large files. - Detail pages under InteractiveAuto must load in
OnParametersSetAsync, notOnInitializedAsync: Blazor reuses a scoped component instance across same-template navigations (e.g./mixes/5→/mixes/8), firingOnParametersSet/OnParametersSetAsyncrather than re-runningOnInitialized. If load logic is inOnInitializedonly, the ViewModel retains the prior track and Play will stream the wrong item. Capture the route parameter (id/key) synchronously at the top ofOnParametersSetAsyncbefore any await — after an await the route state may have advanced. GuardPersistentComponentStaterestores 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).