fix(home): shelf honours the Plex gate; hero unpins its origin; palette asks for a surprise
- A13: with Plex off, the Home shelf and the hero's comfort tier go YouTube-only like the rails beside them (owner's call: hide the tiles). The pins are kept; GET /pins still lists them. - Reorder deals the listed pins into the slots they already hold, so a partial list (hidden Plex pins, a stale tab) no longer puts two pins on one position; repeats are ignored. - A21: a comfort hero carries `pinned_from`, the shelf entry it was resolved from; its menu acts on that pin, so a pinned show's next episode offers "Unpin" instead of creating a second pin. - B12: the palette's "Meglepetés" asks for the surprise tier (the next press leading with it); the rotation carries on from there. - B13: the surprise token names the history entry it was requested for (minted by setPage's new `then`, after the push) and is dropped on any other entry, so a navigation that never mounted Home cannot replay it later.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -135,10 +135,11 @@ export default function AppCommandPalette() {
|
||||
label: t("palette.actions.surprise"),
|
||||
icon: <Sparkles className="w-4 h-4" />,
|
||||
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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ComfortPinMenu menu={pinMenu} target={pinTargetForCard} title={(c) => 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. */}
|
||||
<ComfortPinMenu
|
||||
menu={pinMenu}
|
||||
target={(c) => pinTargetForCard(c.pinned_from ?? c)}
|
||||
title={(c) => (c.pinned_from ?? c).title}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 <LoadingState />;
|
||||
@@ -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={<RotateCcw className="h-4 w-4" />}
|
||||
loading={rerolling}
|
||||
onClick={reroll}
|
||||
onClick={() => reroll()}
|
||||
>
|
||||
{t("home.hero.reroll")}
|
||||
</Button>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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?.();
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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<DiscoverCard, "source" | "kind" | "id">,
|
||||
): 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DiscoverCard, "source" | "kind" | "id" | "title">;
|
||||
/** 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). */
|
||||
|
||||
@@ -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<number | null>(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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user