34 KiB
PLAN.md — DeepDrftHome forward roadmap
Forward-looking roadmap. Sits alongside CONTEXT.md (architecture orientation) and COMPLETED.md (history). Per CONTEXT.md §6, items move from here to COMPLETED.md when work lands; do not delete completed entries.
Organised by theme, not by date. Themes are roughly ordered by current product weight, not commitment. Nothing here carries a timeline unless it explicitly says so.
In-flight — Embeddable iframe player
A standalone, chrome-free player surface intended for embedding in an <iframe> on external pages (e.g. a Bandcamp-style "play this track here" widget on a third-party blog or the collective's socials). Distinct from the dock player, which lives inside the full site chrome.
Shape as it stands in the working tree ([in-progress], code is partial and does not yet compile):
Layout/EmbedLayout.razor— a minimal layout:MudThemeProvider+AudioPlayerProviderwrapping@Body, with no nav, menu, or marketing chrome. Reuses the dark-modePersistentComponentStateround-trip (CONTEXT.md §3.6) so an embedded player still honours the theme.Pages/FramePlayer.razor— routed at/FramePlayer, usesEmbedLayout, renders a single<AudioPlayerBar Fixed />. Reads aTrackEntryKeyfrom the query string and is meant to auto-select that track on load.Services/ITrackDataService.cs+TrackClientDataService.cs— a new track-metadata fetch seam (GetPage+ a newGetTrack(trackId)) so a component can resolve a single track by key without the gallery VM. Intended to be render-mode-agnostic (one seam, SSR and WASM both served by it).
Why it matters: An embeddable player turns every external mention of a DeepDrft track into a play surface. It is the lightest-weight distribution lever the product has — no app install, no account, just a link that plays. Fits the collective's "get the music in front of people" posture.
Open questions (unresolved — surface to Daniel before this lands):
- Is this a committed direction or an experiment? The code is a partial spike. Confirm before scoping the rest.
- Track addressing.
FramePlayerkeys offTrackEntryKey(the FileDatabase entry key), butITrackDataService.GetTrack(string trackId)is ambiguous about whether the argument is the SQLIdor theEntryKey. The two ID spaces (CONTEXT.md §3.4) need to be reconciled before the lookup is correct. AudioPlayerBarreuse vs. a dedicated embed control. TheFramePlayerTODO comment proposes "an iframe-compatible player using the AudioPlayerControl." Decide whether the embed reuses the full dock bar (current approach) or a stripped single-track control.- Embedding security. A public iframe surface implies decisions about
X-Frame-Options/ CSPframe-ancestors, and whether the unauthenticatedGET api/track/{id}stream is acceptable to expose from arbitrary origins (it already is unauthenticated, but embedding makes the exposure explicit).
[in-progress] — capture the design decisions in a product note when Daniel confirms the direction.
0. Baseline — what just landed
A two-part audit (design + streaming) ran on 2026-05-17 and the fixes for Critical, Major, and Minor findings are now on dev. The remainder of this plan assumes that baseline. In summary the audit-pass fixed:
- Index concurrency —
VaultIndexDirectoryno longer drops the lock before its async disk write; the index file can no longer be clobbered by interleaved writers. - Repository semantics —
TrackRepository.Updatenow fails-fast when anIdis not found instead of silently issuing anINSERT. - Streaming Criticals — concurrent-seek race in the client, dirty trailing bytes leaking out of the
ArrayPool-rented buffer, final-tail audio dropped at EOF below the minimum decode frame, and the assumption that the first network chunk contains the whole WAV header. - 17 design and streaming Majors/Minors across all eight projects — format-validation alignment between processor/offset/decoder,
IAsyncDisposableon the player provider, cancellation tokens threaded through the HTTP path, structured logging into the FileDatabase subsystem, sort-sentinel cleanup, sundry DRY/SRP tightenings.
What this means for the roadmap: the streaming substrate is solid. Future work can build on top of it rather than around it. The remaining items in TODO-V2.md that did not land are deferred as features, not bugs — they are captured below under Phase 1.
Phase 1 — Streaming features deferred from the audit
These were flagged during the audit but classified as feature work, not defect fixes. They are listed in rough order of user-visible impact.
1.1 Backward seek
- What: Seeking to a position below
playbackOffsetcurrently clamps silently to the start of the in-memory buffer segment instead of going to the user's chosen time. The forward "seek beyond buffer" path already exists inWavOffsetService+ the client's offset-request path; backward seek is the missing mirror. - Why it matters: The single highest-impact missing feature in the player. Scrub-bar drags backward feel broken — they appear to seek but land in the wrong place.
- Shape: Reuse the existing
GET api/track/{id}?offset=pathway. The client decision becomes "is the target inside the decoded window?" — if yes, jump within the buffer (existing behaviour); if no (forward or backward), tear down the decoder and re-request from the byte-aligned offset. - Prerequisite: None — the substrate exists.
- Constraint: Backward seek must observe the same
blockAlignrounding-down as forward seek (already enforced inWavOffsetService.alignedOffsetandStreamDecoder.calculateByteOffset). The teardown/reinit must respect the generation-counter pattern introduced by the concurrent-seek fix.
1.2 Audio format diversity
- What: Today
AudioProcessor,WavOffsetService, and the JS decoder are PCM/WAV-only.MimeTypeExtensionsalready maps MP3, FLAC, Ogg, AAC, M4A — none are wired. - Why it matters: WAV-only is a real ceiling for any non-internal release. Distribution-grade formats (MP3, FLAC at minimum) are table stakes for a music site.
- Shape: Two seams need a strategy pattern.
- Server side: replace
AudioProcessor.ProcessWavFileAsyncwith a format-router that selects a per-format processor; replaceWavOffsetServicewith a per-format offset strategy (some formats — MP3, Ogg — have natural frame boundaries; FLAC has block headers; AAC has ADTS). - Client side: the JS decoder is currently a WAV byte-walker. For non-WAV, the simplest path is
decodeAudioDataover the full payload (loses streaming-start). The richer path is per-format chunked decoders. Worth a design pass before committing.
- Server side: replace
- Prerequisite: None functionally, but consider settling Phase 4 (HTTP Range) first — native range/cache is much more important for large MP3s than for WAVs.
- Constraint: Spectrum FFT tap currently relies on raw
AudioBuffers throughdecodeAudioData. If a future path usesMediaElementAudioSourceNode(see 4.1), the FFT tap still works but the early-playback story changes.
1.3 Preload / prefetch of the next track
- What: No mechanism to begin the next track's stream during the tail of the current. Each play is a cold fetch.
- Why it matters: Prerequisite for both crossfade (1.4) and gapless (1.5). Also a perceived-latency win on its own — track-change feels instant when the bytes are already in flight.
- Shape: A second
HttpClientrequest kicked off when the current track passes a configurable threshold (e.g. last 10 seconds). Bytes accumulate into a stagedStreamDecoderinstance rather than the live one. Promotion to "current" happens at end-of-stream or on user-selected next. - Prerequisite: Requires a notion of "next track" — today the player only knows the current one. That implies either a playlist/queue model in
IPlayerServiceor a passive "what was the next row in the gallery" inference. - Open question: Does a queue model belong in
IPlayerService, or is the player a single-slot device that a futurePlaylistServiceorchestrates above? Worth a design note before implementation. Capture in product notes when picked up.
1.4 Crossfade
- What: Smooth A→B transition with overlapping fade-out / fade-in.
- Why it matters: DJ/mix aesthetic that fits the DeepDrft collective's electronic-music context. Distinguishing UX from generic "next track."
- Shape: Architecturally two simultaneous
PlaybackSchedulerinstances suffice — each owns its own gain node, crossfaded viaGainNode.gain.linearRampToValueAtTime. The wiring is the work, not the audio graph itself. - Prerequisite: 1.3 (Preload) — there is nothing to fade into without prefetch.
1.5 Gapless playback
- What: Eliminate the inter-track silence that exists today.
- Why it matters: Important for live-set rips, mix tapes, anything authored to flow continuously.
- Shape: The decoder must be able to start the next track's first buffer scheduled exactly at the end of the current one's last buffer (sample-accurate, not wall-clock). With
PlaybackScheduler's existing 500 ms lookahead this is mechanically achievable — the next track's firstAudioBufferSourceNode.start(t)is set to the previous track's end time. - Prerequisite: 1.3 (Preload). Also needs to play nicely with 1.2 because gapless across formats is hard (encoder padding/priming on MP3 in particular).
- Constraint: Truly sample-accurate gapless requires knowing the priming/padding sample counts of the source format. Out of scope for WAV-only; revisit when format diversity lands.
1.6 Track-skip on error
- What: A failed
processStreamingChunkaborts the entire load with no recovery path. - Why it matters: One corrupt frame at byte 4M of a 100 MB stream currently means the listener loses the entire track. Should at minimum surface a clear error and (optionally) skip past the bad region.
- Shape: Two-level response.
- Cheap: catch in the streaming loop, surface a user-visible error, advance the gallery to the next track if a queue exists.
- Richer: byte-scan forward to the next valid frame header for the format and resume. Format-dependent — only worth doing once 1.2 lands.
1.7 Safari compatibility
- What: Two known Safari edge cases.
webkitAudioContext.close()is async-but-not-Promise on older Safari (≤ ~14);awaitresolves immediately and the nextinitialize()can run against a not-yet-closed context.- iOS Safari < 15 had streaming-fetch quirks;
HttpCompletionOption.ResponseHeadersReadbehaviour is not guaranteed there.
- Why it matters: Real listener share. iOS in particular is a primary listening surface for music.
- Shape: For the
close()race — detectwebkitAudioContextand pollstate === "closed"with a short timeout instead of trusting theawait. For the fetch quirks — first decide the minimum supported iOS version; if pre-15 is in scope, fall back to a non-streaming fetch path and accept the latency. - Open question: What's the floor? Decide before designing the fallback. iOS 15+ as the floor would let us drop the second concern entirely.
Phase 2 — Product surface: gallery, browsing, ingestion
These follow from CONTEXT.md §5. Direction is strongly implied but no specific UI has been committed.
2.1 Cover art / image vault wired through
- What:
MediaVaultType.Imageis implemented end-to-end and exercised by tests, but the production surface only registers atracksvault of typeAudio.ImagePathonTrackEntityis a free-form URL string today; it should resolve to an entry in an image vault served byDeepDrftContent. - Why it matters: Prerequisite for any album/release/genre view that wants to look like a music site rather than a list of rows. Also closes a free-form-string surface area that will otherwise calcify.
- Shape:
- Register a second vault (
imagesorart, typeImage) inStartup.ConfigureDomainServicesand in the CLI. - Add
GET api/image/{entryKey}(unauthenticated, mirrors track read) andPUT api/image/{entryKey}(ApiKey, mirrors track write) onDeepDrftContent. - Change
TrackEntity.ImagePathsemantics from "URL" to "image vault entry key" (column rename optional — could remainimage_pathwith semantic shift, or could becomeimage_entry_keyfor clarity). - Add an image processor sibling of
AudioProcessor.
- Register a second vault (
- Prerequisite: None.
- Constraint: This is a small schema-semantics migration. Existing rows have
nullImagePath in production so there is no data to migrate, but commit before the field has real content to avoid a backfill.
2.2 Album and genre views
- What:
TrackCardalready renders album/genre/release date; the data is there. Missing are gallery groupings (album view, genre view), filters, and the API-side support for filter expressions inTrackService.GetPaged. - Why it matters: The track gallery is the only working content surface. Multiple views over the same library is how it earns the "gallery" name.
- Shape: Per
CONTEXT.md §6, the convention is one source of truth, multiple views over it. New views should consume the sameTracksViewModel/PagedResult<TrackEntity>and differ only at the rendering layer.TrackService.GetPagedextended to accept a filter expression (or a simple structured filter DTO).PagingParameters<T>extended with aWhere: Expression<Func<T, bool>>?or a parallelFilterParameters<T>— pick one to avoid drift.- New routes (
/albums,/genres) consume the same VM with different grouping / filter inputs.
- Prerequisite: 2.1 for any view that prominently features cover art (album view especially is impoverished without it).
2.3 Search and filter on the gallery
- What:
TracksViewModelexposes sort but no filter.TrackService.GetPagedaccepts only sort. Simple text search acrossTrackName/Artist/Albumis the obvious first cut. - Why it matters: Once the library has more than ~30 entries, sort-only browsing is friction.
- Shape: Same extension to
GetPagedas 2.2. UI is a debounced text input bound to the VM's filter property. EF Core translatesContainsto SQLiteLIKE. - Prerequisite: Fold into 2.2 if both are being done — the same
GetPagedextension serves both. Doing them separately doubles the API churn.
Phase 3 — New content kinds
3.1 Live / session content
- What: The home page advertises "Live Sessions" and "Video Content (coming soon)". No data model exists for these.
- Why it matters: Honour the home page copy. Also differentiates the site from a generic track gallery — live sessions and video are the collective's authored output.
- Shape: Speculative; no commitment yet.
- Likely new entity table(s) sibling to
TrackEntity(SessionEntity,VideoEntity?) — or a polymorphicMediaEntitywith discriminator. The choice affects how much code inTrackService/TrackControllercan be reused. - New vault type(s).
MediaVaultType.Mediaexists and is the obvious home for video; sessions are probably stillAudio. - New routes, new UI surfaces, new player considerations (video has its own playback element and does not go through the WAV decoder).
- Likely new entity table(s) sibling to
- Prerequisite: Probably 2.1 (vault wiring proof) and a decision on the entity model before any code lands.
[speculative]— direction inferred from home-page copy, not a Daniel-confirmed commitment.
Phase 4 — Infrastructure / delivery
4.1 HTTP Range + CDN caching
- What: Today's
?offset=query parameter defeats HTTP caching — a CDN sees?offset=1234567as a distinct URL from the un-offset request. The architecture re-invents byte-range on top of a custom query param. - Why it matters: Material once the site has real listener traffic. Also relevant to non-WAV formats (1.2) where decoder-side seek is cheaper natively.
- Shape: Two intertwined moves.
- Server:
LoadResourceStreamAsyncreturning an openFileStreaminstead ofLoadResourceAsyncmaterialising the whole buffer.File(stream, mime, enableRangeProcessing: true). TheWavOffsetServicesynthesised-header path becomes a special-case rather than the default. - Client: consider
MediaElementAudioSourceNodeinstead of (or alongside)decodeAudioData-fedAudioBufferSourceNodes. Native seek, native range, native cache; FFT tap on the audio graph still works for the spectrum visualiser.
- Server:
- Prerequisite: None functionally, but the audit explicitly flagged this trade-off as architecture-intentional — the current path was chosen because spectrum analysis wants
AudioBuffers. Re-deciding the trade-off is itself part of the work. - Constraint: A move to
MediaElementAudioSourceNodechanges the early-playback story (the element handles buffering, not us). Worth a design pass.
4.2 Server-side stream from disk (no buffer materialisation)
- What:
LoadResourceAsync<AudioBinary>reads the entire file into memory beforeFile(file.Buffer, mimeType)returns it. A 100 MB WAV is a 100 MB LOH allocation per request. - Why it matters: Scaling ceiling. Currently fine for a small audience and small library; not fine if either grows.
- Shape: Folds into 4.1 — the same
LoadResourceStreamAsyncoverload solves both. Listed separately because either could land without the other (you could stream from disk while still using the?offset=query path, or you could move toRangeheaders while still buffering).
4.3 Dual-write rollback / dead-letter log
- What: If content-side write succeeds and SQL-side write fails, audio is orphaned in the vault. No compensating mechanism exists.
- Why it matters: A latent data-integrity issue. Materially riskier once web upload (2.4) exists.
- Shape: Audit suggested a
DeadLetterLogrecording orphanedentryKeys for a periodic maintenance pass. Lighter than full transactional rollback (which the dual-database split fundamentally cannot give us). - Prerequisite: None. Worth landing alongside or just before 2.4.
Phase 5 — Documentation backlog
5.1 Folder-level CLAUDE.md sweep
- What: Eight folder-level
CLAUDE.mdfiles need writing/rewriting per the brief inDOC_PLAN.md. Five are rewrites (drift from the.NET 10upgrade and structural moves); three are new (DeepDrftWeb.Services,DeepDrftContent.Services— the two libraries where most domain logic now lives — plus the open question onDeepDrftContent.Services/FileDatabase/README.md). - Why it matters: The agent guidance files are how every future implementer (human or agent) gets oriented in a directory. They are currently misleading in ways that will cause wrong assumptions on first contact — claiming
.NET 9, referencingMediaPaththat has beenEntryKeyfor two migrations, describing aFileDatabase/tree insideDeepDrftContentthat has moved out, and missing entirely for the two*.Serviceslibraries. - Shape: Doc-keeper executes against
DOC_PLAN.md. Order of operations and the per-folder briefs are already specified there. - Prerequisite: None. Can run fully in parallel with any feature work.
- Constraint: Wait on Daniel for the
DeepDrftContent.Services/FileDatabase/README.mdjudgement call before that file changes (retire, keep + refresh, or replace with a CLAUDE.md). The other seven can proceed without that decision.
Cross-cutting / not yet themed
A small set of items that are real but don't fit a phase yet. Surface them when they become relevant rather than committing now.
- Identity / accounts. Currently no user concept. Needed before web upload (2.4); also a precondition for favourites, listening history, per-user playlists. Decide the shape before any of those lands.
[speculative]until Daniel signals interest. ITrackServiceinterface. Audit-suggested. Low value today (one consumer pair); higher value when the test surface expands beyond FileDatabase.- Test coverage outside FileDatabase. Tests today cover the FileDatabase subsystem comprehensively and nothing else. As features in Phases 1–4 land, test scope should expand — at minimum
WavOffsetService,AudioProcessor,TrackService(both sides), and the streaming player services. Not a phase of its own; an attached cost to feature work.
Track Detail Page (/track/{entryKey})
A focused, editorial single-track view in DeepDrftPublic.Client. The track gallery answers "what is in the library"; this page answers "tell me about this track" — full metadata, cover art, and a single prominent play affordance, styled to feel like a record-sleeve back-cover rather than a form. Link-only for now (reached from a gallery card / Now Playing), not a top-level nav entry.
Status: [spec — not yet built]. Spec is implementation-ready except for the cover-art dependency called out in §4 below, which forks on 2.1.
Two facts that correct the originating brief
The dispatch brief carried two assumptions that the current tree does not bear out. Both are load-bearing for whoever implements this:
-
There is no cover-art URL pattern yet. The brief said to "confirm and spec the correct URL" for
api/track/{entryKey}/imageas if it existed. It does not. Per 2.1, the image vault is unwired:TrackEntity.ImagePathis still a free-form URL string and no image read endpoint exists onDeepDrftContent. The page must therefore treat cover art as a dependency on 2.1, not a given. See §4 for the resolution and the graceful-degradation default that lets the page ship before 2.1 lands. -
The page consumes
ITrackDataService, notTrackClientdirectly.ITrackDataService.GetTrack(string trackId)already exists and already delegates toTrackClient.GetTrack(entryKey)(TrackClientDataService.cs:29). It is the render-mode-agnostic seam — WASM hits it over HTTP, the SSR prerender pass can swap a direct implementation. The ViewModel must depend onITrackDataService, mirroringTracksViewModel, so the prerender bridge in §6 actually fetches in-process during prerender rather than round-tripping. CallingTrackClientdirectly would couple the page to the WASM transport and defeat the persisted-state bridge.
1. Route and render mode
- Route:
@page "/track/{EntryKey}".EntryKeyis a[Parameter]string; Blazor route matching is case-insensitive by convention. The segment is the FileDatabase entry key, matchingTrackClient.GetTrack'sapi/track/meta/by-key/{entryKey}lookup. The component mustUri.UnescapeDataStringis not needed — Blazor decodes route segments before binding. - Render mode:
@rendermode InteractiveWebAssembly, consistent withTracksViewand the other public pages: server prerenders, WASM hydrates. This is what makes the §6PersistentComponentStatebridge necessary.
2. ViewModel shape — TrackDetailViewModel
Scoped, registered in Startup.ConfigureDomainServices alongside TracksViewModel. Depends on ITrackDataService (the existing seam), not TrackClient.
Properties:
TrackDto? Track— the loaded track,nulluntil the fetch resolves. Single source of truth for the view.bool IsLoading—truefrom construction until the first load completes (pass or fail). Drives the skeleton.bool NotFound—truewhen the load resolved but the result was a failure (404 or deserialize miss). Drives the error state. Kept distinct fromIsLoadingso the view has three clean states: loading / loaded / not-found.
One async command:
Task Load(string entryKey)— callsTrackData.GetTrack(entryKey). OnSuccess, setsTrackand clearsNotFound; on failure, setsNotFound = trueand leavesTracknull. SetsIsLoading = falsein afinally. Idempotent enough to be safe if called once perOnInitializedAsync(it is — see §6).
Note the VM is keyed transiently: unlike TracksViewModel (which holds paging state across the session), this VM is per-track. Because it is scoped, navigating between two /track/... routes reuses the same instance — Load must fully overwrite all three properties each call so a stale Track never bleeds across a navigation. Reset IsLoading = true and NotFound = false at the top of Load.
3. Component layout
Editorial, not a property grid. Target feel: the back of a record sleeve. Single centered column on a generous max-width container (~720–820px), plenty of vertical rhythm, type doing most of the work.
Top to bottom:
-
Back link. A subtle, small
← All tracksat the top-left of the content column, routing to/tracks.MudLinkwith low-emphasis styling (Typo.body2, muted color from the active palette). Not a button — a quiet text affordance. -
Cover art block. A large square (responsive, ~320–400px, centered). When art resolves (§4), an
<img>inside adeepdrft-track-detail-coverframe. When it does not (nullImagePath, or 2.1 not yet wired), a themed placeholder — aMudPaperwith a centered low-emphasisIcons.Material.Filled.Albumglyph, not a broken image and not the word "no image". The placeholder is the default state today, so it must look intentional. -
Title / artist masthead.
TrackNameas the hero —MudText Typo="Typo.h3"(orh2), in the display serif the theme already loads (Bodoni Moda / Cormorant via thedeepdrft-type classes).Artistdirectly beneath,Typo.h6, lighter weight,Color="Color.Primary"or a muted secondary. These two are never null, so they always render. -
Play affordance. Immediately under the masthead — see §5. Prominent; this is the page's primary action.
-
MudDivider— a hairline rule separating the masthead from the metadata block, reinforcing the back-cover feel. -
Metadata block. The optional fields (
Album,Genre,ReleaseDate), each rendered only if non-null. No "Unknown Album" placeholders — a null field omits its entire row. Two viable treatments; implementer picks one and stays consistent:- Definition rows: small-caps muted label (
Album/Genre/Released) left, value right, oneMudTextrow each. Editorial, calm. - Chips:
Genreas aMudChip(it reads as a tag);AlbumandReleaseDateas definition rows. Mixed, slightly livelier. Recommended: definition rows for all three for coherence, withGenreoptionally promoted to a chip if the page feels too text-flat in review.ReleaseDate(aDateOnly?) formats as year-or-full-date —ToString("MMMM yyyy")reads editorially; avoid raw ISO.
- Definition rows: small-caps muted label (
If all three optional fields are null, the metadata block and its preceding divider both collapse — the page is then just back-link / cover / masthead / play, which is still a complete, composed view. Guard the divider on "at least one optional field present."
4. Cover-art URL resolution
This is the spec's one real fork, because the endpoint does not exist yet (see correction #1).
- Default (ship-now) behavior: treat
ImagePathas not-displayable and render the §3.2 themed placeholder unconditionally. This lets the page ship before 2.1. The placeholder is designed to be the resting state, not a degraded one. - When 2.1 lands (image vault wired,
GET api/image/{entryKey}onDeepDrftContent,ImagePathsemantics changed to "image vault entry key"): resolve cover art by composing the content-API base with the new endpoint —api/image/{track.EntryKey}against the"DeepDrft.Content"client's base address — and bind it to the<img src>. This mirrors how audio is keyed: structured metadata over"DeepDrft.API", binary over"DeepDrft.Content". The<img>should still carry anonerrorfallback to the placeholder so a missing vault entry degrades cleanly. - Do not invent an
api/track/{entryKey}/imageendpoint for this page alone. Cover art is a library-wide capability owned by 2.1; the detail page is a consumer, not the place to define the seam. If cover art is wanted before 2.1's full scope, that is a scope conversation for 2.1, not a side-door endpoint here.
Captured as a dependency edge: Track Detail cover art → 2.1 (cover-art / image vault wired through). The page is shippable without it; the cover block simply stays in placeholder state until 2.1 closes.
5. Play affordance
The page resolves a TrackDto (carrying both Id and EntryKey); playback is keyed by the SQL Id via the existing stream path, so no ID-space ambiguity arises here (unlike the FramePlayer note above — this page always has the full DTO).
- A single prominent Play button under the masthead.
MudButton Variant="Variant.Filled"with a leading play icon, or a largeMudIconButtonif the visual language wants a circular transport control. Label "Play" (or "Play track"). - Wires to the cascaded player:
[CascadingParameter] IStreamingPlayerService PlayerService(cascaded fromAudioPlayerProviderinMainLayout, available to all pages). - Click handler mirrors
TracksView.PlayTrack: ifPlayerService.CurrentTrack?.Id == Track.Id && PlayerService.IsPaused, callTogglePlayPause()(resume); otherwiseSelectTrackStreaming(Track). (The brief namedSelectTrackStreaming— that is correct and current;TracksViewhappens to call the baseSelectTrack, but the streaming entry point is the right one for an explicit Play action.) - State-reactive label: the button should reflect live transport state for this track — show "Pause" when this track is the current track and playing, "Play"/"Resume" otherwise. That requires the same multicast subscription
TracksViewuses: subscribe toPlayerService.StateChangedinOnParametersSet(reference-guarded, idempotent),InvokeAsync(StateHasChanged)on fire, unsubscribe onDispose. The cascade isIsFixed, so without this subscription the button label goes stale when the dock bar drives state. This is mandatory, not polish — copy the_subscribedServiceguard pattern verbatim fromTracksView.razor.cs. - Optional, defer: a secondary "Now playing" indicator or the
WaveformProfilemini-render. Out of scope for v1 — note it as a later enhancement, don't build it.
6. Loading and error states
Three states, driven by the §2 VM flags.
- Loading (
IsLoading): a skeleton matching the loaded layout's silhouette so the transition doesn't reflow — a squareMudSkeleton(Rectangle) for the cover, a wideMudSkeleton(Text) for the title, a narrower one for the artist, and two or three short ones for the metadata rows. Reuse theMudSkeletonidiom already inTracksView.razorfor visual consistency. - Loaded (
Track != null): the §3 layout. - Not found (
NotFound): 404-style messaging — a centeredMudText("Track not found") with a quieter line ("This track may have been moved or removed.") and aMudButton/MudLinkback to/tracks. Theme-coherent, calm, not an error-red alert. This is the failure surface for both a genuine 404 and a deserialize miss; the VM collapses both intoNotFound, so the page need not distinguish them.
7. CSS — new deepdrft- classes
All in DeepDrftPublic/wwwroot/styles/deepdrft-styles.css (shared server/client). Described semantically; implementer writes the rules.
deepdrft-track-detail-container— the centered single column: max-width, horizontal auto-margins, top/bottom padding for vertical rhythm.deepdrft-track-detail-cover— the square cover frame: aspect-ratio 1/1, rounded corners consistent with the gallery cards, subtle shadow/border keyed to the active palette,overflow: hiddenso the<img>crops cleanly. Applies to both the<img>and the placeholderMudPaperso they occupy identical space (no layout jump between placeholder and real art once 2.1 lands).deepdrft-track-detail-masthead— title/artist spacing and the display-serif type treatment (lean on existingdeepdrft-font classes rather than redefining font stacks).deepdrft-track-detail-meta— the metadata block: the small-caps muted label treatment and row rhythm for the definition rows.deepdrft-track-detail-back— the quiet back-link affordance (muted color, hover treatment).
Theme coherence comes for free via MudBlazor palette tokens (Color.Primary, Color.Secondary, surface/text from the active PaletteLight/PaletteDark); the deepdrft- classes handle layout, rhythm, and the serif/small-caps editorial touches that MudBlazor doesn't express. Verify both palettes ("Charleston in the Day" / "Lowcountry Summer Nights") in review — the cover shadow and back-link muted color are the two spots most likely to read wrong in one mode.
8. Pages.cs registration
Link-only — do not add to MenuPages. A per-track detail route is not a destination you navigate to from the header; it's reached contextually (a gallery card, the Now Playing surface). Adding it to nav makes no sense without a specific {entryKey}.
Open question for a follow-up, not this page: should gallery cards / Now Playing link into /track/{entryKey}? That is the inbound-link wiring that makes this page reachable, and it lives in those components, not here. This spec delivers the destination; the links that point at it are a small separate change (likely: make the TrackCard cover/title a MudLink to /track/@track.EntryKey). Flag it so the page isn't shipped orphaned and unreachable.
Dependencies and sequencing
- Shippable now in placeholder-cover form — no blocking dependency.
- Cover art depends on 2.1; until then the cover block is the themed placeholder. (§4)
- Reachability depends on a small inbound-link change in
TrackCard/ Now Playing (§8) — trivial, but required or the page is orphaned. Recommend bundling that link change with this page so v1 is actually reachable.
Working with this file
- Add items by extending an existing phase first; only create a new phase when the addition genuinely doesn't fit any of 1–5. Phase numbers are organisational, not sequencing.
- When something lands, move it to
COMPLETED.mdrather than deleting it. Keep the original "What / Why / Shape" body intact so the history reads as a record of the decision, not just the outcome. - Mark genuinely uncertain items
[speculative]so future readers can tell what is direction vs. commitment. - Open questions belong in the item that raises them, not in a separate "questions" list — they expire when the item does.