Files
deepdrft/DeepDrftPublic.Client/CLAUDE.md
T

22 KiB
Raw 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"; does not compose ReleaseDetailScaffoldPlayTrack is wired directly in its own @code block; mounts <WaveformVisualizer> ambient engine + <WaveformVisualizerControlPopover> directly), 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), CutDetail.razor (album detail — composes ReleaseDetailScaffold with the Ambient slot carrying <WaveformVisualizer> + <WaveformVisualizerControlPopover> for mode-B ambient layer). 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).
  • 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 via IStreamingPlayerService. 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).
    • 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. 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).
    • 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: Eight-knob RadialKnob control panel (the panel content hosted by WaveformVisualizerControlPopover). 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 PanelChrome scopes panel chrome (title bar, background) to the popover mount — set true when placed in a popover, false when embedded directly. 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 MudPopover overlay 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 from deepdrft-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-controls at the panel's top-right corner, not inside NowPlayingCard).
    • 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). Pure presentational — label/"Now Playing" dot, track name, and artist·release sub-line. Cascades IStreamingPlayerService for live track data. No visualizer or popover; those moved to NowPlaying.razor.
    • 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.
    • 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.
  • 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.
  • 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 control positions: ScrollSpeed, GradientRotationSpeed, LavaGravity, LavaHeat, FluidAmount (wax count/volume), FluidViscosity (cohesion — the second half of the Phase 10 "bubbles" split; BlobDensity is gone), CollisionStrength, WaveformWidth. 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. 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 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.
  • 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.
  • 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.
  • 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. 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 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.

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: 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. 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).