feat(e24): the Home surface — one question, one answer, both sources

E24 S2. A new `home` module in layout C+ (the mockup the owner picked): the
time-budget chips on top, one large hero that answers them, then the comfort
shelf and a cross-source resume rail. Home is the landing — deliberately OVER
the remembered last module, since landing on whatever you happened to close the
app on is the habit this epic exists to replace; `LS.startOnHome = "0"` opts an
account back out (Settings toggle lands in S4).

No third card component: each unified card carries the native feed / Plex
payload, so the rails render through the existing VideoCard (`cardsSmall`) and
PlexPosterCard, and `aspect` decides the tile shape without branching on source.
Plex playback hands off to the Plex module via a new `jumpTo` type — the player
lives there with its lazy hls.js and its provider tree, and mounting a second one
on the landing page to save one hand-off would be the expensive way round.

Four defects found by driving it in a real browser against the live account, each
now pinned by a mutation-checked test:

- **The reroll gave the same card back.** A new seed only re-draws the SURPRISE
  tier; the resume tier deterministically returns the most recently progressed
  thing that fits, so anyone with something half-finished pressed a dead button.
  `/pick` now takes an `exclude` list of `<source>:<id>` keys and the client keeps
  a rolling window of 8. Excluding a SHOW also excludes the episode it resolves
  to — otherwise the same suggestion comes back wearing a hat.
- **"Nothing to suggest yet" flashed over a full library** whenever a reroll was
  in flight: the new query key has no data for a beat. An empty state standing in
  for a loading one is the dishonesty the house rules name outright. The hero now
  falls back to the loaded payload while a pick is pending, and only trusts a null
  once the pick has RESOLVED.
- **A budget change replaced the page — chips included — with a spinner**, so the
  control you just clicked vanished under your cursor. `keepPreviousData`, the
  same reason the feed uses it.
- **The hero printed twice**, once as itself and once as the first resume tile.

Two smaller UI calls made from the same session: the budget caption sits NEXT to
its chips rather than in SegmentedToggle's justify-between mode, which on an 80rem
page throws the answers to the far edge; and the feed takes the YouTube mark its
own toolbar already uses, since Home now owns the house icon.

Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 780,
pytest 989. Verified end to end on localdev against the real account: budget
chips re-answer (matched on REMAINING time — a 51-minute video with 2:46 left is
a ten-minute answer), the reroll walks three distinct heroes, and a Plex show
tile opens its drill-down in the Plex module.
This commit is contained in:
2026-09-06 04:14:14 +02:00
parent 53b50231ff
commit f3c0b7be23
21 changed files with 964 additions and 14 deletions
+98
View File
@@ -0,0 +1,98 @@
// The Home surface ("Mit nézzek most?", E24): one hero suggestion, a cross-source resume rail, and
// the comfort shelf. Types mirror `backend/app/discover/` — the card carries the NATIVE feed / Plex
// payload under `video` / `plex`, so the page renders through the existing VideoCard /
// PlexPosterCard instead of a third card component.
import { req } from "./core";
import type { Video } from "./feed";
import type { PlexCard } from "./plex";
// How much time the user says they have. The deterministic replacement for a mood question: the band
// also decides the source (ten minutes is a video, an evening is a film). Bands live server-side
// (`app/discover/budget.py`) — these are just the names on the wire.
export const DISCOVER_BUDGETS = ["short", "medium", "long", "any"] as const;
export type DiscoverBudget = (typeof DISCOVER_BUDGETS)[number];
// Where the hero came from. Travels with the card so the page can label it honestly instead of
// inferring "where you left off" over something the user has never opened.
export type HeroTier = "resume" | "comfort" | "surprise";
export type DiscoverKind = "video" | "movie" | "show" | "episode" | "channel" | "playlist";
// What can go on the comfort shelf. `plex_item` covers a movie or a single episode; a show is pinned
// as `plex_show` because "put that show on" resolves to whichever episode is next.
export const PIN_KINDS = ["video", "channel", "plex_item", "plex_show", "playlist"] as const;
export type PinKind = (typeof PIN_KINDS)[number];
export interface DiscoverCard {
source: "youtube" | "plex";
kind: DiscoverKind;
id: string;
title: string;
subtitle: string | null;
thumb: string | null;
// The ARTWORK's shape, not the module's: a Plex episode ships a landscape still exactly like a
// YouTube video, while movies and shows ship posters. Lets a rail size its tiles without
// branching on `source`.
aspect: "wide" | "poster";
duration_seconds: number | null;
position_seconds: number;
remaining_seconds: number | null;
status: string;
// Exactly one is set (both null for a channel / playlist tile, which are not themselves playable).
video: Video | null;
plex: PlexCard | null;
// Shelf entries only.
pin_id?: number;
/** What actually plays when the tile is clicked: a show's next episode, a channel's newest
* unwatched video, or a copy of the tile itself when it plays directly. Null when the pin has
* nothing to offer right now (a fully-watched channel, an empty playlist). */
next?: DiscoverCard | null;
unwatched_count?: number;
item_count?: number;
}
export interface DiscoverHero {
tier: HeroTier;
card: DiscoverCard;
}
export interface DiscoverPayload {
budget: DiscoverBudget;
hero: DiscoverHero | null;
continue_watching: DiscoverCard[];
comfort: DiscoverCard[];
}
export const discoverApi = {
discover: (budget: DiscoverBudget, seed: number): Promise<DiscoverPayload> =>
req(`/api/discover?budget=${budget}&seed=${seed}`),
// The "Mást kérek" reroll: a new hero alone, so pressing it does not re-fetch the rails under the
// user's cursor.
// `exclude` is the rolling list of `<source>:<id>` keys already shown. Without it a reroll only
// moves the surprise tier, so a user with something half-finished gets the same hero back.
discoverPick: (
budget: DiscoverBudget,
seed: number,
exclude: string[] = [],
): Promise<{ budget: DiscoverBudget; hero: DiscoverHero | null }> =>
req(
`/api/discover/pick?budget=${budget}&seed=${seed}` +
(exclude.length ? `&exclude=${encodeURIComponent(exclude.join(","))}` : ""),
),
comfortPins: (): Promise<{ items: DiscoverCard[] }> => req("/api/discover/pins"),
addComfortPin: (
kind: PinKind,
ref: string,
): Promise<{ ok: boolean; id: number; created: boolean }> =>
req("/api/discover/pins", { method: "POST", body: JSON.stringify({ kind, ref }) }),
removeComfortPin: (pinId: number): Promise<{ deleted: number }> =>
req(`/api/discover/pins/${pinId}`, { method: "DELETE" }),
reorderComfortPins: (ids: number[]): Promise<{ ok: boolean }> =>
req("/api/discover/pins/reorder", { method: "POST", body: JSON.stringify({ ids }) }),
};
/** A card's identity across sources — mirrors `app/discover/cards.py::key`. The reroll's exclusion
* list and the "is this tile the hero?" check both speak it, so the two cannot drift. */
export function discoverCardKey(card: Pick<DiscoverCard, "source" | "id">): string {
return `${card.source}:${card.id}`;
}
+1
View File
@@ -20,6 +20,7 @@ const SLICE_MODULES = [
["authApi", () => import("./auth")],
["setupApi", () => import("./setup")],
["feedApi", () => import("./feed")],
["discoverApi", () => import("./discover")],
["channelsApi", () => import("./channels")],
["syncApi", () => import("./sync")],
["playlistsApi", () => import("./playlists")],