diff --git a/backend/app/discover/hero.py b/backend/app/discover/hero.py index df7a3320..e75a2e88 100644 --- a/backend/app/discover/hero.py +++ b/backend/app/discover/hero.py @@ -81,7 +81,11 @@ def pick( return None def _comfort() -> dict | None: - for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf): + if shelf is None: + own = pins.shelf(db, user, include_plex=rails.plex_available(db)) + else: + own = shelf + for card in pins.playables(own): if cards.key(card) not in skip and fits(card["remaining_seconds"], budget): return card return None diff --git a/backend/app/discover/pins.py b/backend/app/discover/pins.py index 98a6a0f9..c3c88a35 100644 --- a/backend/app/discover/pins.py +++ b/backend/app/discover/pins.py @@ -75,6 +75,31 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje return (col, pl.id) if pl is not None and pl.user_id == user.id else None +def reorder(db: Session, user: User, ids: list[int]) -> None: + """Put the listed pins in the listed order, WITHIN the slots they already hold. + + The client sends the order it can SEE, which is not always the whole shelf: with Plex off the Home + rail hides the Plex pins, and a stale tab may not know a pin added elsewhere. Numbering only the + listed ids 0..n-1 collided with every pin left out — two tiles on one position, and the hidden + ones jumped about when they came back. So the pins left out keep their slots, and the listed ones + are dealt into the slots the listed set occupies. Ids that are not this user's, or repeated, are + ignored rather than failing a stale replay wholesale. Flushes; the route commits.""" + current = list_pins(db, user) + owned = {p.id for p in current} + listed: list[int] = [] + for i in ids: + if i in owned and i not in listed: + listed.append(i) + chosen = set(listed) + queue = iter(listed) + order = [next(queue) if p.id in chosen else p.id for p in current] + by_id = {p.id: p for p in current} + for position, pin_id in enumerate(order): + if by_id[pin_id].position != position: + by_id[pin_id].position = position + db.flush() + + def list_pins(db: Session, user: User) -> list[ComfortPin]: return list( db.execute( @@ -188,10 +213,21 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict return card -def shelf(db: Session, user: User) -> list[dict]: - """The whole shelf, in the user's own order.""" +_PLEX_COLUMNS = ("plex_item_id", "plex_show_id") + + +def shelf(db: Session, user: User, *, include_plex: bool = True) -> list[dict]: + """The whole shelf, in the user's own order. + + `include_plex=False` is the Home surface with the deployment's Plex toggle off (the owner's call, + 2026-09-19): the rails beside it go YouTube-only, and a Plex tile there would offer a Play that + lands on a gated module. The pins themselves are kept — the pin menus still read the full shelf + (`GET /pins`), and switching Plex back on brings the tiles back in their old slots + (`reorder` keeps a hidden pin's place).""" out = [] pins = list_pins(db, user) + if not include_plex: + pins = [p for p in pins if all(getattr(p, c) is None for c in _PLEX_COLUMNS)] batch = _Batch.load(db, user, pins) for pin in pins: card = _shelf_card(db, user, pin, batch) @@ -202,5 +238,17 @@ def shelf(db: Session, user: User) -> list[dict]: def playables(shelf_cards: list[dict]) -> list[dict]: """The `next` of every shelf entry that has one — what the hero may draw from. A pinned channel - with nothing unwatched left, or an empty playlist, simply is not an answer right now.""" - return [c["next"] for c in shelf_cards if c.get("next")] + with nothing unwatched left, or an empty playlist, simply is not an answer right now. + + Each carries `pinned_from`: the pin it was resolved from. A show's next EPISODE is not itself + pinned, so without it the hero's menu looked the episode up, missed, and offered "Pin" under a + kicker that says "From your shelf" — and taking it inserted a second pin. With it, the hero's + pin row acts on the shelf entry the card came from (the owner's call, 2026-09-19: "Unpin").""" + return [ + { + **c["next"], + "pinned_from": {k: c[k] for k in ("source", "kind", "id", "title")}, + } + for c in shelf_cards + if c.get("next") + ] diff --git a/backend/app/routes/discover.py b/backend/app/routes/discover.py index 1ac1bea1..a1f7ae23 100644 --- a/backend/app/routes/discover.py +++ b/backend/app/routes/discover.py @@ -13,7 +13,7 @@ from typing import Literal from fastapi import APIRouter, Depends from pydantic import BaseModel -from sqlalchemy import select, update +from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -53,7 +53,9 @@ def get_discover( `resume` and `shelf` are built once and handed to the hero, so the two rails the hero draws from are not queried twice for a single page load.""" resume = rails.continue_watching(db, user) - shelf = pins.shelf(db, user) + # Plex off → the shelf rail (and so the hero's comfort tier) goes YouTube-only like the rails + # beside it; the pins are kept and `GET /pins` still lists them (`pins.shelf`). + shelf = pins.shelf(db, user, include_plex=rails.plex_available(db)) return { "budget": budget, "hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf), @@ -180,18 +182,11 @@ def reorder_pins( user: User = Depends(require_human), db: Session = Depends(get_db), ) -> dict: - """Rewrite the shelf order from a full list of pin ids. + """Rewrite the shelf order from the list of pin ids the client shows. Whole-list rather than a move-one delta: the shelf is short, and a full rewrite cannot leave two - tiles claiming the same slot the way an interrupted sequence of single moves can. Ids that are not - this user's are ignored rather than 404'd — a stale tab replaying an order that includes a pin - deleted meanwhile should reorder what still exists, not fail wholesale.""" - owned = {p.id for p in pins.list_pins(db, user)} - for position, pin_id in enumerate(i for i in body.ids if i in owned): - db.execute( - update(ComfortPin) - .where(ComfortPin.id == pin_id, ComfortPin.user_id == user.id) - .values(position=position) - ) + tiles claiming the same slot the way an interrupted sequence of single moves can. The list may be + PARTIAL (hidden Plex pins, a stale tab) — `pins.reorder` keeps what it leaves out in place.""" + pins.reorder(db, user, body.ids) db.commit() return {"ok": True} diff --git a/backend/tests/test_discover.py b/backend/tests/test_discover.py index a25beb5c..c8c3bc47 100644 --- a/backend/tests/test_discover.py +++ b/backend/tests/test_discover.py @@ -1007,3 +1007,65 @@ def test_deleting_a_pin_reports_what_went(db, as_user): r = c.delete(f"/api/discover/pins/{pin_id}") assert r.status_code == 200 and r.json() == {"deleted": pin_id} assert c.get("/api/discover/pins").json()["items"] == [] + + +def test_with_plex_off_the_home_shelf_hides_plex_pins_but_keeps_them(db, as_user): + """The owner's call (2026-09-19): with the deployment's Plex toggle off, the Home shelf goes + YouTube-only like the rails beside it — a Plex tile there offered a Play that landed on a gated + module. The hero's comfort tier draws from the same filtered shelf, and the pin menus' own + listing still sees every pin, so nothing the user curated is lost.""" + user, _ = _seed_yt(db) + _seed_plex(db, user) + db.add_all( + [ + ComfortPin( + user_id=user.id, + plex_item_id=db.scalar(select(PlexItem.id).where(PlexItem.rating_key == "pm1")), + position=0, + ), + ComfortPin(user_id=user.id, video_id="vlong", position=1), + ] + ) + db.commit() + c = as_user(user) + assert [t["id"] for t in c.get("/api/discover").json()["comfort"]] == ["vlong"] + assert c.get("/api/discover/pick?start=1").json()["hero"]["card"]["id"] == "vlong" + assert {t["id"] for t in c.get("/api/discover/pins").json()["items"]} == {"pm1", "vlong"} + _enable_plex(db) + assert [t["id"] for t in c.get("/api/discover").json()["comfort"]] == ["pm1", "vlong"] + + +def test_a_reorder_of_the_visible_pins_keeps_the_hidden_ones_in_place(db, as_user): + """The client sends the order it SHOWS. With Plex off that leaves the Plex pins out, and + numbering only the listed ids 0..n-1 put two pins on one position and moved the hidden one about + when Plex came back. The listed pins trade the slots they hold; the rest stay put.""" + user, ch = _seed_yt(db) + _seed_plex(db, user) + c = as_user(user) + _enable_plex(db) + a = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"] + hidden = c.post("/api/discover/pins", json={"kind": "plex_item", "ref": "pm1"}).json()["id"] + b = c.post("/api/discover/pins", json={"kind": "channel", "ref": ch.id}).json()["id"] + assert c.post("/api/discover/pins/reorder", json={"ids": [b, b, a]}).status_code == 200 + assert [p["pin_id"] for p in c.get("/api/discover/pins").json()["items"]] == [b, hidden, a] + positions = db.scalars(select(ComfortPin.position).where(ComfortPin.user_id == user.id)).all() + assert sorted(positions) == [0, 1, 2] + + +def test_a_hero_from_a_show_pin_names_the_pin_it_came_from(db, as_user): + """A show's next EPISODE is not itself pinned, so the hero's menu used to look the episode up, + miss, and offer "Pin" under "From your shelf" — creating a second pin. The card now carries + the pin it was resolved from, which the menu acts on (owner's call: "Unpin").""" + user, _ = _seed_yt(db) + _seed_plex(db, user) + _enable_plex(db) + c = as_user(user) + assert c.post("/api/discover/pins", json={"kind": "plex_show", "ref": "ps1"}).status_code == 200 + got = c.get("/api/discover/pick?start=1").json()["hero"] + assert got["tier"] == "comfort" and got["card"]["id"] == "ps1e1" + assert got["card"]["pinned_from"] == { + "source": "plex", + "kind": "show", + "id": "ps1", + "title": "A Show", + } diff --git a/frontend/src/components/AppCommandPalette.tsx b/frontend/src/components/AppCommandPalette.tsx index 132aa69b..2951848e 100644 --- a/frontend/src/components/AppCommandPalette.tsx +++ b/frontend/src/components/AppCommandPalette.tsx @@ -135,10 +135,11 @@ export default function AppCommandPalette() { label: t("palette.actions.surprise"), icon: , keywords: t("home.title"), - run: () => { - setPage("home"); - requestSurprise(); - }, + // The token is minted on the entry the push made (see lib/homeIntent), and asks for the + // SURPRISE tier: the command is labelled "Meglepetés", and the reroll's plain next press + // is answered by whichever tier the rotation reaches — for anyone with a shelf, "A + // polcodról" under a command that promised a surprise (owner's call, 2026-09-19). + run: () => setPage("home", requestSurprise), }, { id: "action:shortcuts", diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx index c41abdb4..3778bcdd 100644 --- a/frontend/src/components/Home.dom.test.tsx +++ b/frontend/src/components/Home.dom.test.tsx @@ -52,6 +52,7 @@ vi.mock("../lib/api", () => ({ downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it }, DISCOVER_BUDGETS: ["short", "medium", "long", "any"], + HERO_TIERS: ["resume", "comfort", "surprise"], discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`, })); @@ -609,8 +610,38 @@ describe("Home", () => { ); expect(await screen.findByText("Rerolled")).toBeTruthy(); expect(discoverPick).toHaveBeenCalledTimes(1); - // …and on the press count the server reads as "which tier answers first". - expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 1); + // …and on the press count the server reads as "which tier answers first": 2 leads with the + // SURPRISE tier, which is what a command labelled "Meglepetés" promised. A plain next press (1) + // led with the shelf, so anyone with one got "A polcodról" instead (owner's call, 2026-09-19). + expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 2); + }); + + // The rotation carries on from the requested tier: the page's own button after a palette surprise + // is the NEXT tier (resume), not a second surprise and not a jump back to press 1. + it("lets the button's next press carry the rotation on from the requested tier", async () => { + discoverPick.mockResolvedValue({ + budget: "any", + hero: { tier: "surprise", card: ytCard("v9", "Rerolled") }, + }); + requestSurprise(); + mount(); + expect(await screen.findByText("Rerolled")).toBeTruthy(); + fireEvent.click(screen.getByText("home.hero.reroll")); + await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2)); + expect(discoverPick.mock.lastCall?.[3]).toBe(3); + }); + + // A token minted for a navigation that never mounted Home (a failed lazy chunk, a Back before + // the module arrived) must not replay on the NEXT visit: it names the history entry it was + // requested for, and Home mounting on any other entry drops it. + it("drops a surprise request that was made for another history entry", async () => { + requestSurprise(); + window.history.pushState(null, ""); + mount(); + await screen.findByText("Hero v1"); + expect(discoverPick).not.toHaveBeenCalled(); + // …and it is spent, not merely skipped: the entry it named does not revive it either. + expect(takeSurprise()).toBe(false); }); it("says the shelf is empty rather than hiding it", async () => { diff --git a/frontend/src/components/Home.pin.dom.test.tsx b/frontend/src/components/Home.pin.dom.test.tsx index 3fe11a54..857e86b4 100644 --- a/frontend/src/components/Home.pin.dom.test.tsx +++ b/frontend/src/components/Home.pin.dom.test.tsx @@ -61,6 +61,7 @@ vi.mock("../lib/api", () => ({ }, HttpError: class extends Error {}, DISCOVER_BUDGETS: ["short", "medium", "long", "any"], + HERO_TIERS: ["resume", "comfort", "surprise"], discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`, })); @@ -302,6 +303,34 @@ describe("Home — the comfort shelf's context menu", () => { await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(11)); }); + // A comfort hero resolved from a pinned SHOW is that show's next episode, which is not itself + // pinned: the menu looked the episode up, missed, and offered "Pin" under "From your shelf" — + // taking it created a second pin. The card now names the pin it came from, and the row acts on + // THAT (owner's call, 2026-09-19: "Unpin"). + it("unpins the SHOW a comfort hero's episode was resolved from", async () => { + const show = { + ...base("ps1", "Pinned show"), + source: "plex", + kind: "show", + video: null, + pin_id: 21, + }; + const episode = { + ...base("ps1e2", "Next episode"), + source: "plex", + kind: "episode", + video: null, + pinned_from: { source: "plex", kind: "show", id: "ps1", title: "Pinned show" }, + }; + discover.mockResolvedValue({ ...payload(), hero: { tier: "comfort", card: episode } }); + comfortPins.mockResolvedValue({ items: [...shelf, show] }); + mount(); + fireEvent.contextMenu(await screen.findByText("Next episode")); + fireEvent.click(await waitFor(() => item("home.pin.remove"))); + await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(21)); + expect(addComfortPin).not.toHaveBeenCalled(); + }); + it("says so rather than guessing while the shelf is still loading", async () => { // "Not pinned" and "not known yet" must not render as the same row: the pin label is only // trustworthy once the shelf has answered. diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 370a266e..d9d63fdc 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -18,6 +18,7 @@ import { type DiscoverBudget, type DiscoverCard, type DiscoverPayload, + HERO_TIERS, type HeroTier, type PlexCard, type Video, @@ -350,7 +351,14 @@ function Hero({ - c.title} /> + {/* A comfort hero acts on the pin it came from: a pinned show's next episode is not itself + pinned, and looking the episode up offered "Pin" under "From your shelf" — a second pin. + The owner's call (2026-09-19): the row unpins the show. */} + pinTargetForCard(c.pinned_from ?? c)} + title={(c) => (c.pinned_from ?? c).title} + /> ); } @@ -654,28 +662,40 @@ export default function Home() { [budget, 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)); - setPress((n) => n + 1); - setSeed((prev) => { - let next = prev; - while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1; - return next; - }); - }, [heroKey]); + const reroll = useCallback( + (lead?: HeroTier) => { + // 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)); + // `lead` asks for a tier: the next press whose rotation leads with it. The rotation carries on + // from there, so the button's following press is the tier after it, as it would have been. + setPress((n) => { + let next = n + 1; + // Guarded on membership: a click handler passed straight in would hand over its event + // as `lead`, and this loop would never end. + if (lead && HERO_TIERS.includes(lead)) + while (HERO_TIERS[next % HERO_TIERS.length] !== lead) next += 1; + return next; + }); + setSeed((prev) => { + let next = prev; + while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1; + return next; + }); + }, + [heroKey], + ); // "Meglepetés" from the command palette: the request is made on another page, so it waits as a // token until this module's chunk has actually mounted (see lib/homeIntent). The subscribed value // is what WAKES this effect; `takeSurprise()` is what decides — it clears the token and answers // false to every later pass, including StrictMode's second mount invocation and the re-runs - // `reroll`'s changing identity causes. + // `reroll`'s changing identity causes — and to a token minted for another history entry. const surpriseRequest = useSurpriseRequest(); useEffect(() => { - if (surpriseRequest && takeSurprise()) reroll(); + if (surpriseRequest !== null && takeSurprise()) reroll("surprise"); }, [surpriseRequest, reroll]); if (q.isPending) return ; @@ -716,7 +736,7 @@ export default function Home() { tier={hero.tier} card={hero.card} onPlay={onPlay} - onReroll={reroll} + onReroll={() => reroll()} rerolling={rerolling} /> ) : ( @@ -743,7 +763,7 @@ export default function Home() { type="button" icon={} loading={rerolling} - onClick={reroll} + onClick={() => reroll()} > {t("home.hero.reroll")} diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx index fe29cf5d..dc33daad 100644 --- a/frontend/src/components/NavigationProvider.dom.test.tsx +++ b/frontend/src/components/NavigationProvider.dom.test.tsx @@ -156,6 +156,23 @@ describe("NavigationProvider · never under an open overlay", () => { expect(window.history.state).toMatchObject({ sfPage: "plex" }); }); + // `then` belongs to the NEW entry (the palette's "Meglepetés" token names it — lib/homeIntent), so + // over an open player it must wait for the push, and with nothing to push it still runs. + it("runs setPage's `then` after the push, on the entry the push made", async () => { + const { result } = render(); + await openOverlay(); + const seen: unknown[] = []; + expect(result.current.page).not.toBe("plex"); + act(() => result.current.setPage("plex", () => seen.push(window.history.state?.sfPage))); + expect(seen).toEqual([]); + await settle(); + await settle(); + expect(seen).toEqual(["plex"]); + + act(() => result.current.setPage("plex", () => seen.push("again"))); + expect(seen).toEqual(["plex", "again"]); + }); + // Shift+W on Home with the player open (owner decision 2026-09-09): nothing to push, but the player // closes, and its entry is reclaimed rather than orphaned under a push that never happened. it("setPage to the current page closes the overlay and pushes nothing", async () => { diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index b196bcce..7a37a724 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -57,13 +57,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) { channelViewRef.current = channelView; const setPage = useCallback( - (next: Page) => + (next: Page, then?: () => void) => // Never under an open overlay — see the interface doc. The refs are read when the push // actually happens, which over an open player is a moment after this call. closeOverlaysThenNavigate(() => { // A channel page overlays the content column, so a nav click must close it even when the // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed). - if (next === pageRef.current && !channelViewRef.current) return; + if (next === pageRef.current && !channelViewRef.current) { + then?.(); + return; + } setChannelView(null); // leave any open channel page when navigating via the rail setPageState(next); setAccountRaw(PAGE_KEY, next); @@ -95,6 +98,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { }), ); notifyNav(); + then?.(); }), [], ); diff --git a/frontend/src/components/comfortPin.tsx b/frontend/src/components/comfortPin.tsx index 888b22b1..d7ff1bc7 100644 --- a/frontend/src/components/comfortPin.tsx +++ b/frontend/src/components/comfortPin.tsx @@ -49,7 +49,9 @@ export const pinPlex = (card: { id: string; type: string }): PinTarget => ({ /** A unified discover card back to the thing it was pinned as — the inverse of the mapping in * `app/discover/cards.py`. Null for a kind that cannot be pinned. */ -export function pinTargetForCard(card: DiscoverCard): PinTarget | null { +export function pinTargetForCard( + card: Pick, +): PinTarget | null { if (card.source === "plex") return pinPlex({ id: card.id, type: card.kind }); if (card.kind === "video") return pinVideo(card.id); if (card.kind === "channel") return pinChannel(card.id); diff --git a/frontend/src/components/navigationContext.ts b/frontend/src/components/navigationContext.ts index b557cefc..5c3b1684 100644 --- a/frontend/src/components/navigationContext.ts +++ b/frontend/src/components/navigationContext.ts @@ -23,8 +23,12 @@ export interface NavigationActions { /** Switch modules. Closes every open overlay first and pushes only once their history entries are * back (`lib/history.closeOverlaysThenNavigate`), so over an open player the push lands a moment * after the call; with nothing open it is synchronous. The requested page being already current - * (with no channel page over it) pushes nothing — but the overlays still close. */ - setPage: (next: Page) => void; + * (with no channel page over it) pushes nothing — but the overlays still close. + * + * `then` runs once the navigation has happened (the push made, or found unnecessary) — for a + * request that belongs to the NEW entry, which over an open player does not exist yet at the + * call (`lib/homeIntent`). */ + setPage: (next: Page, then?: () => void) => void; openChannel: (id: string, name?: string) => void; closeChannel: () => void; enterYtSearch: (q: string) => void; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 44756f22..4f45b1df 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -28,7 +28,7 @@ import { feedApi } from "./api/feed"; export type * from "./api/feed"; import { discoverApi } from "./api/discover"; export type * from "./api/discover"; -export { DISCOVER_BUDGETS, discoverCardKey } from "./api/discover"; +export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover"; export type * from "./api/tagModes"; export { DEFAULT_TAG_MODES, diff --git a/frontend/src/lib/api/discover.ts b/frontend/src/lib/api/discover.ts index d3052278..e2dfab03 100644 --- a/frontend/src/lib/api/discover.ts +++ b/frontend/src/lib/api/discover.ts @@ -15,6 +15,9 @@ 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"; +/** The server's tier order (`app/discover/hero.py::HERO_TIERS`): a reroll's press count `start` + * leads with `HERO_TIERS[start % 3]`, which is how a press can ASK for a tier. */ +export const HERO_TIERS: readonly HeroTier[] = ["resume", "comfort", "surprise"]; export type DiscoverKind = "video" | "movie" | "show" | "episode" | "channel" | "playlist"; @@ -43,6 +46,10 @@ export interface DiscoverCard { plex: PlexCard | null; // Shelf entries only. pin_id?: number; + /** A comfort-tier hero only: the shelf entry it was resolved from — for a pinned SHOW, the show + * whose next episode this is. The hero's pin row acts on it rather than on the episode, which is + * not itself pinned (`app/discover/pins.py::playables`). */ + pinned_from?: Pick; /** 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). */ diff --git a/frontend/src/lib/homeIntent.ts b/frontend/src/lib/homeIntent.ts index f58d173f..4b8e8c44 100644 --- a/frontend/src/lib/homeIntent.ts +++ b/frontend/src/lib/homeIntent.ts @@ -1,3 +1,4 @@ +import { ensureNavId, peekNavId } from "./history"; import { createStore } from "./store"; // A one-shot "…and surprise me straight away" request for the Home surface (E24), handed over by @@ -5,18 +6,23 @@ import { createStore } from "./store"; // // A TOKEN that waits, not an event that fires: Home is a lazy module and is normally NOT mounted // when the request is made (the palette navigates to it in the same click), so an event would be -// shouted into an empty room. Home takes the token on arrival, which clears it, so walking back to -// Home an hour later does not replay the press — a stale reroll would look like the page deciding -// on its own that the answer you came back for was wrong. -const surprise = createStore(0); +// shouted into an empty room. Home takes the token on arrival, which clears it. +// +// The token is the HISTORY ENTRY it was requested for, and Home honours it only on that entry. A +// plain counter outlived a navigation that never mounted Home — a lazy chunk that failed, a Back +// before the module arrived — and replayed on the next visit, an hour later: the page deciding on +// its own that the answer you came back for was wrong. So it is minted AFTER the push +// (`setPage`'s `then`), on the entry the push made; Home mounting on any other entry drops it. +const surprise = createStore(null); -/** Ask Home to reroll its hero as soon as it is on screen. Pair it with a `setPage("home")`. */ -export const requestSurprise = (): void => surprise.set((n) => n + 1); +/** Ask Home to reroll its hero on THIS history entry. Call it once the navigation to Home has + * pushed — `setPage("home", requestSurprise)` — so the token names Home's own entry. */ +export const requestSurprise = (): void => surprise.set(ensureNavId()); -/** The pending token (0 = nothing pending) — the render subscription that wakes Home up. Home only. */ -export const useSurpriseRequest = (): number => surprise.use(); +/** The pending token (null = nothing pending) — the render subscription that wakes Home up. Home only. */ +export const useSurpriseRequest = (): number | null => surprise.use(); -/** Take the pending request, clearing it; false when there was nothing to take. +/** Take the pending request, clearing it; true only when it was requested for the entry Home is on. * * Compare-and-clear rather than a plain read + `consume()`, because a reroll is NOT idempotent — it * spends a `/discover/pick` and steps the tier rotation — and React 18's StrictMode invokes a mount @@ -24,7 +30,8 @@ export const useSurpriseRequest = (): number => surprise.use(); * "Meglepetés" from another module arrived at Home as `start=2`, two picks deep, before the page had * painted once. Checking the LIVE store is what makes the second pass a no-op. */ export const takeSurprise = (): boolean => { - if (!surprise.get()) return false; - surprise.set(0); - return true; + const entry = surprise.get(); + if (entry === null) return false; + surprise.set(null); + return entry === peekNavId(); };