290 lines
30 KiB
Markdown
290 lines
30 KiB
Markdown
# 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 — Two-app architectural split
|
||
|
||
The public site and the CMS are being split into two independent Blazor applications. **Design locked by Daniel 2026-05-19** at `design/TWO-APP-SPLIT.md` §10. All ten open questions resolved. Implementation phases ready to schedule in the phased rollout at §8.
|
||
|
||
**Names locked:** `DeepDrftPublic` (public host), `DeepDrftManager` (CMS host), `DeepDrftShared.Client` (shared RCL). Subdomain topology: `deepdrft.com` (public) and `manage.deepdrft.com` (CMS).
|
||
|
||
Supersedes the host-shape pieces of `CMS-PLAN.md §2`; the CMS feature waves in `CMS-PLAN.md` survive unchanged and move to the new host.
|
||
|
||
**Phases 1–4 landed (Wave 2):** `DeepDrftManager` host created, AuthBlocks stripped from `DeepDrftWeb`, `DeepDrftShared.Client` RCL extracted, and `DeepDrftWeb` / `DeepDrftWeb.Client` renamed to `DeepDrftPublic` / `DeepDrftPublic.Client`. Phase 5 (nginx/deploy topology, dev-ops territory) is next.
|
||
|
||
---
|
||
|
||
## 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** — `VaultIndexDirectory` no longer drops the lock before its async disk write; the index file can no longer be clobbered by interleaved writers.
|
||
- **Repository semantics** — `TrackRepository.Update` now fails-fast when an `Id` is not found instead of silently issuing an `INSERT`.
|
||
- **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, `IAsyncDisposable` on 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* `playbackOffset` currently 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 in `WavOffsetService` + 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 `blockAlign` rounding-down as forward seek (already enforced in `WavOffsetService.alignedOffset` and `StreamDecoder.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. `MimeTypeExtensions` already 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.ProcessWavFileAsync` with a format-router that selects a per-format processor; replace `WavOffsetService` with 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 `decodeAudioData` over the full payload (loses streaming-start). The richer path is per-format chunked decoders. Worth a design pass before committing.
|
||
- **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 `AudioBuffer`s through `decodeAudioData`. If a future path uses `MediaElementAudioSourceNode` (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 `HttpClient` request kicked off when the current track passes a configurable threshold (e.g. last 10 seconds). Bytes accumulate into a staged `StreamDecoder` instance 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 `IPlayerService` or 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 future `PlaylistService` orchestrates 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 `PlaybackScheduler` instances suffice — each owns its own gain node, crossfaded via `GainNode.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 first `AudioBufferSourceNode.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 `processStreamingChunk` aborts 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); `await` resolves immediately and the next `initialize()` can run against a not-yet-closed context.
|
||
- iOS Safari < 15 had streaming-fetch quirks; `HttpCompletionOption.ResponseHeadersRead` behaviour 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 — detect `webkitAudioContext` and poll `state === "closed"` with a short timeout instead of trusting the `await`. 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.Image` is implemented end-to-end and exercised by tests, but the production surface only registers a `tracks` vault of type `Audio`. `ImagePath` on `TrackEntity` is a free-form URL string today; it should resolve to an entry in an image vault served by `DeepDrftContent`.
|
||
- **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 (`images` or `art`, type `Image`) in `Startup.ConfigureDomainServices` and in the CLI.
|
||
- Add `GET api/image/{entryKey}` (unauthenticated, mirrors track read) and `PUT api/image/{entryKey}` (ApiKey, mirrors track write) on `DeepDrftContent`.
|
||
- Change `TrackEntity.ImagePath` semantics from "URL" to "image vault entry key" (column rename optional — could remain `image_path` with semantic shift, or could become `image_entry_key` for clarity).
|
||
- Add an image processor sibling of `AudioProcessor`.
|
||
- **Prerequisite:** None.
|
||
- **Constraint:** This is a small schema-semantics migration. Existing rows have `null` ImagePath 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:** `TrackCard` already 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 in `TrackService.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 same `TracksViewModel` / `PagedResult<TrackEntity>` and differ only at the rendering layer.
|
||
- `TrackService.GetPaged` extended to accept a filter expression (or a simple structured filter DTO).
|
||
- `PagingParameters<T>` extended with a `Where: Expression<Func<T, bool>>?` or a parallel `FilterParameters<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:** `TracksViewModel` exposes sort but no filter. `TrackService.GetPaged` accepts only sort. Simple text search across `TrackName` / `Artist` / `Album` is the obvious first cut.
|
||
- **Why it matters:** Once the library has more than ~30 entries, sort-only browsing is friction.
|
||
- **Shape:** Same extension to `GetPaged` as 2.2. UI is a debounced text input bound to the VM's filter property. EF Core translates `Contains` to SQLite `LIKE`.
|
||
- **Prerequisite:** Fold into 2.2 if both are being done — the same `GetPaged` extension 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 polymorphic `MediaEntity` with discriminator. The choice affects how much code in `TrackService` / `TrackController` can be reused.
|
||
- New vault type(s). `MediaVaultType.Media` exists and is the obvious home for video; sessions are probably still `Audio`.
|
||
- New routes, new UI surfaces, new player considerations (video has its own playback element and does not go through the WAV decoder).
|
||
- **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=1234567` as 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: `LoadResourceStreamAsync` returning an open `FileStream` instead of `LoadResourceAsync` materialising the whole buffer. `File(stream, mime, enableRangeProcessing: true)`. The `WavOffsetService` synthesised-header path becomes a special-case rather than the default.
|
||
- Client: consider `MediaElementAudioSourceNode` instead of (or alongside) `decodeAudioData`-fed `AudioBufferSourceNode`s. Native seek, native range, native cache; FFT tap on the audio graph still works for the spectrum visualiser.
|
||
- **Prerequisite:** None functionally, but the audit explicitly flagged this trade-off as architecture-intentional — the current path was chosen because spectrum analysis wants `AudioBuffer`s. Re-deciding the trade-off is itself part of the work.
|
||
- **Constraint:** A move to `MediaElementAudioSourceNode` changes 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 before `File(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 `LoadResourceStreamAsync` overload 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 to `Range` headers 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 `DeadLetterLog` recording orphaned `entryKey`s 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.md` files need writing/rewriting per the brief in `DOC_PLAN.md`. Five are rewrites (drift from the `.NET 10` upgrade and structural moves); three are new (`DeepDrftWeb.Services`, `DeepDrftContent.Services` — the two libraries where most domain logic now lives — plus the open question on `DeepDrftContent.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`, referencing `MediaPath` that has been `EntryKey` for two migrations, describing a `FileDatabase/` tree inside `DeepDrftContent` that has moved out, and missing entirely for the two `*.Services` libraries.
|
||
- **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.md` judgement 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.
|
||
- **`ITrackService` interface.** 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.
|
||
|
||
---
|
||
|
||
## 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.md`** rather 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.
|
||
|
||
---
|
||
|
||
## AudioPlayerBar Responsive Unification
|
||
|
||
**Goal:** Collapse the two divergent Razor trees in `AudioPlayerBar.razor` (`@if (_isDesktop)` / `@else`) into one markup tree where CSS — not a runtime breakpoint flag — does the responsive work. Removes the first-render layout flash caused by the async `BrowserViewportService` subscription, and deletes the inline duplication of the transport cluster that the mobile branch carries today.
|
||
|
||
This is presentation-layer cleanup, not a feature. It fits the `CONTEXT.md §6` "one source of truth, multiple views" principle applied at the markup level: one composition, two rendered shapes, divergence pushed entirely into rendering (CSS) rather than into a branch in the component.
|
||
|
||
### The core observation
|
||
|
||
The mobile branch's top row — `[PlayerControls + spinner] [Timestamp] [VolumeControls]` — is not a different set of controls from `PlayerTransportZone`. It *is* `PlayerTransportZone` (which already composes `PlayerControls` + spinner + `TimestampLabel`) laid out **horizontally instead of vertically**, with `VolumeControls` sitting beside it. The desktop branch already uses `PlayerTransportZone`; the mobile branch hand-rolls the same parts inline only because it needed a different axis and wanted volume adjacent.
|
||
|
||
So the entire desktop/mobile split reduces to two differences:
|
||
|
||
1. **`PlayerTransportZone` internal axis** — vertical (controls-over-timestamp) on wide, horizontal (controls-beside-timestamp) on narrow.
|
||
2. **`VolumeControls` position** — far-right of the single row on wide; tucked beside the transport cluster on narrow (so the seek zone can take the full width below).
|
||
|
||
Both are achievable with one markup tree. Recommendation below; two alternatives considered after it.
|
||
|
||
### 1. Layout grid / arrangement (recommended)
|
||
|
||
One outer **flex container** that wraps, holding three children in source order: `PlayerTransportZone`, `VolumeControls`, `PlayerSeekZone`.
|
||
|
||
```
|
||
┌─ .player-layout (flex, wrap) ──────────────────────────────────┐
|
||
│ [PlayerTransportZone] [VolumeControls] [PlayerSeekZone ───] │
|
||
└────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- **Wide (≥ breakpoint):** `flex-direction: row; flex-wrap: nowrap`. Transport at its natural `min-width`, VolumeControls at natural width, SeekZone `flex-grow: 1` eating the middle. To match today's desktop order (transport — seek — volume), SeekZone is given `order: 2` and VolumeControls `order: 3` at this breakpoint. Transport stays `order: 1`.
|
||
- **Narrow (< breakpoint):** still `flex-wrap: wrap`, but SeekZone is forced to a new line via `flex-basis: 100%`. Transport (`order: 1`) and VolumeControls (`order: 2`) share the top line; SeekZone (`order: 3`, `flex-basis: 100%`) drops full-width below. This reproduces today's mobile shape exactly: `[transport] [volume]` over `[seek full-width]`.
|
||
|
||
The `order` swap is what lets a single source order serve both shapes. Source order is transport → volume → seek (the narrow reading order); `order` props rewrite it to transport → seek → volume on wide. Keeping source order = narrow order means the DOM reads naturally for the smaller, more constrained surface.
|
||
|
||
**Narrow-viewport answers to the brief's specific questions:**
|
||
- *Where does VolumeControls go?* Beside the transport cluster, on the same top line — left of nothing, right of transport — exactly as today's mobile row. It does **not** drop below with the seek zone.
|
||
- *Does PlayerTransportZone stay vertical or go horizontal?* It goes **horizontal** at narrow, so `[controls+spinner]` and `[timestamp]` sit side by side rather than stacked. This is the change that lets the transport cluster + volume fit on one line and matches the current mobile row.
|
||
|
||
### 2. PlayerTransportZone changes
|
||
|
||
`PlayerTransportZone` needs to flip its internal axis at the breakpoint. Two ways:
|
||
|
||
- **(Recommended) CSS-only, no new parameter.** The component's outer `MudStack Row="false"` renders a flex column. Scoped CSS in `PlayerTransportZone.razor.css` overrides `flex-direction` to `row` below the breakpoint via a media query, and adjusts `align-items` / gap to match. No C# parameter, no parent coordination — the component adapts to its own width context. This keeps the component self-contained and the parent ignorant of breakpoints.
|
||
- Caveat: MudBlazor's `MudStack` emits inline-ish utility classes; overriding `flex-direction` on the rendered root needs a `::deep` rule with sufficient specificity (or a wrapper element the component controls). Confirm the generated class can be overridden cleanly; if not, fall back to wrapping the `MudStack` in a `div` the scoped CSS owns.
|
||
|
||
- **(Alternative) Add a `Row` (or `Vertical`) bool parameter.** Parent passes the axis explicitly. Rejected as the default because it reintroduces parent-side breakpoint awareness — the parent would need to know "narrow ⇒ Row=true," which is exactly the runtime-flag coupling we are removing. CSS-only keeps the responsive decision in CSS where the brief wants it. Keep this in pocket only if the `MudStack` override proves intractable.
|
||
|
||
Net: **prefer no parameter change to `PlayerTransportZone`.** CSS in its own scoped file handles the axis flip.
|
||
|
||
### 3. CSS strategy
|
||
|
||
- **`AudioPlayerBar.razor.css`** owns the *outer arrangement*: the `.player-layout` flex container, the `flex-wrap`, the `order` assignments, and the `flex-basis: 100%` line-break on the seek zone. This is where the three-zone composition lives, so its responsive rules belong here. Add a single media query block to the existing file (which already carries a `max-width: 768px` block for the dock/spacer — reuse the same threshold for consistency).
|
||
- **`PlayerTransportZone.razor.css`** owns the *internal axis flip* of the transport cluster (column ⇄ row) and keeps its existing `min-width: 200px` on `.controls-left` (note: that `min-width` should be reviewed — at narrow widths a 200px floor on the transport cluster may crowd VolumeControls; consider relaxing it inside the narrow media query).
|
||
- **Breakpoint threshold:** Use **`max-width: 599.98px`** to mirror MudBlazor's `Sm` boundary (600px), since the old `_isDesktop` flag used `Breakpoint.Sm` (`>= Sm` = desktop). This preserves the exact switch point users see today. Do **not** silently inherit the existing `768px` block's threshold for the *layout* switch — 768 is the dock-padding breakpoint and is a different concern; conflating them would move the layout switch point. (If Daniel prefers a single unified breakpoint for the whole component, that is a one-line decision — call it out, don't assume.)
|
||
|
||
Decision needed from Daniel: keep the layout switch at the historical 600px (`Sm`), or unify everything on 768px? Recommendation: keep 600px to preserve current behaviour; revisit only if the 200px transport floor forces an earlier wrap.
|
||
|
||
### 4. What `AudioPlayerBar.razor.cs` loses
|
||
|
||
All of the following are deleted — they exist only to drive the now-removed `@if (_isDesktop)` branch:
|
||
|
||
- The `private bool _isDesktop = true;` field.
|
||
- The `private Guid _viewportSubscriptionId;` field.
|
||
- The `[Inject] public required IBrowserViewportService BrowserViewportService { get; set; }` injection.
|
||
- The entire `OnAfterRenderAsync(bool firstRender)` override (its only job is the breakpoint read + subscription).
|
||
- The `BrowserViewportService.UnsubscribeAsync(_viewportSubscriptionId)` call inside `DisposeAsync` (the rest of `DisposeAsync` — the `StateChanged` detach — **stays**).
|
||
- The `using MudBlazor.Services;` import, once `IBrowserViewportService` / `ResizeOptions` references are gone (verify nothing else in the file needs it — `using MudBlazor;` stays for `Color`, `Size`, etc.).
|
||
|
||
`IAsyncDisposable` stays on the class (still needed for the `StateChanged` unsubscribe); `DisposeAsync` remains but loses its viewport line. No other `.cs` logic changes — playback, seek, volume, minimize, and the `StateChanged` cascade plumbing are untouched.
|
||
|
||
### Razor consolidation (what the single tree looks like)
|
||
|
||
Replace the `@if (_isDesktop) { ... } else { ... }` block (lines 15–72) with one composition: the `.player-layout` flex container holding `PlayerTransportZone`, `VolumeControls`, and `PlayerSeekZone` in that source order. The mobile branch's hand-rolled `PlayerControls` + spinner + `TimestampLabel` + inline `VolumeControls` (lines 44–71) is deleted entirely — it is fully subsumed by `PlayerTransportZone` + the horizontal CSS flip. `PlayerWindowControls` (top-right minimize/close) and the error `MudAlert` are unchanged and sit outside `.player-layout` as they do today.
|
||
|
||
### Alternatives considered (for the outer layout)
|
||
|
||
- **(A) MudBlazor `MudGrid` / `MudItem` with `xs`/`sm` breakpoint props** instead of a hand-rolled flex container. MudBlazor's grid carries breakpoint props natively (`xs="12" sm="auto"`), which is idiomatic and avoids custom media queries for the *wrap*. Viable, and arguably the more "MudBlazor-native" move. Downside: the `order` swap (seek between transport and volume on wide, but below both on narrow) is awkward in a 12-column grid — you end up fighting column order with `order` utilities anyway, and the seek zone's full-width-on-narrow / grow-on-wide behaviour is cleaner as a flex `flex-grow` + `flex-basis` than as grid columns. Net: grid is tidy for the wrap but clumsy for the reorder. Flex wins on this specific layout.
|
||
- **(B) CSS Grid with `grid-template-areas` swapped per breakpoint.** Most declarative — name the three areas and redraw the template in the media query. Cleanest conceptually, and the reorder is trivial (just rewrite the template). Downside: more CSS machinery than this three-element layout warrants, and `grid` interplay with MudBlazor component roots (which bring their own display) needs care. Reasonable if the flex `order` approach gets fiddly; otherwise heavier than needed.
|
||
- **(Recommended) Flexbox with `order` + `flex-basis: 100%` line break.** Least machinery, maps directly onto the two differences identified, reuses the component's existing flex-based `MudStack` mental model. Recommended unless the `MudStack` override in §2 forces a rethink.
|
||
|
||
### Trade-offs / risks
|
||
|
||
- **MudStack override specificity.** The one real implementation risk is whether scoped `::deep` CSS can reliably override `MudStack`'s rendered `flex-direction`. If it can't, either wrap in a component-owned `div` (cheap) or fall back to the `Row` parameter (§2 alternative). Worth a 10-minute spike before committing the CSS-only path.
|
||
- **Breakpoint divergence.** Two breakpoints now live in the component's CSS (600px for layout, 768px for dock padding). That's pre-existing for the padding; the layout one is new. If this bothers anyone, unify on one value — but that's a behaviour change, so it's Daniel's call, flagged above.
|
||
- **No flash, by construction.** Because CSS evaluates at first paint (no async round-trip), the first-render flash is gone — this is the whole point and the primary win.
|
||
- **`min-width: 200px` on `.controls-left`** may need relaxing at narrow widths once the transport cluster goes horizontal beside volume; check during implementation.
|