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
+7
View File
@@ -59,6 +59,13 @@ def _base(
}
def key(card: dict) -> str:
"""The identity of a card ACROSS sources — `<source>:<id>`. A Plex rating_key and a YouTube video
id could in principle collide as bare strings, and the reroll's exclusion list has to be able to
say "not that one" about either."""
return f"{card['source']}:{card['id']}"
def from_video(row: dict) -> dict:
"""`row` is a `feedquery.serialize` dict — the same object the feed grid renders."""
card = _base(
+22 -3
View File
@@ -29,6 +29,10 @@ from app.plex.lookups import by_rating_key
HERO_TIERS = ("resume", "comfort", "surprise")
# How deep the surprise tier draws before giving up. The client's exclusion window is 8 (see
# `Home.tsx`), so a draw of 12 can always step past a full window of already-shown picks.
SURPRISE_DRAW = 12
def _playable(db: Session, uid: int, card: dict) -> dict | None:
"""A hero has to have a Play button, so a Plex SHOW is resolved to the episode that would start.
@@ -50,22 +54,37 @@ def pick(
*,
resume: list[dict] | None = None,
shelf: list[dict] | None = None,
exclude: set[str] | None = None,
) -> dict | None:
"""`{"tier": ..., "card": ...}`, or None when nothing at all fits.
`resume` and `shelf` may be passed pre-computed so the consolidated `/api/discover` response does
not build the same two rails twice; the reroll endpoint passes neither and pays only for what it
reaches."""
reaches.
`exclude` is what makes "Mást kérek" mean something. A new seed alone only re-draws the SURPRISE
tier: the resume tier deterministically returns the most recently progressed thing that fits, so
pressing reroll on a user who has something half-finished handed back the identical card and the
button looked dead (measured in the browser, 2026-09-06). Skipping the cards already shown makes
every press produce a different answer, at every tier."""
skip = exclude or set()
for card in rails.continue_watching(db, user) if resume is None else resume:
playable = _playable(db, user.id, card)
# Excluded on the card the USER saw (the show), not only on what it resolves to, so a
# rerolled-past show does not come straight back as its own next episode.
if cards.key(card) in skip or (playable is not None and cards.key(playable) in skip):
continue
if playable is not None and fits(playable["remaining_seconds"], budget):
return {"tier": "resume", "card": playable}
for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf):
if fits(card["remaining_seconds"], budget):
if cards.key(card) not in skip and fits(card["remaining_seconds"], budget):
return {"tier": "comfort", "card": card}
for card in rails.surprise(db, user, budget, seed, limit=1):
# A wider draw than the one card we need: the shuffle is seeded, so without the headroom an
# excluded pick would leave the tier empty rather than move past it.
for card in rails.surprise(db, user, budget, seed, limit=SURPRISE_DRAW):
if cards.key(card) not in skip:
return {"tier": "surprise", "card": card}
return None
+16 -2
View File
@@ -60,18 +60,32 @@ def get_discover(
}
# How many already-shown cards a reroll may carry. The client keeps a rolling window (see
# `Home.tsx`); the cap is here so a crafted URL cannot turn the exclusion list into an unbounded scan.
MAX_EXCLUDE = 32
@router.get("/pick")
def get_pick(
budget: Budget = DEFAULT_BUDGET,
seed: int = 0,
exclude: str = "",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""The "Mást kérek" reroll: a hero on its own, for a new seed and/or a new budget.
One seed drives BOTH sources (see `discover/queries.py`) — otherwise a reroll would re-draw the
Plex half while the YouTube half stood still, and the button would look broken half the time."""
return {"budget": budget, "hero": hero_mod.pick(db, user, budget, seed)}
Plex half while the YouTube half stood still, and the button would look broken half the time.
`exclude` is a comma-separated list of `<source>:<id>` keys the user has already been shown. It
is what makes the button honest: a seed alone only moves the surprise tier, so a user with
something half-finished got the same card back every press."""
skip = {p for p in exclude.split(",") if p}
return {
"budget": budget,
"hero": hero_mod.pick(db, user, budget, seed, exclude=set(list(skip)[:MAX_EXCLUDE])),
}
@router.get("/pins")
+56
View File
@@ -356,6 +356,62 @@ def test_the_seed_decides_which_source_leads(db):
assert leaders == {"youtube", "plex"}
def test_the_reroll_must_be_able_to_move_past_the_resume_tier(db):
"""A new seed alone only re-draws the SURPRISE tier — the resume tier deterministically returns
the most recently progressed thing that fits, so without an exclusion the reroll handed the same
card straight back and the button looked dead (found in the browser, 2026-09-06)."""
user, _ = _seed_yt(db)
db.add_all(
[
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW,
),
VideoState(
user_id=user.id,
video_id="vshort",
status="new",
position_seconds=60,
progress_updated_at=NOW - timedelta(hours=1),
),
]
)
db.commit()
first = hero.pick(db, user, "any", seed=1)
assert first["card"]["id"] == "vmed"
# Same seed, same everything — only the exclusion differs.
second = hero.pick(db, user, "any", seed=1, exclude={"youtube:vmed"})
assert second["card"]["id"] == "vshort"
def test_excluding_a_show_also_excludes_the_episode_it_resolves_to(db):
"""The user rerolled past the SHOW they were shown; handing back its next episode under a
different title would be the same suggestion wearing a hat."""
user, _ = _seed_yt(db)
_, show, ep1 = _seed_plex(db, user)
_enable_plex(db)
db.add(
PlexState(
user_id=user.id,
item_id=ep1.id,
status="new",
position_seconds=120,
progress_updated_at=NOW,
)
)
db.commit()
shown = hero.pick(db, user, "any", seed=1)
assert shown["card"]["id"] == "ps1e1" # the show resolved to its in-progress episode
# Excluding what the USER saw — the show — must move the tier on, not return the episode again.
after = hero.pick(db, user, "any", seed=1, exclude={"plex:ps1"})
assert after is None or after["card"]["id"] != "ps1e1"
# ── comfort shelf ─────────────────────────────────────────────────────────────────────────────────
+184
View File
@@ -0,0 +1,184 @@
// @vitest-environment jsdom
//
// E24 S2: the Home surface. Covers the four behaviours the browser round actually caught, each of
// which is invisible to tsc and to a render-only check:
// • the hero's kicker comes from the SERVER's tier, never from the card's shape;
// • the hero is not printed twice (once as the hero, once as the first resume tile);
// • a budget change asks for the new budget and keeps the old answer on screen meanwhile;
// • the reroll sends the already-shown card as an exclusion — without it the resume tier hands the
// identical card straight back and the button looks dead.
// i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts?.time ? `${key}:${opts.time}` : key,
i18n: { language: "en" },
}),
};
});
const discover = vi.fn();
const discoverPick = vi.fn();
vi.mock("../lib/api", () => ({
api: {
discover: (...a: unknown[]) => discover(...a),
discoverPick: (...a: unknown[]) => discoverPick(...a),
setState: () => Promise.resolve(),
watchLaterAdd: () => Promise.resolve(),
watchLaterRemove: () => Promise.resolve(),
},
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
// The page hands Plex playback off to the Plex module; neither provider is under test here.
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
vi.mock("./PlexProvider", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
import Home from "./Home";
function ytCard(id: string, title: string, extra: Record<string, unknown> = {}) {
return {
source: "youtube",
kind: "video",
id,
title,
subtitle: "A channel",
thumb: null,
aspect: "wide",
duration_seconds: 600,
position_seconds: 120,
remaining_seconds: 480,
status: "new",
plex: null,
video: {
id,
title,
channel_id: "UC1",
channel_title: "A channel",
channel_thumbnail: null,
channel_url: "https://youtube.com/channel/UC1",
published_at: "2026-01-01T00:00:00Z",
thumbnail_url: null,
duration_seconds: 600,
view_count: 10,
is_short: false,
live_status: "none",
status: "new",
position_seconds: 120,
saved: false,
watch_url: `https://www.youtube.com/watch?v=${id}`,
},
...extra,
};
}
const payload = (heroId: string, tier = "resume") => ({
budget: "any",
hero: { tier, card: ytCard(heroId, `Hero ${heroId}`) },
continue_watching: [ytCard(heroId, `Hero ${heroId}`), ytCard("v2", "Second thing")],
comfort: [],
});
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<Home />
</QueryClientProvider>,
);
}
beforeEach(() => {
discover.mockReset();
discoverPick.mockReset();
discover.mockResolvedValue(payload("v1"));
});
afterEach(cleanup);
describe("Home", () => {
it("labels the hero from the server's tier, not from the card", async () => {
// The same card shape is served under a `surprise` tier: a client that inferred "where you left
// off" from the resume position would get this wrong, which is the dishonest state we forbid.
discover.mockResolvedValue(payload("v1", "surprise"));
mount();
expect(await screen.findByText("home.hero.surprise")).toBeTruthy();
expect(screen.queryByText(/home\.hero\.resume/)).toBeNull();
});
it("interpolates the remaining time into the resume kicker", async () => {
mount();
// 480s left → 8:00, passed through the key-passthrough t() as `<key>:<time>`.
expect(await screen.findByText("home.hero.resumeLeft:8:00")).toBeTruthy();
});
it("does not print the hero again as the first resume tile", async () => {
mount();
await screen.findByText("home.hero.resumeLeft:8:00");
// "Hero v1" is the hero's own heading; the rail below must not repeat it.
expect(screen.getAllByText("Hero v1")).toHaveLength(1);
expect(screen.getByText("Second thing")).toBeTruthy();
});
it("asks for the chosen budget and keeps the previous answer on screen meanwhile", async () => {
mount();
await screen.findByText("Hero v1");
// A never-resolving refetch: the point is what the page shows WHILE it waits.
discover.mockReturnValue(new Promise(() => {}));
fireEvent.click(screen.getByText("home.budget.short"));
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0));
// Still the old answer — not a spinner that would take the chips away with it.
expect(screen.getByText("Hero v1")).toBeTruthy();
});
it("rerolls with the shown card excluded, and holds the hero while it loads", async () => {
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"]),
);
expect(await screen.findByText("Rerolled")).toBeTruthy();
});
it("never shows the empty state while a reroll is in flight", async () => {
// The regression the browser round caught: a new query key has no data for a beat, so the hero
// fell to null and the page rendered "nothing to suggest" over a perfectly good library.
discoverPick.mockReturnValue(new Promise(() => {}));
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalled());
expect(screen.queryByText("home.hero.emptyAny")).toBeNull();
expect(screen.getByText("Hero v1")).toBeTruthy();
});
it("says the shelf is empty rather than hiding it", async () => {
mount();
expect(await screen.findByText("home.comfort.empty")).toBeTruthy();
});
});
+446
View File
@@ -0,0 +1,446 @@
import { lazy, Suspense, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { Play, RotateCcw } from "lucide-react";
import { qk } from "../lib/queryKeys";
import {
api,
discoverCardKey,
type DiscoverBudget,
type DiscoverCard,
type HeroTier,
type PlexCard,
type Video,
type VideoStatus,
} from "../lib/api";
import { formatDuration } from "../lib/format";
import { toggleWatchState } from "../lib/plexWatch";
import { Button } from "./ui/Button";
import { Carousel } from "./ui/Carousel";
import SegmentedToggle from "./ui/SegmentedToggle";
import { LoadingState, StateMessage } from "./ui/QueryState";
import VideoCard from "./VideoCard";
import { PlexPosterCard } from "./plexCards";
import { useNavigationActions } from "./NavigationProvider";
import { usePlexActions } from "./PlexProvider";
// The Home surface (E24) — "Mit nézzek most?".
//
// Layout C+, chosen from mockups: the time-budget chips sit ABOVE a single large hero, so the
// question comes first and the hero is its answer; the shelves follow, compact. The page's whole
// promise is that it ANSWERS rather than offers, which is why there is one hero and not a grid of
// three: a shortlist is still a comparison, and comparing is the thing the user cannot do on the
// evenings this page exists for.
//
// Everything domain-shaped is already built: the cards are the feed's `VideoCard` and Plex's
// `PlexPosterCard`, rendered from the native payload each unified card carries, and the rails are
// the platform `Carousel`. Nothing here is a third card component.
// Lazy, exactly as the feed does it: the YouTube IFrame player loads only once something is played.
const PlayerModal = lazy(() => import("./PlayerModal"));
const BUDGETS: readonly DiscoverBudget[] = ["short", "medium", "long", "any"];
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
// drift from it.
const YT_SLOT = 200;
// How many already-offered cards the reroll remembers. Matched by the server's own draw depth
// (`hero.SURPRISE_DRAW`), which has to be able to step past a full window.
const SEEN_WINDOW = 8;
function Rail({
title,
ariaLabel,
cards,
emptyLabel,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
}: {
title: string;
ariaLabel: string;
cards: DiscoverCard[];
emptyLabel: string;
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
onInfo: (card: DiscoverCard) => void;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onToggleWatched: (card: PlexCard) => void;
}) {
// An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when
// you have not pinned anything teaches nothing about how to fill it.
if (!cards.length) {
return (
<section className="mb-6">
<h2 className="text-sm font-semibold mb-2">{title}</h2>
<StateMessage className="!py-6">{emptyLabel}</StateMessage>
</section>
);
}
return (
<section className="mb-6">
<Carousel
title={<span className="text-sm font-semibold">{title}</span>}
ariaLabel={ariaLabel}
>
{cards.map((c) => (
<div
key={`${c.source}:${c.kind}:${c.id}`}
className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"}
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
>
{c.video ? (
<VideoCard
video={c.video}
view="cardsSmall"
width={YT_SLOT}
onState={onState}
onToggleSave={onToggleSave}
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
/>
) : c.plex ? (
<PlexPosterCard
card={c.plex}
onOpen={() => onPlay(c)}
onInfo={() => onInfo(c)}
onToggleWatched={onToggleWatched}
/>
) : (
<ShelfTile card={c} onPlay={onPlay} />
)}
</div>
))}
</Carousel>
</section>
);
}
// A pinned CHANNEL or PLAYLIST has no native card of its own — neither is a playable thing, so
// neither VideoCard nor PlexPosterCard fits. It advertises what is waiting behind it instead of a
// runtime it does not have.
function ShelfTile({ card, onPlay }: { card: DiscoverCard; onPlay: (card: DiscoverCard) => void }) {
const { t } = useTranslation();
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
const countLabel =
card.kind === "channel"
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
: t("home.shelf.itemCount", { count: count ?? 0 });
return (
<button
type="button"
onClick={() => onPlay(card)}
disabled={!card.next}
className="block w-full text-left rounded-xl overflow-hidden border border-border bg-surface disabled:opacity-60 enabled:hover:border-accent transition-colors"
>
<span className="block aspect-video bg-card overflow-hidden">
{card.thumb ? (
<img src={card.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
) : null}
</span>
<span className="block p-2">
<span className="block text-xs font-medium truncate">{card.title}</span>
<span className="block text-2xs text-muted">{countLabel}</span>
</span>
</button>
);
}
function Hero({
tier,
card,
onPlay,
onReroll,
rerolling,
}: {
tier: HeroTier;
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
onReroll: () => void;
rerolling: boolean;
}) {
const { t } = useTranslation();
const started = card.position_seconds > 0;
const left = card.remaining_seconds;
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
// you left off" over something never opened is exactly the quiet lie the house rules forbid.
const kicker =
tier === "resume"
? left != null
? t("home.hero.resumeLeft", { time: formatDuration(left) })
: t("home.hero.resume")
: tier === "comfort"
? t("home.hero.comfort")
: t("home.hero.surprise");
return (
<section className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]">
<button
type="button"
onClick={() => onPlay(card)}
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
}`}
aria-label={t("home.hero.play")}
>
{card.thumb ? <img src={card.thumb} alt="" className="w-full h-full object-cover" /> : null}
{card.duration_seconds != null && (
<span className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums">
{formatDuration(card.duration_seconds)}
</span>
)}
{started && card.duration_seconds ? (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
<span
className="block h-full bg-accent"
style={{
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
}}
/>
</span>
) : null}
</button>
<div className="flex min-w-0 flex-col">
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
<h2 className="mt-1 text-lg font-semibold leading-tight">{card.title}</h2>
{card.subtitle && <p className="mt-0.5 text-xs text-muted truncate">{card.subtitle}</p>}
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3">
<Button icon={<Play className="h-4 w-4" />} onClick={() => onPlay(card)}>
{started ? t("home.hero.continue") : t("home.hero.play")}
</Button>
<Button
variant="outline"
icon={<RotateCcw className="h-4 w-4" />}
loading={rerolling}
onClick={onReroll}
>
{t("home.hero.reroll")}
</Button>
</div>
</div>
</section>
);
}
export default function Home() {
const { t } = useTranslation();
const qc = useQueryClient();
const { setPage } = useNavigationActions();
const { setJumpTo } = usePlexActions();
const [budget, setBudget] = useState<DiscoverBudget>("any");
// 0 = "the hero the page loaded with". Any other value is a reroll, answered by its own query so
// pressing the button does not refetch the rails under the user's cursor.
const [seed, setSeed] = useState(0);
// The cards the reroll has already offered this session, newest last. A ROLLING window rather than
// the whole history: an unbounded list would eventually exclude everything and leave the button
// with nothing to give, whereas a window of 8 can never exhaust a library and only repeats after
// eight presses. Kept here (not in the URL or a pref) because it is a property of this sitting.
const [seen, setSeen] = useState<string[]>([]);
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null);
const q = useQuery({
queryKey: qk.discover(budget),
queryFn: () => api.discover(budget, 0),
// Keep the previous budget's answer on screen while the new one loads. Without this the page
// falls back to `isPending` and replaces itself — chips included — with a spinner, so the
// control you just clicked vanishes under your cursor. (Same reason the feed does it.)
placeholderData: keepPreviousData,
});
const pickQ = useQuery({
queryKey: qk.discoverPick(budget, seed, seen),
queryFn: () => api.discoverPick(budget, seed, seen),
enabled: seed !== 0,
staleTime: 0,
// Hold the previous hero while the next one is in flight. Without this the reroll's new query key
// has no data for a beat, `hero` falls to null, and the page renders "nothing to suggest yet" —
// an EMPTY state standing in for a loading one, which is the exact dishonesty the house rules
// forbid. Measured in the browser (2026-09-06); the button's own spinner is the loading signal.
placeholderData: keepPreviousData,
});
const data = q.data;
// Which hero is on screen. The middle branch is the load-bearing one: `keepPreviousData` holds the
// PREVIOUS reroll, but the FIRST reroll has no previous pick to hold, so without falling back to
// the loaded payload the page renders "nothing to suggest" over a perfectly good library for a
// beat. `pickQ.data !== undefined` (not `?? null`) is what keeps a genuinely empty answer honest:
// once the pick has RESOLVED, a null hero really means nothing is left and the empty state is the
// truth.
const hero =
seed === 0
? (data?.hero ?? null)
: pickQ.data !== undefined
? pickQ.data.hero
: (data?.hero ?? null);
// The hero is the top of the resume rail by construction, so leaving it in the rail below prints
// the same card twice, a hand's width apart. Matched on source+id rather than on the tier alone:
// a rerolled hero can be a surprise pick that happens to be nothing to do with this rail.
const heroKey = hero ? discoverCardKey(hero.card) : null;
const resumeRail = (data?.continue_watching ?? []).filter((c) => discoverCardKey(c) !== heroKey);
const onState = useCallback(
(id: string, status: VideoStatus) => {
// A full refetch, unlike the feed's stale-mark: these rails are two dozen rows, and marking
// something watched genuinely changes what the page should answer (it can retire the hero).
// The feed avoids this because it would refetch ten infinite-query pages per click.
void api.setState(id, status).then(() => qc.invalidateQueries({ queryKey: qk.discover() }));
},
[qc],
);
const onToggleSave = useCallback(
(id: string, saved: boolean) => {
void (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)).then(() => {
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
});
},
[qc],
);
// One click plays. A shelf tile resolves through its `next` (a show → the episode that would
// start, a channel → its newest unwatched video); everything else plays itself. Plex hands off to
// its own module, which owns the hls.js player and its whole provider tree — the alternative would
// be a second player mounted on the landing page.
const onPlay = useCallback(
(card: DiscoverCard, startAt?: number | null) => {
const target = card.next ?? card;
if (target.video) {
setPlayer({ video: target.video, startAt: startAt ?? null });
return;
}
if (target.plex) {
setJumpTo({ id: target.plex.id, type: target.plex.type === "show" ? "show" : "play" });
setPage("plex");
}
},
[setJumpTo, setPage],
);
// The ⓘ affordance on a Plex tile: the item's own page in the Plex module, exactly where the same
// click goes from the Plex grid. A show tile's card click lands there too — a show is not playable.
const onInfo = useCallback(
(card: DiscoverCard) => {
if (!card.plex) return;
setJumpTo({ id: card.plex.id, type: card.plex.type === "show" ? "show" : "movie" });
setPage("plex");
},
[setJumpTo, setPage],
);
// Watched-toggle on a Plex tile. No optimistic cache patch here (unlike the Plex Discover
// surface): marking something watched can retire the hero as well as the tile, so this page
// re-asks the server rather than trying to predict a cross-rail consequence.
const onToggleWatched = useCallback(
(card: PlexCard) =>
void toggleWatchState(card, t, {
optimistic: () => {},
resync: () => qc.invalidateQueries({ queryKey: qk.discover() }),
}),
[qc, t],
);
const reroll = useCallback(() => {
// Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
// never 0 (which means "the hero the page loaded with") and never the current one (a cache hit
// would look like a dead button).
if (heroKey)
setSeen((prev) => [...prev.filter((k) => k !== heroKey), heroKey].slice(-SEEN_WINDOW));
setSeed((prev) => {
let next = prev;
while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1;
return next;
});
}, [heroKey]);
if (q.isPending) return <LoadingState />;
if (q.isError)
return (
<StateMessage tone="error" onRetry={() => void q.refetch()}>
{t("home.error")}
</StateMessage>
);
return (
<div className="p-4">
{/* The caption sits NEXT TO the chips, not opposite them: SegmentedToggle's own `label` prop
renders a justify-between row, which is right for a narrow filter rail and wrong here — on
an 80rem page it throws the answers to the far edge, away from the question they answer. */}
<div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="text-sm text-muted">{t("home.budget.ask")}</span>
<SegmentedToggle
size="md"
ariaLabel={t("home.budget.ask")}
value={budget}
onChange={(v) => {
setBudget(v);
setSeed(0); // the payload for the new budget carries its own hero
setSeen([]); // a new question deserves the whole library again
}}
options={BUDGETS.map((b) => ({ value: b, label: t(`home.budget.${b}`) }))}
/>
</div>
{hero ? (
<Hero
tier={hero.tier}
card={hero.card}
onPlay={onPlay}
onReroll={reroll}
rerolling={pickQ.isFetching}
/>
) : (
// Honest: nothing in the library fits what was asked. Offering something that does not fit
// would make the one control on this page meaningless.
<section className="mb-6 rounded-xl border border-border bg-surface p-4">
<StateMessage className="!py-6" onRetry={budget === "any" ? undefined : reroll}>
{budget === "any" ? t("home.hero.emptyAny") : t("home.hero.emptyBudget")}
</StateMessage>
</section>
)}
<Rail
title={t("home.comfort.title")}
ariaLabel={t("home.comfort.title")}
cards={data?.comfort ?? []}
emptyLabel={t("home.comfort.empty")}
onPlay={onPlay}
onInfo={onInfo}
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
/>
<Rail
title={t("home.continue.title")}
ariaLabel={t("home.continue.title")}
cards={resumeRail}
emptyLabel={t("home.continue.empty")}
onPlay={onPlay}
onInfo={onInfo}
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
/>
{player && (
<Suspense fallback={null}>
<PlayerModal
video={player.video}
startAt={player.startAt}
onClose={() => {
setPlayer(null);
// The player saves progress on close, so the rails and the hero are stale by
// definition once it shuts.
void qc.invalidateQueries({ queryKey: qk.discover() });
}}
onState={onState}
/>
</Suspense>
)}
</div>
);
}
+5 -1
View File
@@ -134,7 +134,11 @@ export default function PlexBrowse() {
if (jumpTo != null) {
if (jumpTo.type !== "show") infoScrollRef.current = 0; // a jumped-to info page starts at the top
sub.open(
jumpTo.type === "show" ? { kind: "show", id: jumpTo.id } : { kind: "info", id: jumpTo.id },
jumpTo.type === "show"
? { kind: "show", id: jumpTo.id }
: jumpTo.type === "play"
? { kind: "player", id: jumpTo.id }
: { kind: "info", id: jumpTo.id },
);
setJumpTo(null);
}
+6 -3
View File
@@ -28,9 +28,12 @@ import { useNavigation } from "./NavigationProvider";
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
// A "jump straight to this title" hand-off from the header typeahead: PlexBrowse opens a movie's
// info page (like the card's ⓘ) or a show's drill-down (like clicking a show card) by type.
export type PlexJumpTarget = { id: string; type: "movie" | "show" };
// A "jump straight to this title" hand-off: PlexBrowse opens a movie's info page (like the card's ⓘ),
// a show's drill-down (like clicking a show card), or — for E24's Home hero, whose whole promise is a
// single Play button — the PLAYER for a movie/episode rating_key. `play` exists because the player
// lives inside this module with its lazy hls.js and its provider tree; mounting a second one on the
// landing page to avoid one hand-off would be the expensive way round.
export type PlexJumpTarget = { id: string; type: "movie" | "show" | "play" };
type PlexState = {
scope: string; // movie | show | both (unified cross-library scope)
@@ -22,6 +22,7 @@ import { useSavedViews } from "./SavedViewsProvider";
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>.
const Home = lazy(() => import("./Home"));
const Feed = lazy(() => import("./Feed"));
const Channels = lazy(() => import("./Channels"));
const Playlists = lazy(() => import("./Playlists"));
@@ -92,6 +93,7 @@ export type PageDef = {
// page id → the component App renders in the content column (+ its shell chrome config).
export const PAGE_CONTENT: Record<Page, PageDef> = {
home: { Component: Home, maxWidth: "80rem" },
feed: { Component: FeedPage },
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
+15 -2
View File
@@ -58,15 +58,28 @@ describe("loadInitialPage precedence", () => {
setUrl("?v=show:1&pl=3&tags=1");
expect(loadInitialPage()).toBe("plex");
});
it("a bare URL uses the account's stored landing page", () => {
// E24 changed this rung: a bare URL lands on Home, OVER the remembered module. Landing on
// whatever you happened to close the app on is precisely the habit the anti-paralysis surface
// exists to replace, so the remembered page only wins once the account opts out.
it("a bare URL lands on Home even when another module is remembered", () => {
stored[LS.page] = "downloads";
expect(loadInitialPage()).toBe("home");
});
it("opting out restores the account's stored landing page", () => {
stored[LS.startOnHome] = "0";
stored[LS.page] = "downloads";
expect(loadInitialPage()).toBe("downloads");
});
it("a bare URL with no / invalid stored page defaults to feed", () => {
it("opted out with no / invalid stored page still defaults to feed", () => {
stored[LS.startOnHome] = "0";
expect(loadInitialPage()).toBe("feed");
stored[LS.page] = "nope";
expect(loadInitialPage()).toBe("feed");
});
it("a deep link still beats Home", () => {
setUrl("?pl=3");
expect(loadInitialPage()).toBe("playlists");
});
});
describe("channelViewFrom", () => {
+8 -1
View File
@@ -6,6 +6,9 @@ import { hasFilterParams, isPage, readPage, type Page } from "../lib/urlState";
import { getAccountRaw, LS } from "../lib/storage";
export const PAGE_KEY = LS.page;
// Module-local: the Settings opt-out (S4) writes `LS.startOnHome` directly, so exporting this
// alias would only be a second name for the same key.
const START_HOME_KEY = LS.startOnHome;
export interface ChannelView {
id: string;
@@ -20,7 +23,11 @@ export function loadInitialPage(): Page {
if (params.has("v")) return "plex";
if (params.has("pl")) return "playlists";
if (hasFilterParams(params) || params.has("chan")) return "feed";
// A bare URL → the account's stored landing page.
// A bare URL → Home (E24), the surface whose whole job is to answer "what should I watch" before
// the user has to decide anything. It deliberately OVERRIDES the remembered last module: landing
// on whatever you happened to close the app on is the behaviour the anti-paralysis epic exists to
// replace. The account can opt out (Settings) and get the remembered module back.
if (getAccountRaw(START_HOME_KEY) !== "0") return "home";
const stored = getAccountRaw(PAGE_KEY);
return isPage(stored) ? stored : "feed";
}
+37
View File
@@ -0,0 +1,37 @@
{
"navLabel": "Home",
"title": "What should I watch?",
"error": "Could not load your suggestions.",
"budget": {
"ask": "How much time do you have?",
"short": "10 minutes",
"medium": "30 minutes",
"long": "An evening",
"any": "Doesn't matter"
},
"hero": {
"resume": "Where you left off",
"resumeLeft": "Where you left off · {{time}} to go",
"comfort": "From your shelf",
"surprise": "Surprise",
"play": "Play",
"continue": "Continue",
"reroll": "Something else",
"emptyAny": "Nothing to suggest yet. Subscribe to a channel, or pin something to your shelf.",
"emptyBudget": "Nothing in your library fits that much time."
},
"comfort": {
"title": "Comfort shelf",
"empty": "Nothing pinned yet. Pin a channel, a show or a playlist you always come back to."
},
"continue": {
"title": "Continue watching",
"empty": "Nothing half-finished right now."
},
"shelf": {
"unwatchedCount_one": "{{count}} unwatched",
"unwatchedCount_other": "{{count}} unwatched",
"itemCount_one": "{{count}} video",
"itemCount_other": "{{count}} videos"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"navLabel": "Kezdőlap",
"title": "Mit nézzek most?",
"error": "Nem sikerült betölteni az ajánlatokat.",
"budget": {
"ask": "Mennyi időd van?",
"short": "10 perc",
"medium": "30 perc",
"long": "Egy este",
"any": "Mindegy"
},
"hero": {
"resume": "Ott hagytad abba",
"resumeLeft": "Ott hagytad abba · {{time}} van hátra",
"comfort": "A polcodról",
"surprise": "Meglepetés",
"play": "Lejátszás",
"continue": "Folytatás",
"reroll": "Mást kérek",
"emptyAny": "Még nincs mit ajánlani. Iratkozz fel egy csatornára, vagy tűzz valamit a polcodra.",
"emptyBudget": "Ennyi időbe semmi sem fér bele a könyvtáradból."
},
"comfort": {
"title": "Komfort polc",
"empty": "Még nincs ide tűzve semmi. Tűzz ki egy csatornát, sorozatot vagy listát, amihez mindig visszatérsz."
},
"continue": {
"title": "Folytatás",
"empty": "Most nincs félbehagyott dolgod."
},
"shelf": {
"unwatchedCount_one": "{{count}} megnézetlen",
"unwatchedCount_other": "{{count}} megnézetlen",
"itemCount_one": "{{count}} videó",
"itemCount_other": "{{count}} videó"
}
}
+4
View File
@@ -32,6 +32,9 @@ import { setupApi } from "./api/setup";
export type * from "./api/setup";
import { feedApi } from "./api/feed";
export type * from "./api/feed";
import { discoverApi } from "./api/discover";
export type * from "./api/discover";
export { DISCOVER_BUDGETS, PIN_KINDS, discoverCardKey } from "./api/discover";
export type * from "./api/tagModes";
export {
DEFAULT_TAG_MODES,
@@ -77,6 +80,7 @@ export const api = {
...authApi,
...setupApi,
...feedApi,
...discoverApi,
...channelsApi,
...syncApi,
...playlistsApi,
+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")],
+6 -1
View File
@@ -12,6 +12,7 @@ import {
SlidersHorizontal,
Tv,
Users,
Youtube,
type LucideIcon,
} from "lucide-react";
import type { Me } from "./api";
@@ -46,7 +47,11 @@ const notDemo = (me: Me) => !me.is_demo;
// Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
const MODULES: readonly ModuleDef[] = [
{ id: "feed", labelKey: "header.account.feed", icon: Home },
// Home leads: it is the landing (see navigationInit.loadInitialPage), and the module order IS the
// nav order and the header stepper's sequence. The feed takes the YouTube mark its own toolbar
// already uses, so the two are apart by shape and not just by position.
{ id: "home", labelKey: "home.navLabel", titleKey: "home.title", icon: Home },
{ id: "feed", labelKey: "header.account.feed", icon: Youtube },
{
id: "channels",
labelKey: "header.account.channels",
+1
View File
@@ -6,6 +6,7 @@ import type { Page } from "./urlState";
// browser-tab title keys the old hand-written switch produced — pin every page so a future labelKey
// rename (or a dropped titleKey) can't silently change a tab title.
const EXPECTED: Record<Page, string> = {
home: "home.title",
feed: "header.account.feed",
channels: "header.channelManager",
playlists: "header.account.playlists",
+6
View File
@@ -100,6 +100,12 @@ export const qk = {
// array, a "union"/"group"/"contains" discriminator), so they take a variadic pass-through:
// the value this factory centralizes is the ROOT string, and TanStack keys are `unknown[]`
// anyway. Single-id plex keys stay precisely typed.
// The Home surface (E24). `discover()` with no args is the prefix every mutation invalidates —
// pinning something changes the shelf AND can change the hero, so the two are never invalidated
// separately. The reroll has its own key so pressing "Mást kérek" does not refetch the rails.
discover: (...rest: unknown[]) => ["discover", ...rest],
discoverPick: (...rest: unknown[]) => ["discover-pick", ...rest],
comfortPins: () => ["comfort-pins"] as const,
plex: () => ["plex"] as const,
plexWatchLink: () => ["plex-watch-link"] as const,
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
+5
View File
@@ -15,6 +15,11 @@ export const LS = {
lang: "siftlode.lang",
filters: "siftlode.filters",
page: "siftlode.page",
// E24: start every session on the Home surface rather than on the module you happened to leave
// open. Per-account, "0" = opt out and go back to remembering the last module. It has to be
// localStorage rather than a server pref because `loadInitialPage` runs before /api/me lands —
// a landing page decided after the first paint would be a visible jump.
startOnHome: "siftlode.startOnHome",
perfMode: "siftlode.perfMode",
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
// established tab paints your view straight away. A brand-new tab still starts on the default
+1
View File
@@ -119,6 +119,7 @@ export function hasFilterParams(params: URLSearchParams): boolean {
// derive from it, so adding a page is a one-line change (no parallel allowlists to keep in
// sync). "feed" is the default/fallback.
const PAGES = [
"home",
"feed",
"channels",
"stats",