diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index ada2cf9b..b5a904f4 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -14,6 +14,7 @@ from app.collation import collated, sorted_words from app import logs from app.auth import current_user from app.db import get_db +from app.discover import queries from app.models import ( PlexCollection, PlexItem, @@ -221,6 +222,24 @@ def show_detail( } +@router.get("/show/{rating_key}/next") +def show_next( + rating_key: str, + lang: str = "en", + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """The episode a show's card plays: the one in progress, else the first unwatched, else the first + (`discover.queries.next_episode`, the rule Home's shelves and the show page's Resume follow). + Asked one show at a time, when a card is hovered or played, rather than carried on every card of a + grid: three probes a show would cost a 60-card page 180 queries for the few cards anyone plays. + `next` is null for a show with no episodes.""" + sh = by_rating_key(db, PlexShow, rating_key) + if sh is None: + raise not_found("show") + return {"next": queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))} + + def _season_block(se: PlexSeason, cards: list[dict]) -> dict: """A season's card for the show page (with its aggregate rollup) plus its episodes (used by the season subpage, read from the same cached show-detail payload).""" diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py index 572da9b1..2f8f4895 100644 --- a/backend/tests/test_plex_detail.py +++ b/backend/tests/test_plex_detail.py @@ -19,7 +19,7 @@ import app.state as state from app.auth import current_user from app.db import get_db from app.main import app -from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, User +from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User from app.plex import detail from app.plex.sync import _apply_facet_fields, _enrich_people_batch @@ -446,3 +446,63 @@ def test_item_detail_markers_carry_plex_final_flag(db, monkeypatch, override_dep {"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False}, {"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True}, ] + + +def _show_with_episodes(db, n=3): + lib = PlexLibrary(plex_key="3", title="Shows", kind="show", enabled=True) + db.add(lib) + db.flush() + show = PlexShow(rating_key="sn1", library_id=lib.id, title="Next Show") + db.add(show) + db.flush() + eps = [ + PlexItem( + rating_key=f"sn1e{i}", + library_id=lib.id, + kind="episode", + title=f"Episode {i}", + show_id=show.id, + season_number=1, + episode_number=i, + ) + for i in range(1, n + 1) + ] + db.add_all(eps) + db.flush() + return eps + + +def test_show_next_plays_the_first_episode_of_an_untouched_show(db, monkeypatch, override_dep): + """A show card's picture plays: never started → its first episode.""" + _show_with_episodes(db) + user = _user(db, "next1@example.com") + db.commit() + resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next") + assert resp.status_code == 200, resp.text + nxt = resp.json()["next"] + assert nxt["id"] == "sn1e1" + assert nxt["show_title"] == "Next Show" + + +def test_show_next_continues_the_episode_in_progress(db, monkeypatch, override_dep): + """Started → the episode in progress (the show page's Resume), not the first unwatched.""" + eps = _show_with_episodes(db) + user = _user(db, "next2@example.com") + db.add(PlexState(user_id=user.id, item_id=eps[0].id, status="watched")) + db.add(PlexState(user_id=user.id, item_id=eps[2].id, status="new", position_seconds=120)) + db.commit() + resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next") + assert resp.json()["next"]["id"] == "sn1e3" + assert resp.json()["next"]["position_seconds"] == 120 + + +def test_show_next_is_null_for_an_empty_show_and_404_for_an_unknown_one( + db, monkeypatch, override_dep +): + _show_with_episodes(db, n=0) + user = _user(db, "next3@example.com") + db.commit() + assert _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next").json() == { + "next": None + } + assert _get(db, user, monkeypatch, override_dep, "/api/plex/show/nope/next").status_code == 404 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e5032807..75233359 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -400,7 +400,9 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen # `mediaTitleClass`, `mediaPictureBox`). - **Play only where the click plays.** The hover layer dims the picture and shows `MediaPlayGlyph`, the one round media Play (the button family's `media` variant), only on a card whose click starts - playback; a show without a next episode opens instead and shows none. The hover row (watched, save or + playback. Every Plex picture plays, a show too: the episode in progress (Continue + Restart), else the + first unwatched, else the first, which the click asks the server for (`/show/{id}/next`); its title + and an ⓘ open the show's page, and one with no episodes opens there. The hover row (watched, save or add, "…") docks at the picture's bottom-right; the length badge steps aside while it shows. - **Density.** A grid card: a two-line title and the full meta (Plex: year · ★ score; a video: the channel, then views · age). On a shelf (any horizontal strip) the title is one line, cut with an @@ -413,7 +415,8 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen # had become the strip's. - **The preview (the owner's pick C).** Resting the mouse on a Plex card for ~0.9 s opens a panel beside it: the whole title, year · length · ★, and the synopsis in short lines (`ui/MediaPreview` + - `useMediaPreview`). Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same), + `useMediaPreview`); a show's adds the episode it would play ("Next: S6·E16 — Title"), asked as the + panel opens. Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same), passive (it never takes a click), and any press on the card closes it. Long text never goes in a native `title` tooltip. - **Home's hero is a card** (the owner's pick A): the same hover row and Play on its picture, the same @@ -718,3 +721,4 @@ of a full-width panel and nothing on the right. | 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine | | 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it | | 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" | +| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (in progress → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays | diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index be08e9a0..1c91edd2 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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} /> diff --git a/frontend/src/components/PlexMenu.dom.test.tsx b/frontend/src/components/PlexMenu.dom.test.tsx index ac5b2105..23fcd867 100644 --- a/frontend/src/components/PlexMenu.dom.test.tsx +++ b/frontend/src/components/PlexMenu.dom.test.tsx @@ -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", () => { diff --git a/frontend/src/components/plexCards.dom.test.tsx b/frontend/src/components/plexCards.dom.test.tsx index a8e5a6f5..ba6ae93f 100644 --- a/frontend/src/components/plexCards.dom.test.tsx +++ b/frontend/src/components/plexCards.dom.test.tsx @@ -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(); @@ -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 }) => ( + {children} + ), + }); 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( - , + , ); - 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( + , + ); + 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(); + 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; diff --git a/frontend/src/components/plexCards.lang.dom.test.tsx b/frontend/src/components/plexCards.lang.dom.test.tsx index cf5e079a..219603ed 100644 --- a/frontend/src/components/plexCards.lang.dom.test.tsx +++ b/frontend/src/components/plexCards.lang.dom.test.tsx @@ -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(); @@ -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 }) => ( + {children} + ), + }); + afterEach(cleanup); const watchedEpisode: PlexCard = { diff --git a/frontend/src/components/plexCards.langSwitch.dom.test.tsx b/frontend/src/components/plexCards.langSwitch.dom.test.tsx index a2135c62..f493d87e 100644 --- a/frontend/src/components/plexCards.langSwitch.dom.test.tsx +++ b/frontend/src/components/plexCards.langSwitch.dom.test.tsx @@ -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 }) => ( + {children} + ), + }); + afterEach(() => { cleanup(); setLanguage("en"); diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index a3866f13..11fb9b63 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -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={ } @@ -432,6 +453,7 @@ export function PlexPosterCard({ {rating} } + 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; menuButtonProps?: (c: PlexCard) => Record; @@ -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} diff --git a/frontend/src/components/plexShow.aside.dom.test.tsx b/frontend/src/components/plexShow.aside.dom.test.tsx index 1202688a..4d6425b2 100644 --- a/frontend/src/components/plexShow.aside.dom.test.tsx +++ b/frontend/src/components/plexShow.aside.dom.test.tsx @@ -78,6 +78,7 @@ function renderShow(onFilter = vi.fn(), onOpenPerson = vi.fn()) { onPlay={() => {}} onOpenSeason={() => {}} onOpenShow={() => {}} + onPlayShow={() => {}} onFilter={onFilter} onOpenPerson={onOpenPerson} /> diff --git a/frontend/src/components/plexViews.restart.dom.test.tsx b/frontend/src/components/plexViews.restart.dom.test.tsx index 45458e93..ea08038d 100644 --- a/frontend/src/components/plexViews.restart.dom.test.tsx +++ b/frontend/src/components/plexViews.restart.dom.test.tsx @@ -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")); diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 0500729b..e03d7092 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -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) => 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(); - 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(); const copy = useCopyItems(); @@ -743,6 +753,8 @@ export function PlexShowView({ 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} diff --git a/frontend/src/components/ui/MediaPreview.tsx b/frontend/src/components/ui/MediaPreview.tsx index d225b078..ba416c9d 100644 --- a/frontend/src/components/ui/MediaPreview.tsx +++ b/frontend/src/components/ui/MediaPreview.tsx @@ -18,12 +18,15 @@ export function MediaPreview({ preview, title, facts, + note, summary, }: { preview: ReturnType; 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({ >

{title}

{facts &&
{facts}
} + {note &&

{note}

} {summary && (

{summary} diff --git a/frontend/src/components/ui/useMediaPreview.ts b/frontend/src/components/ui/useMediaPreview.ts index 09e5341d..8521abc3 100644 --- a/frontend/src/components/ui/useMediaPreview.ts +++ b/frontend/src/components/ui/useMediaPreview.ts @@ -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 & Record = {}) => ({ diff --git a/frontend/src/components/usePlayShow.dom.test.tsx b/frontend/src/components/usePlayShow.dom.test.tsx new file mode 100644 index 00000000..b4877a79 --- /dev/null +++ b/frontend/src/components/usePlayShow.dom.test.tsx @@ -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()), + 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 }) => ( + {children} + ), + }); + 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(); + }); +}); diff --git a/frontend/src/components/usePlayShow.ts b/frontend/src/components/usePlayShow.ts new file mode 100644 index 00000000..2a0ca6b5 --- /dev/null +++ b/frontend/src/components/usePlayShow.ts @@ -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); + }; +} diff --git a/frontend/src/components/usePlexCardActions.tsx b/frontend/src/components/usePlexCardActions.tsx index 2b391e2f..f62a99e0 100644 --- a/frontend/src/components/usePlexCardActions.tsx +++ b/frontend/src/components/usePlexCardActions.tsx @@ -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 ? : , + label: started ? t("card.continue") : t("plex.play"), + 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: , + label: isShow ? t("plex.openShow") : t("plex.info.title"), + icon: isShow ? : , 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) : []), ]; }; diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 6862834b..bd7db65a 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index d8caa7c8..05030cab 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -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", diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 922eea7c..17542060 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -644,6 +644,10 @@ export const plexApi = { }, plexShow: (id: string, lang?: string): Promise => 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`, { diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index c098fb48..1903ecf2 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -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.