feat(plex): every show card plays the episode it would put on

A show card's picture opened the show page everywhere but Home, whose shelves carried the
resolved `next`. Now every Plex picture plays: a show asks `GET /api/plex/show/{id}/next`
(the in-progress episode, else the first unwatched, else S1·E1 — the rule Home and the show
page's Resume follow) on the click, fresh each time. A started show shows Continue + Restart;
its title, an ⓘ and the menu's "Open show" row open its page; one with no episodes opens there.

The question is asked per show, never carried on every card: three probes a show would cost a
grid page up to 180 queries. The hover preview asks it once it opens and names the episode
("Next: S6·E16 — Title"). Related shows on a show page play the same way.
This commit is contained in:
2026-10-09 01:22:00 +02:00
parent 851b97b09d
commit 3eb87e7108
21 changed files with 405 additions and 43 deletions
+14 -9
View File
@@ -17,8 +17,10 @@ import { type ActionMenuItem } from "./ui/ActionMenu";
// the everyday toggles (watched, add to playlist) on hover; details, copy and the pin live here.
export interface PlexActionHandlers {
/** Play it: a show, the episode it would play (the surface resolves it). */
onOpen: (c: PlexCard) => void;
/** The media-info page. Omitted where a surface has none (the episode lists). */
/** The media-info page, or a show's own page. Omitted where a surface has none (the episode
* lists). */
onInfo?: (c: PlexCard) => void;
onToggleWatched?: (c: PlexCard) => void;
/** Play from the start instead of the resume point (C5). Where given, a started title lists it
@@ -32,7 +34,8 @@ const icon = "w-4 h-4";
export const plexAddLabelKey = (c: PlexCard): string =>
c.type === "episode" ? "plex.playlist.addEpisode" : "plex.playlistAdd.manage";
/** A show opens its drill-down; a movie or an episode plays. */
/** A leaf: a movie or an episode, what the watched toggle, the playlist add and a resume point
* belong to. A show plays too, through the episode it resolves to. */
const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show";
/** Has a resume point: the click continues it, and "from the start" is a choice. A watched item is
@@ -73,15 +76,17 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
): ActionMenuItem[] => {
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
const playable = isPlexPlayable(c);
const isShow = c.type === "show";
const watched = c.status === "watched";
const started = plexStarted(c);
// A show continues where its rollup says it stands, as its card's Continue + Restart do.
const started = isShow ? c.status === "in_progress" : plexStarted(c);
return [
...(play
? [
{
id: "play",
label: !playable ? t("plex.openShow") : started ? t("card.continue") : t("plex.play"),
icon: playable ? <Play className={icon} /> : <Tv2 className={icon} />,
label: started ? t("card.continue") : t("plex.play"),
icon: <Play className={icon} />,
onSelect: () => onOpen(c),
},
// The same words and glyph as the video card's menu (useVideoActions).
@@ -131,18 +136,18 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
},
]
: []),
...(playable && onInfo
...(onInfo
? [
{
id: "details",
label: t("plex.info.title"),
icon: <Info className={icon} />,
label: isShow ? t("plex.openShow") : t("plex.info.title"),
icon: isShow ? <Tv2 className={icon} /> : <Info className={icon} />,
separatorBefore: true,
onSelect: () => onInfo(c),
},
]
: []),
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !(playable && onInfo) })),
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })),
...(withPin ? pin.item(pinPlex(c), c.title) : []),
];
};