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:
@@ -26,6 +26,7 @@ import { useDebounced } from "../lib/useDebounced";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { usePlayShow } from "./usePlayShow";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import PlexDiscover from "./PlexDiscover";
|
||||
@@ -328,13 +329,23 @@ export default function PlexBrowse() {
|
||||
return () => io.disconnect();
|
||||
}, [sentinelEl, hasNextPage, isFetching, fetchNextPage]);
|
||||
|
||||
const resolveShow = usePlayShow(
|
||||
(id, fromStart) => sub.open({ kind: "player", id, fromStart }),
|
||||
(id) => sub.open({ kind: "show", id }),
|
||||
);
|
||||
// A show's card plays the episode it resolves to (usePlayShow); the grid's place is kept first.
|
||||
function playShow(card: PlexCard, fromStart: boolean) {
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
void resolveShow(card, fromStart);
|
||||
}
|
||||
function onCard(card: PlexCard) {
|
||||
if (card.type === "show") return playShow(card, false);
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
|
||||
if (card.type === "show") sub.open({ kind: "show", id: card.id });
|
||||
else sub.open({ kind: "player", id: card.id });
|
||||
sub.open({ kind: "player", id: card.id });
|
||||
}
|
||||
// The started card's Restart (C5): the same trip, from 0 instead of the resume point.
|
||||
function onRestartCard(card: PlexCard) {
|
||||
if (card.type === "show") return playShow(card, true);
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
sub.open({ kind: "player", id: card.id, fromStart: true });
|
||||
}
|
||||
@@ -509,6 +520,7 @@ export default function PlexBrowse() {
|
||||
}
|
||||
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
||||
onOpenShow={(id) => sub.open({ kind: "show", id })}
|
||||
onPlayShow={playShow}
|
||||
onFilter={applyFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
/>
|
||||
|
||||
@@ -161,10 +161,27 @@ describe("a Plex title's menu", () => {
|
||||
expect(labels()).not.toContain("card.restartTitle");
|
||||
});
|
||||
|
||||
it("a show opens its page and has no watched, add or details rows", () => {
|
||||
setup(card({ type: "show" }));
|
||||
// A show plays like any title (the surface resolves the episode); its page is a row of its own.
|
||||
// The watched toggle and the add belong to an episode, so a show has none.
|
||||
it("a show plays, opens its page from a row of its own, and has no watched or add rows", async () => {
|
||||
const { handlers } = setup(card({ type: "show" }));
|
||||
rightClick();
|
||||
expect(labels()).toEqual(["plex.openShow", "common.copy.title", "home.pin.add"]);
|
||||
expect(labels()).toEqual(["plex.play", "plex.openShow", "common.copy.title", "home.pin.add"]);
|
||||
fireEvent.click(item("plex.play"));
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onOpen).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })),
|
||||
);
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.openShow"));
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onInfo).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("a started show continues, and starts its episode over from the next row", () => {
|
||||
setup(card({ type: "show", status: "in_progress" }));
|
||||
rightClick();
|
||||
expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
||||
});
|
||||
|
||||
it("the card's '…' opens the same menu, and says so", () => {
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
|
||||
// key-passthrough, so the asserted names are the i18n keys.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
|
||||
import { render as rtlRender, screen, cleanup, fireEvent, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -21,7 +23,15 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
|
||||
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
|
||||
import { iconButtonClass } from "./ui/buttonStyles";
|
||||
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
||||
import { api, type PlexCard, type PlexSeasonDetail } from "../lib/api";
|
||||
|
||||
// A show card asks the server which episode it would play once its preview is up: a query client.
|
||||
const render = (ui: ReactElement) =>
|
||||
rtlRender(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const movie = (status?: string): PlexCard => ({
|
||||
id: "1",
|
||||
@@ -169,14 +179,57 @@ describe("PlexPosterCard with a next episode", () => {
|
||||
expect(onInfo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays a plain show card without one: no Play, no toggles, no ⓘ", () => {
|
||||
// Every show card plays (the owner, 2026-10-09): without its episode in hand the surface's onOpen
|
||||
// asks which one. The toggles act on an episode, so they wait for one; the ⓘ is the show's page.
|
||||
it("plays without one too, opens its page from the ⓘ, and offers no toggles", () => {
|
||||
const onOpen = vi.fn();
|
||||
const onPage = vi.fn();
|
||||
const { container } = render(
|
||||
<PlexPosterCard card={show} onOpen={vi.fn()} onToggleWatched={vi.fn()} onInfo={vi.fn()} />,
|
||||
<PlexPosterCard card={show} onOpen={onOpen} onPage={onPage} onToggleWatched={vi.fn()} />,
|
||||
);
|
||||
expect(playGlyph(container)).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "plex.openShow" })).toBeNull();
|
||||
expect(playGlyph(container)).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show" }));
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.openShow" }));
|
||||
expect(onPage).toHaveBeenCalledWith(show);
|
||||
expect(screen.queryByRole("button", { name: "plex.markWatched" })).toBeNull();
|
||||
});
|
||||
|
||||
it("continues a started show and starts its episode over, as a started movie does", () => {
|
||||
const onOpen = vi.fn();
|
||||
const onRestart = vi.fn();
|
||||
render(
|
||||
<PlexPosterCard
|
||||
card={{ ...show, status: "in_progress" }}
|
||||
onOpen={onOpen}
|
||||
onRestart={onRestart}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "card.restart" }));
|
||||
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "card.continue" }));
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("names the episode it would play once its preview is up, and not asked before", async () => {
|
||||
vi.useFakeTimers();
|
||||
const ask = vi
|
||||
.spyOn(api, "plexShowNext")
|
||||
.mockResolvedValue({ next: { ...next, title: "Pilot" } });
|
||||
render(<PlexPosterCard card={show} onOpen={vi.fn()} />);
|
||||
fireEvent.mouseEnter(screen.getByRole("button", { name: "Show" }).closest(".cv-poster")!);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(ask).not.toHaveBeenCalled(); // a sweep across a grid asks nothing
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
expect(ask).toHaveBeenCalledWith("sh", "en");
|
||||
expect(await screen.findByText("plex.nextUp:S6·E16 — Pilot")).toBeTruthy();
|
||||
ask.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// C5: a started item's picture offers Continue and Restart (the video card's pair) instead of a Play;
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
// ("Season 1", "Specials") is named from its number instead. i18n here echoes the key AND its values,
|
||||
// so an assertion can see which words went into the line.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { render as rtlRender, screen, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -17,6 +19,14 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
import { PlexPosterCard, SeasonCard } from "./plexCards";
|
||||
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
||||
|
||||
// A show card asks the server which episode it would play once its preview is up: a query client.
|
||||
const render = (ui: ReactElement) =>
|
||||
rtlRender(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const watchedEpisode: PlexCard = {
|
||||
|
||||
@@ -4,11 +4,21 @@
|
||||
// React Compiler memoizes a call on its ARGUMENTS — `formatRuntime(card.duration_seconds)` stayed
|
||||
// "2 hr 4 min" after switching to Hungarian while the hero beside it said "2 ó 16 p". Real catalog.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, act } from "@testing-library/react";
|
||||
import { render as rtlRender, screen, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
import { setLanguage } from "../i18n";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import type { PlexCard } from "../lib/api";
|
||||
|
||||
// A show card asks the server which episode it would play once its preview is up: a query client.
|
||||
const render = (ui: ReactElement) =>
|
||||
rtlRender(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setLanguage("en");
|
||||
|
||||
@@ -2,10 +2,14 @@ import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { plexLang } from "../i18n";
|
||||
import { formatDecimal, formatRuntime } from "../lib/format";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||
import { episodeName, episodeNumbers, seasonTitle } from "../lib/plexDetail";
|
||||
import { episodeLine, episodeName, episodeNumbers, seasonTitle } from "../lib/plexDetail";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
|
||||
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
|
||||
@@ -30,8 +34,9 @@ import { useMediaPreview } from "./ui/useMediaPreview";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { cardImage } from "../lib/plexImage";
|
||||
|
||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
||||
// Presentational Plex cards + strips, split out of PlexBrowse (prop-driven, no shared state; the one
|
||||
// read is a show card asking which episode it would play, once its preview is up). Rendered by the
|
||||
// browse grid, the person page, and the series show/season views.
|
||||
//
|
||||
// docs/DESIGN.md C1 ("Action placement"): a card shows its state (watched, progress) at rest;
|
||||
// on hover its picture carries the everyday toggles (watched, add to playlist) and the "…" that
|
||||
@@ -74,7 +79,7 @@ function HoverBar({
|
||||
onToggleWatched?: () => void;
|
||||
addLabel: string;
|
||||
onAdd?: () => void;
|
||||
/** The item's own page, when the click does something else (a show on Home plays its next episode). */
|
||||
/** A show's own page: its picture plays an episode, so the page has a button of its own. */
|
||||
onInfo?: () => void;
|
||||
more: More;
|
||||
}) {
|
||||
@@ -276,7 +281,7 @@ export function PlexPosterCard({
|
||||
/** Open a card's page (a show's, or a movie's or an episode's info page): the title's link. It is
|
||||
* handed the card the title NAMES — the show, for an episode standing as its show. */
|
||||
onPage?: (target: PlexCard) => void;
|
||||
/** Play the item (or the `next` episode) from its start, not its resume point (C5). */
|
||||
/** Play the item (or the episode a show plays) from its start, not its resume point (C5). */
|
||||
onRestart?: () => void;
|
||||
onToggleWatched?: (c: PlexCard) => void;
|
||||
/** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */
|
||||
@@ -296,11 +301,12 @@ export function PlexPosterCard({
|
||||
wide?: boolean;
|
||||
/** On a shelf (a horizontal strip): a one-line title (ui/mediaCardStyles). */
|
||||
shelf?: boolean;
|
||||
/** A SHOW whose click plays this episode (Home's shelves: a pinned or started show plays on). The
|
||||
* picture then draws its Play, the watched toggle and the add act on the episode, the meta line
|
||||
* names it, and an ⓘ (`onInfo`) opens the show. */
|
||||
/** A SHOW's episode, resolved by the surface (Home's shelves carry it): the watched toggle and the
|
||||
* add act on it and the meta line names it. Without it a show still plays — the surface's `onOpen`
|
||||
* asks the server which episode (`/show/{id}/next`) — and the preview names that episode once the
|
||||
* pointer has rested on the card. */
|
||||
next?: PlexCard | null;
|
||||
/** The show's page, beside a `next` episode. */
|
||||
/** The show's page, on the ⓘ (`onPage` where a surface has no other). */
|
||||
onInfo?: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -315,9 +321,21 @@ export function PlexPosterCard({
|
||||
const titled: PlexCard = asShow
|
||||
? { ...card, type: "show", id: card.show_id!, title: card.show_title! }
|
||||
: card;
|
||||
const ep = card.type === "show" ? (next ?? null) : null;
|
||||
// movies/episodes play; a show opens its drill-down — unless it plays on (`next`)
|
||||
const isPlayable = card.type !== "show" || !!ep;
|
||||
const isShow = card.type === "show";
|
||||
const ep = isShow ? (next ?? null) : null;
|
||||
// Every picture plays: a show its next episode. The hover toggles act on a leaf, so a show offers
|
||||
// them only with its episode in hand.
|
||||
const isPlayable = !isShow || !!ep;
|
||||
const plang = plexLang(lang);
|
||||
// Which episode a show would play, for the preview: asked once the pointer rests on the card, and
|
||||
// fresh every time (staleTime 0), since a watch anywhere moves it.
|
||||
const upNextQ = useQuery({
|
||||
queryKey: qk.plexShowNext(card.id, plang),
|
||||
queryFn: () => api.plexShowNext(card.id, plang),
|
||||
enabled: isShow && !ep && preview.open,
|
||||
staleTime: 0,
|
||||
});
|
||||
const upNext = ep ?? upNextQ.data?.next ?? null;
|
||||
// What the hover row acts on: the episode a show would play, else the card itself.
|
||||
const acted = ep ?? card;
|
||||
const watched = card.status === "watched";
|
||||
@@ -329,6 +347,9 @@ export function PlexPosterCard({
|
||||
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
|
||||
// The next episode by name: its numbers, or its title when Plex has none for it.
|
||||
const nextName = ep && episodeName(ep);
|
||||
// A show without its episode in hand continues where a started one stands (its rollup status).
|
||||
const started = ep ? plexStarted(ep) : isShow ? card.status === "in_progress" : plexStarted(card);
|
||||
const showPage = ep ? onInfo : isShow && onPage ? () => onPage(card) : undefined;
|
||||
// A show has no length: its corner says how many seasons it has instead, so the corner alone tells
|
||||
// a movie from a show (the film/TV glyph it replaces said the same thing a second time).
|
||||
const corner =
|
||||
@@ -363,10 +384,10 @@ export function PlexPosterCard({
|
||||
label={name}
|
||||
onOpen={onOpen}
|
||||
onRestart={onRestart}
|
||||
plays={isPlayable}
|
||||
plays
|
||||
small={shelf && landscape}
|
||||
pct={pct}
|
||||
started={plexStarted(acted)}
|
||||
started={started}
|
||||
bar={
|
||||
<HoverBar
|
||||
watched={actedWatched}
|
||||
@@ -382,7 +403,7 @@ export function PlexPosterCard({
|
||||
onToggleWatched={toggle}
|
||||
addLabel={t(plexAddLabelKey(acted))}
|
||||
onAdd={add}
|
||||
onInfo={ep ? onInfo : undefined}
|
||||
onInfo={showPage}
|
||||
more={m}
|
||||
/>
|
||||
}
|
||||
@@ -432,6 +453,7 @@ export function PlexPosterCard({
|
||||
{rating}
|
||||
</span>
|
||||
}
|
||||
note={upNext && t("plex.nextUp", { name: episodeLine(upNext) })}
|
||||
summary={card.summary}
|
||||
/>
|
||||
</>
|
||||
@@ -637,12 +659,18 @@ export function CastRow({
|
||||
export function RelatedStrip({
|
||||
related,
|
||||
onOpen,
|
||||
onPlay,
|
||||
onRestart,
|
||||
menuProps,
|
||||
menuButtonProps,
|
||||
openId = null,
|
||||
}: {
|
||||
related: PlexCard[];
|
||||
/** A related show's page (its title and its ⓘ). */
|
||||
onOpen: (c: PlexCard) => void;
|
||||
/** Its picture: the episode it would play, as on every show card. */
|
||||
onPlay: (c: PlexCard) => void;
|
||||
onRestart: (c: PlexCard) => void;
|
||||
/** The page's Plex menu (a related show is a Plex card like any other: DESIGN.md C1). */
|
||||
menuProps?: (c: PlexCard) => Record<string, unknown>;
|
||||
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
|
||||
@@ -663,8 +691,8 @@ export function RelatedStrip({
|
||||
card={r}
|
||||
shelf
|
||||
navIndex={i}
|
||||
onOpen={() => onOpen(r)}
|
||||
// A related title is a show: its page is what both its picture and its title open.
|
||||
onOpen={() => onPlay(r)}
|
||||
onRestart={() => onRestart(r)}
|
||||
onPage={onOpen}
|
||||
menuProps={menuProps}
|
||||
menuButtonProps={menuButtonProps}
|
||||
|
||||
@@ -78,6 +78,7 @@ function renderShow(onFilter = vi.fn(), onOpenPerson = vi.fn()) {
|
||||
onPlay={() => {}}
|
||||
onOpenSeason={() => {}}
|
||||
onOpenShow={() => {}}
|
||||
onPlayShow={() => {}}
|
||||
onFilter={onFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
/>
|
||||
|
||||
@@ -132,6 +132,7 @@ describe("the restart button on a show and a season", () => {
|
||||
onPlay={onPlay}
|
||||
onOpenSeason={() => {}}
|
||||
onOpenShow={() => {}}
|
||||
onPlayShow={() => {}}
|
||||
/>,
|
||||
);
|
||||
const btn = await screen.findByRole("button", { name: /plex\.series\.showFromStart/ });
|
||||
@@ -154,6 +155,7 @@ describe("the show page's summary", () => {
|
||||
onPlay={() => {}}
|
||||
onOpenSeason={() => {}}
|
||||
onOpenShow={() => {}}
|
||||
onPlayShow={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByText("plex.textMore"));
|
||||
|
||||
@@ -422,6 +422,7 @@ export function PlexShowView({
|
||||
onPlay,
|
||||
onOpenSeason,
|
||||
onOpenShow,
|
||||
onPlayShow,
|
||||
onFilter,
|
||||
onOpenPerson,
|
||||
}: {
|
||||
@@ -430,6 +431,8 @@ export function PlexShowView({
|
||||
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
|
||||
onOpenSeason: (seasonId: string) => void;
|
||||
onOpenShow: (id: string) => void;
|
||||
/** A related show's picture: the episode it would play, as on every show card. */
|
||||
onPlayShow: (show: PlexCard, fromStart: boolean) => void;
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
onOpenPerson?: (name: string) => void;
|
||||
}) {
|
||||
@@ -446,7 +449,14 @@ export function PlexShowView({
|
||||
const [collOpen, setCollOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const relatedMenu = useContextMenu<PlexCard>();
|
||||
const related = usePlexCardActions({ onOpen: (c) => onOpenShow(c.id) }, relatedMenu.open);
|
||||
const related = usePlexCardActions(
|
||||
{
|
||||
onOpen: (c) => onPlayShow(c, false),
|
||||
onRestart: (c) => onPlayShow(c, true),
|
||||
onInfo: (c) => onOpenShow(c.id),
|
||||
},
|
||||
relatedMenu.open,
|
||||
);
|
||||
const seasonMenu = useContextMenu<PlexSeasonDetail>();
|
||||
const copy = useCopyItems();
|
||||
|
||||
@@ -743,6 +753,8 @@ export function PlexShowView({
|
||||
<RelatedStrip
|
||||
related={d.related}
|
||||
onOpen={(c) => onOpenShow(c.id)}
|
||||
onPlay={(c) => onPlayShow(c, false)}
|
||||
onRestart={(c) => onPlayShow(c, true)}
|
||||
menuProps={relatedMenu.triggerProps}
|
||||
menuButtonProps={relatedMenu.buttonProps}
|
||||
openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null}
|
||||
|
||||
@@ -18,12 +18,15 @@ export function MediaPreview({
|
||||
preview,
|
||||
title,
|
||||
facts,
|
||||
note,
|
||||
summary,
|
||||
}: {
|
||||
preview: ReturnType<typeof useMediaPreview>;
|
||||
title: string;
|
||||
/** One line under the title: year, length, score. */
|
||||
facts?: ReactNode;
|
||||
/** A line of its own under the facts (a show's "Next: S6·E16 — Title"). */
|
||||
note?: ReactNode;
|
||||
summary?: string | null;
|
||||
}) {
|
||||
return (
|
||||
@@ -36,6 +39,7 @@ export function MediaPreview({
|
||||
>
|
||||
<p className="text-sm font-semibold leading-snug text-fg">{title}</p>
|
||||
{facts && <div className="mt-0.5 text-xs text-muted">{facts}</div>}
|
||||
{note && <p className="mt-1 text-xs font-medium text-fg">{note}</p>}
|
||||
{summary && (
|
||||
<p className="mt-2 line-clamp-8 whitespace-pre-line text-xs leading-relaxed text-fg">
|
||||
{summary}
|
||||
|
||||
@@ -19,6 +19,8 @@ export function useMediaPreview(enabled = true) {
|
||||
});
|
||||
return {
|
||||
popover: pop,
|
||||
/** The preview is up: the pointer has rested on the card (what a card may fetch for it waits on). */
|
||||
open: open && enabled,
|
||||
/** The card root's props: the anchor, the hover wiring, and a close on any press — merged with
|
||||
* the root's own (its menu's right-click and long-press). */
|
||||
rootProps: (own: HTMLAttributes<HTMLDivElement> & Record<string, unknown> = {}) => ({
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A show's card plays (the owner, 2026-10-09): the click asks the server which episode, every time —
|
||||
// a preview answer from before a watch elsewhere must not decide it.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const notified = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../lib/actionErrors", () => ({ notifyActionError: notified }));
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "hu" } }),
|
||||
}));
|
||||
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { usePlayShow } from "./usePlayShow";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
notified.mockReset();
|
||||
});
|
||||
|
||||
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "" };
|
||||
const ep = (id: string): PlexCard => ({ id, type: "episode", title: id, thumb: "" });
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const play = vi.fn();
|
||||
const openShow = vi.fn();
|
||||
const { result } = renderHook(() => usePlayShow(play, openShow), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
return { qc, play, openShow, playShow: result.current };
|
||||
}
|
||||
|
||||
describe("usePlayShow", () => {
|
||||
it("plays the episode the server names, from the start when asked to", async () => {
|
||||
const ask = vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: ep("e7") });
|
||||
const { play, openShow, playShow } = mount();
|
||||
await act(() => playShow(show, true));
|
||||
expect(ask).toHaveBeenCalledWith("sh", "hu");
|
||||
expect(play).toHaveBeenCalledWith("e7", true);
|
||||
expect(openShow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks again rather than play what the preview cached before a watch", async () => {
|
||||
const ask = vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: ep("e8") });
|
||||
const { qc, play, playShow } = mount();
|
||||
qc.setQueryData(qk.plexShowNext("sh", "hu"), { next: ep("e7") });
|
||||
await act(() => playShow(show, false));
|
||||
expect(ask).toHaveBeenCalledTimes(1);
|
||||
expect(play).toHaveBeenCalledWith("e8", false);
|
||||
});
|
||||
|
||||
it("opens the page of a show with nothing to play", async () => {
|
||||
vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: null });
|
||||
const { play, openShow, playShow } = mount();
|
||||
await act(() => playShow(show, false));
|
||||
expect(openShow).toHaveBeenCalledWith("sh");
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says so when the question fails, and opens nothing", async () => {
|
||||
vi.spyOn(api, "plexShowNext").mockRejectedValue(new Error("down"));
|
||||
const { play, openShow, playShow } = mount();
|
||||
await act(() => playShow(show, false));
|
||||
expect(notified).toHaveBeenCalledWith(expect.any(Error), "plex.playShowFailed");
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(openShow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { plexLang } from "../i18n";
|
||||
|
||||
/** A show's card plays like any card: the episode in progress, else the first unwatched, else the
|
||||
* first (`/show/{id}/next`). Asked fresh on the click, since the card's preview may hold an answer
|
||||
* from before a watch somewhere else. A show with no episodes has nothing to play, so its page opens
|
||||
* (`openShow`) and says so; a failed question is a toast, and nothing opens. */
|
||||
export function usePlayShow(
|
||||
play: (episodeId: string, fromStart: boolean) => void,
|
||||
openShow: (showId: string) => void,
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = plexLang(i18n.language);
|
||||
// `undefined` when the question failed (said in the toast).
|
||||
const next = (id: string) =>
|
||||
qc
|
||||
.fetchQuery({
|
||||
queryKey: qk.plexShowNext(id, lang),
|
||||
queryFn: () => api.plexShowNext(id, lang),
|
||||
staleTime: 0,
|
||||
})
|
||||
.then(
|
||||
(r) => r.next,
|
||||
(e: unknown) => void notifyActionError(e, t("plex.playShowFailed")),
|
||||
);
|
||||
return async (show: PlexCard, fromStart: boolean) => {
|
||||
const ep = await next(show.id);
|
||||
if (ep === undefined) return;
|
||||
if (ep) play(ep.id, fromStart);
|
||||
else openShow(show.id);
|
||||
};
|
||||
}
|
||||
@@ -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) : []),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
||||
"seasonTitle": "Season {{n}}",
|
||||
"specials": "Specials",
|
||||
"playShowFailed": "Couldn't find the episode to play — please try again",
|
||||
"watchedFailed": "Couldn't update the watched state — please try again",
|
||||
"seasons_one": "{{count}} season",
|
||||
"seasons_other": "{{count}} seasons",
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"episodeLine": "S{{season}}·E{{episode}} · {{title}}",
|
||||
"seasonTitle": "{{n}}. évad",
|
||||
"specials": "Különkiadások",
|
||||
"playShowFailed": "Nem sikerült megtalálni a lejátszandó epizódot — próbáld újra",
|
||||
"watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra",
|
||||
"seasons_one": "{{count}} évad",
|
||||
"seasons_other": "{{count}} évad",
|
||||
|
||||
@@ -644,6 +644,10 @@ export const plexApi = {
|
||||
},
|
||||
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
|
||||
req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`),
|
||||
// The episode a show's card plays: in progress, else the first unwatched, else the first (null:
|
||||
// no episodes). Asked per show, as a card is hovered or played (the route says why).
|
||||
plexShowNext: (id: string, lang?: string): Promise<{ next: PlexCard | null }> =>
|
||||
req(`/api/plex/show/${encodeURIComponent(id)}/next${lang === "hu" ? "?lang=hu" : ""}`),
|
||||
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
|
||||
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
|
||||
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, {
|
||||
|
||||
@@ -134,6 +134,10 @@ export const qk = {
|
||||
showId !== undefined
|
||||
? (["plex-show", showId, ...(lang ? [lang] : [])] as const)
|
||||
: (["plex-show"] as const),
|
||||
// A show card's next episode. Its own root, not under `plex-show`: a watch write anywhere moves
|
||||
// it, and the card reads it fresh (staleTime 0) instead of trusting every write site to invalidate.
|
||||
plexShowNext: (showId: string, lang?: string) =>
|
||||
["plex-show-next", showId, ...(lang ? [lang] : [])] as const,
|
||||
plexItem: (id: string, lang?: string) => ["plex-item", id, ...(lang ? [lang] : [])] as const,
|
||||
// Extraction state of one subtitle track. Not keyed by the playhead: the position is an argument
|
||||
// that steers the background extraction, not part of the track's identity.
|
||||
|
||||
Reference in New Issue
Block a user