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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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ó"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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")],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user