22 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"; does not composeReleaseDetailScaffold—PlayTrackis wired directly in its own@codeblock; mounts<WaveformVisualizer>ambient engine +<WaveformVisualizerControlPopover>directly),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),CutDetail.razor(album detail — composesReleaseDetailScaffoldwith theAmbientslot carrying<WaveformVisualizer>+<WaveformVisualizerControlPopover>for mode-B ambient layer). 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).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 viaIStreamingPlayerService. 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).AudioPlayerBar/PlayerControls.razor: Play/pause/stop buttons in the transport zone. Renders via<PlayStateIcon>.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).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: Eight-knob RadialKnob control panel (the panel content hosted byWaveformVisualizerControlPopover). Controls (in order): waveform scroll speed, color gradient rotation speed, lava gravity, lava heat, fluid amount, fluid viscosity (cohesion), collision strength, waveform width.[Parameter] bool PanelChromescopes panel chrome (title bar, background) to the popover mount — settruewhen placed in a popover,falsewhen embedded directly. 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 withWaveformVisualizerControlsasMudPopoveroverlay content. This is the unit every host places — one icon anywhere gives the full eight-knob panel on demand. Styled to the NowPlaying Hero look fromdeepdrft-tokens.css(no hardcoded hex). 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).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). Pure presentational — label/"Now Playing" dot, track name, and artist·release sub-line. CascadesIStreamingPlayerServicefor live track data. No visualizer or popover; those moved toNowPlaying.razor.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.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.
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.
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 control positions:ScrollSpeed,GradientRotationSpeed,LavaGravity,LavaHeat,FluidAmount(wax count/volume),FluidViscosity(cohesion — the second half of the Phase 10 "bubbles" split;BlobDensityis gone),CollisionStrength,WaveformWidth. Each has a matchingDefault*const.Changedevent is the decoupling seam — controls mutate state + raiseChanged; the bridge (WaveformVisualizer) subscribes and pushes the affected uniform. Scoped DI so state survives SPA nav within a session and resets on fresh page load.
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.
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.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.
Common/: Shared utilities.DarkModeSettings.cs:[PersistentState]-annotated class (single source of truth for dark mode in the client). Registered scoped.DDIcons.cs: Hand-rolled SVG icons (gas-lamp lit/unlit for dark mode toggle).
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 fromDDIcons.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.
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:
Common/DDIcons.cs(hand-rolled gas-lamp, etc.).
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).