diff --git a/PLAN.md b/PLAN.md index d969a08..f54a853 100644 --- a/PLAN.md +++ b/PLAN.md @@ -89,6 +89,90 @@ These follow from `CONTEXT.md §5`. Direction is strongly implied but no specifi - 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.5 "Stream Now" — random-track instant play + +- **What:** The nav-bar "Stream Now ▶" CTA (desktop and mobile, in `DeepDrftMenu.razor`) today just navigates to `/tracks`. Change it to **pick a random track from the library and start playing it immediately**, in place, without forcing the user onto the gallery page. +- **Why it matters:** It is the single most prominent call-to-action on the site and currently does the least interesting thing — it dumps the listener on a grid and asks them to choose. "Stream Now" should mean *now*: one click, music plays. It is also the lowest-friction way for a first-time visitor to hear the collective's output, which is the whole point of the public site. Borrowed pattern: the "shuffle play" / "I'm feeling lucky" affordance (Spotify's shuffle, Bandcamp's "play random"). + +#### UX flow + +1. User clicks "Stream Now ▶" (desktop CTA or mobile menu item). +2. Button enters a brief loading affordance (disabled + subtle pulse/spinner) while a track is selected — the selection requires at least one HTTP round-trip, so this is not instantaneous. +3. A random track is chosen from the full library (see *Random selection strategy*). +4. The player begins streaming that track via the existing `AudioPlayerBar` dock at the bottom of the layout. The dock is already cascaded into every page by `AudioPlayerProvider` in `MainLayout`, so it appears/animates in exactly as it does when a gallery card is clicked. +5. The user does **not** navigate. They stay on whatever page they were on (most likely `Home`). Music plays; the dock is the player surface. (See *Navigation behavior* for the rationale and the rejected alternative.) +6. On mobile, the menu closes (`CloseMobileMenu`) as part of the click, same as the existing nav links. + +#### Edge cases + +- **Empty library (`TotalCount == 0`):** No track to play. The button should surface a non-blocking, transient message ("No tracks yet") and do nothing else. Do not navigate, do not error-toast aggressively. This is a legitimate cold-start state, not a failure. +- **Metadata fetch fails (HTTP error on `GetPage`):** Surface a transient error on the button ("Couldn't reach the library — try again"), re-enable the button, do not navigate. Reuse the existing `ApiResult` failure check pattern (`result is { Success: true, ... }`). +- **Track fails to stream (selected track is valid metadata but the audio stream errors):** This is already handled downstream by `StreamingAudioPlayerService` / `StreamingErrorHandler` and surfaced through `IPlayerService.ErrorMessage` and the dock. Stream Now does not need its own handling here — it hands off to the same `SelectTrackStreaming` path every other play uses, and inherits that path's error behavior. **Do not duplicate** stream-error handling in the menu. +- **Player already playing something:** Stream Now interrupts it and starts the random track. No confirmation prompt — "Stream Now" is an explicit user command to play something new. (If a future "is something already playing?" courtesy is wanted, capture it then; not in scope now.) +- **Repeat clicks / same-track-twice:** Acceptable for v1 to occasionally re-pick the currently-playing track (uniform random over a small library will do this). If it becomes annoying, a cheap "exclude `PlayerService.CurrentTrack?.Id`" filter on the candidate set is a one-line follow-up — note it, don't build it. + +#### Random selection strategy + +The API is paginated (`GET api/track/page` → `PagedResult` with `Items`, `TotalCount`, `PageNumber`, `PageSize`, unauthenticated). The library is small today but the strategy must not assume that. + +**Chosen strategy — two-request, count-then-fetch (uniform over the whole library):** + +1. Fetch page 1 at the existing default page size (`GetPage(1, pageSize)`). Read `TotalCount`. +2. If `TotalCount == 0` → empty-library path. +3. If `TotalCount <= pageSize` → all tracks are already in the page-1 `Items`; pick `Items[Random.Shared.Next(Items.Count)]`. **One request, done.** +4. Otherwise compute a uniform global index `i = Random.Shared.Next(TotalCount)`, derive its page `p = i / pageSize + 1` and in-page offset `o = i % pageSize`. If `p == 1`, reuse the page-1 result already in hand; else fetch `GetPage(p, pageSize)` and pick `Items[o]`. **At most two requests.** + +This gives true uniform-random selection across the entire library with at most two round-trips, and degenerates to a single request for any library that fits in one page (the common case today). It does not fetch the whole library, and it does not bias toward page 1. + +**Alternatives considered (and why not):** + +- *Page-1-only random:* fetch page 1, pick random from `Items`. One request, dead simple — but only ever plays from the first page once the library exceeds one page. A silent correctness cliff exactly when the library grows, which is the smell this team treats as a design failure. Rejected. +- *Server-side `GET api/track/random` endpoint:* cleanest possible client (one request, `ORDER BY RANDOM() LIMIT 1` server-side, no count math). Genuinely better long-term — it is O(1) client work, leaks no pagination assumptions, and is the right home for "exclude currently-playing" logic. But it is a new API surface (`DeepDrftAPI` endpoint + `TrackService` method + `TrackClient` method) for a feature that the two-request client strategy already serves correctly. **Deferred, not rejected** — see open question. If/when album/genre views (2.2) or search (2.3) add server-side filtering, a `random` endpoint that honors the same filter ("shuffle within this genre") becomes clearly worth it, and the client should migrate to it then. + +#### Player integration seam + +**The architecture already supports this with no new player seam.** The player is a cascading singleton: + +- `AudioPlayerProvider.razor` cascades `IStreamingPlayerService` (`IsFixed="true"`) across the entire layout from `MainLayout`. +- `DeepDrftMenu` is rendered inside that layout, so it can take `[CascadingParameter] IStreamingPlayerService PlayerService` exactly as `TracksView` and `TrackDetail` do. +- Playing a track is one call: `PlayerService.SelectTrackStreaming(track)`. This is the live entry point (`SelectTrack` delegates to it; the buffered path is dead). `TrackDetail.PlayTrack` is the precedent to mirror. + +So the launch logic is **not** duplicated from `TracksView` — both call into the same `SelectTrackStreaming`. The only new code is the random-*selection* helper, which is a data concern, not a player concern. + +**Where the selection helper lives:** put it behind a small service rather than inline in the menu component, to keep `DeepDrftMenu` a presentation surface (per the project's MVVM convention — components render and dispatch only). + +- New method on `ITrackDataService` / `TrackClientDataService`: `Task> GetRandomTrack()`, implementing the count-then-fetch strategy above over the existing `TrackClient.GetPage`. This keeps all track-fetch concerns on the one seam components already inject, and means the SSR/WASM split is handled for free (the same way `GetPage` / `GetTrack` already are). +- `DeepDrftMenu` injects `ITrackDataService` and the cascaded `PlayerService`, and gets a `StreamNow()` handler: call `GetRandomTrack()`, on success call `PlayerService.SelectTrackStreaming(track)`, on empty/failure show the transient message. + +**Critical constraint — AudioContext user-gesture.** Browsers (Safari most strictly) only allow an `AudioContext` to start inside a user-gesture call stack. `SelectTrackStreaming` starts the context. Stream Now does an `await GetRandomTrack()` (network) *before* it can call `SelectTrackStreaming` — and an intervening `await` can lose the gesture context on Safari, leaving the context suspended and no audio despite a "playing" state. This is the one real seam decision. Two acceptable mitigations, in order of preference: + +- **Preferred:** ensure the `AudioContext` is created/resumed at the *start* of the click handler, before the network await — i.e. an explicit "warm the context" call synchronous with the gesture, then fetch, then stream. `AudioInteropService.CreatePlayerAsync` already exists and polls readiness; the player likely needs a cheap "resume context now" entry point callable from the gesture. If `InitializeAsync` is safe to call eagerly inside the gesture, that is the hook. **Flag for the implementer:** confirm whether an existing method resumes a suspended context synchronously; if not, this is a small addition to the player service, not a redesign. +- **Fallback (if the above is fiddly):** accept that the *first* Stream Now click after a cold page load may require the context to warm, and rely on the same gesture-handling the gallery already uses (gallery play works today, so the existing path already satisfies the gesture requirement when the call is close enough to the click). Validate on Safari before considering this done — see Phase 1.7, which already tracks Safari AudioContext quirks. + +#### Navigation behavior + +**Decision: play in place, do not navigate.** + +- *Play in place (chosen):* The dock is a layout-level surface that already persists across navigation. The user clicked "Stream Now," so the valuable thing — audio — happens immediately wherever they are. Most clicks come from `Home`, and keeping the listener on the hero/about page while music starts is a better first impression than yanking them to a grid. The player bar gives them full transport control without the gallery. +- *Navigate to `/tracks` then auto-play (rejected):* Pros — lands the user somewhere they can pick a *next* track, and the gallery would reflect the now-playing track via its existing active-card state. Cons — it conflates "play something now" with "go browse," adds a navigation the user didn't ask for, and reintroduces the prerender/persisted-state dance (`tracks-page` key) for a side effect. If we later want a "and show me the gallery" behavior, that is a *second*, distinct affordance ("Browse" already exists as nav), not what "Stream Now" should do. +- **Leave room for:** a future variant where Stream Now *also* navigates if clicked from a context where the dock would be visually awkward. No evidence that context exists today; do not build for it. + +#### Acceptance criteria + +- Clicking "Stream Now ▶" (desktop CTA) with a non-empty library selects a track uniformly at random across the *entire* library (not just page 1) and begins streaming it via the existing dock, without navigating away from the current page. +- Clicking "Stream Now ▶" in the mobile menu does the same and closes the mobile menu. +- Selection issues **at most two** HTTP requests, and exactly one when the library fits in a single page. +- With an empty library, the button shows a transient "no tracks" message and does not navigate or throw. +- With a failed metadata fetch, the button shows a transient error, re-enables, and does not navigate. +- A track that streams-errors after selection surfaces through the *existing* player error path — no new error handling in the menu. +- The menu component contains no track-fetch logic inline: selection goes through `ITrackDataService.GetRandomTrack()`; playback goes through `PlayerService.SelectTrackStreaming`. No duplication of `TracksView`'s launch logic. +- Audio actually plays on the first click after a cold load on Chrome and Safari (the user-gesture/AudioContext constraint is satisfied). If Safari needs the context warmed inside the gesture, that hook is added to the player service, not worked around in the menu. +- While selection is in flight, the button is disabled to prevent double-launch. + +#### Open question + +- **Server-side `GET api/track/random` now, or defer?** The client-side count-then-fetch strategy is correct and ships without touching the API. A server endpoint is cleaner and is the natural home for "exclude current track" and "shuffle within filter," but those don't exist yet. Recommendation: **ship client-side now; revisit the endpoint when 2.2/2.3 land server-side filtering**, at which point "shuffle within this genre/search" makes the endpoint clearly worth it. Decision is Daniel's if he wants the endpoint up front. + ### 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.