- 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.
109 lines
5.2 KiB
TypeScript
109 lines
5.2 KiB
TypeScript
// 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";
|
|
/** 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";
|
|
|
|
// 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;
|
|
/** 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). */
|
|
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.
|
|
// `press` is which press this is; it rotates the tier the server answers from, so consecutive
|
|
// presses offer a different KIND of answer instead of walking down the resume rail.
|
|
discoverPick: (
|
|
budget: DiscoverBudget,
|
|
seed: number,
|
|
exclude: string[] = [],
|
|
press = 0,
|
|
): Promise<{ budget: DiscoverBudget; hero: DiscoverHero | null }> =>
|
|
req(
|
|
`/api/discover/pick?budget=${budget}&seed=${seed}&start=${press}` +
|
|
(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}`;
|
|
}
|