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
+19
View File
@@ -14,6 +14,7 @@ from app.collation import collated, sorted_words
from app import logs from app import logs
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.discover import queries
from app.models import ( from app.models import (
PlexCollection, PlexCollection,
PlexItem, 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: 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 """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).""" season subpage, read from the same cached show-detail payload)."""
+61 -1
View File
@@ -19,7 +19,7 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app 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 import detail
from app.plex.sync import _apply_facet_fields, _enrich_people_batch 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": 6811.0, "end_s": 6831.0, "final": False},
{"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True}, {"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
+6 -2
View File
@@ -400,7 +400,9 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
`mediaTitleClass`, `mediaPictureBox`). `mediaTitleClass`, `mediaPictureBox`).
- **Play only where the click plays.** The hover layer dims the picture and shows `MediaPlayGlyph`, the - **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 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. 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 - **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 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. 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 - **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` + 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 passive (it never takes a click), and any press on the card closes it. Long text never goes in a
native `title` tooltip. 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 - **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 | 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 | 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-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 |
+14 -2
View File
@@ -26,6 +26,7 @@ import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history"; import { useHistorySubview } from "../lib/history";
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl"; import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
import { toggleWatchState } from "../lib/plexWatch"; import { toggleWatchState } from "../lib/plexWatch";
import { usePlayShow } from "./usePlayShow";
import SortControl from "./ui/SortControl"; import SortControl from "./ui/SortControl";
import FilterSelect from "./ui/FilterSelect"; import FilterSelect from "./ui/FilterSelect";
import PlexDiscover from "./PlexDiscover"; import PlexDiscover from "./PlexDiscover";
@@ -328,13 +329,23 @@ export default function PlexBrowse() {
return () => io.disconnect(); return () => io.disconnect();
}, [sentinelEl, hasNextPage, isFetching, fetchNextPage]); }, [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) { function onCard(card: PlexCard) {
if (card.type === "show") return playShow(card, false);
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
if (card.type === "show") sub.open({ kind: "show", id: card.id }); sub.open({ kind: "player", id: card.id });
else sub.open({ kind: "player", id: card.id });
} }
// The started card's Restart (C5): the same trip, from 0 instead of the resume point. // The started card's Restart (C5): the same trip, from 0 instead of the resume point.
function onRestartCard(card: PlexCard) { function onRestartCard(card: PlexCard) {
if (card.type === "show") return playShow(card, true);
scrollRef.current = scroller()?.scrollTop ?? 0; scrollRef.current = scroller()?.scrollTop ?? 0;
sub.open({ kind: "player", id: card.id, fromStart: true }); 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 })} onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
onOpenShow={(id) => sub.open({ kind: "show", id })} onOpenShow={(id) => sub.open({ kind: "show", id })}
onPlayShow={playShow}
onFilter={applyFilter} onFilter={applyFilter}
onOpenPerson={onOpenPerson} onOpenPerson={onOpenPerson}
/> />
+20 -3
View File
@@ -161,10 +161,27 @@ describe("a Plex title's menu", () => {
expect(labels()).not.toContain("card.restartTitle"); expect(labels()).not.toContain("card.restartTitle");
}); });
it("a show opens its page and has no watched, add or details rows", () => { // A show plays like any title (the surface resolves the episode); its page is a row of its own.
setup(card({ type: "show" })); // 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(); 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", () => { it("the card's '…' opens the same menu, and says so", () => {
+59 -6
View File
@@ -5,7 +5,9 @@
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is // 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. // key-passthrough, so the asserted names are the i18n keys.
import { describe, it, expect, afterEach, vi } from "vitest"; 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) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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 { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
import { iconButtonClass } from "./ui/buttonStyles"; 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 => ({ const movie = (status?: string): PlexCard => ({
id: "1", id: "1",
@@ -169,14 +179,57 @@ describe("PlexPosterCard with a next episode", () => {
expect(onInfo).toHaveBeenCalled(); 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( 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(playGlyph(container)).not.toBeNull();
expect(screen.queryByRole("button", { name: "plex.openShow" })).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(); 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; // 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, // ("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. // so an assertion can see which words went into the line.
import { describe, it, expect, afterEach, vi } from "vitest"; 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) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
@@ -17,6 +19,14 @@ vi.mock("react-i18next", async (importOriginal) => {
import { PlexPosterCard, SeasonCard } from "./plexCards"; import { PlexPosterCard, SeasonCard } from "./plexCards";
import type { PlexCard, PlexSeasonDetail } from "../lib/api"; 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); afterEach(cleanup);
const watchedEpisode: PlexCard = { const watchedEpisode: PlexCard = {
@@ -4,11 +4,21 @@
// React Compiler memoizes a call on its ARGUMENTS — `formatRuntime(card.duration_seconds)` stayed // 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. // "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 { 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 { setLanguage } from "../i18n";
import { PlexPosterCard } from "./plexCards"; import { PlexPosterCard } from "./plexCards";
import type { PlexCard } from "../lib/api"; 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(() => { afterEach(() => {
cleanup(); cleanup();
setLanguage("en"); setLanguage("en");
+45 -17
View File
@@ -2,10 +2,14 @@ import { useTranslation } from "react-i18next";
import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react"; import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import { type ReactNode, useRef } from "react"; 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 { formatDecimal, formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel"; import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; 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 { navItemAttrs } from "../lib/listNav";
import { endsTextSelection, movedPastSlop } from "../lib/textSelection"; import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions"; import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
@@ -30,8 +34,9 @@ import { useMediaPreview } from "./ui/useMediaPreview";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
import { cardImage } from "../lib/plexImage"; import { cardImage } from "../lib/plexImage";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared // Presentational Plex cards + strips, split out of PlexBrowse (prop-driven, no shared state; the one
// state). Rendered by the browse grid, the person page, and the series show/season views. // 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; // 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 // on hover its picture carries the everyday toggles (watched, add to playlist) and the "…" that
@@ -74,7 +79,7 @@ function HoverBar({
onToggleWatched?: () => void; onToggleWatched?: () => void;
addLabel: string; addLabel: string;
onAdd?: () => void; 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; onInfo?: () => void;
more: More; 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 /** 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. */ * handed the card the title NAMES — the show, for an episode standing as its show. */
onPage?: (target: PlexCard) => void; 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; onRestart?: () => void;
onToggleWatched?: (c: PlexCard) => void; onToggleWatched?: (c: PlexCard) => void;
/** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */ /** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */
@@ -296,11 +301,12 @@ export function PlexPosterCard({
wide?: boolean; wide?: boolean;
/** On a shelf (a horizontal strip): a one-line title (ui/mediaCardStyles). */ /** On a shelf (a horizontal strip): a one-line title (ui/mediaCardStyles). */
shelf?: boolean; shelf?: boolean;
/** A SHOW whose click plays this episode (Home's shelves: a pinned or started show plays on). The /** A SHOW's episode, resolved by the surface (Home's shelves carry it): the watched toggle and the
* picture then draws its Play, the watched toggle and the add act on the episode, the meta line * add act on it and the meta line names it. Without it a show still plays — the surface's `onOpen`
* names it, and an ⓘ (`onInfo`) opens the show. */ * 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; 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; onInfo?: () => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -315,9 +321,21 @@ export function PlexPosterCard({
const titled: PlexCard = asShow const titled: PlexCard = asShow
? { ...card, type: "show", id: card.show_id!, title: card.show_title! } ? { ...card, type: "show", id: card.show_id!, title: card.show_title! }
: card; : card;
const ep = card.type === "show" ? (next ?? null) : null; const isShow = card.type === "show";
// movies/episodes play; a show opens its drill-down — unless it plays on (`next`) const ep = isShow ? (next ?? null) : null;
const isPlayable = card.type !== "show" || !!ep; // 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. // What the hover row acts on: the episode a show would play, else the card itself.
const acted = ep ?? card; const acted = ep ?? card;
const watched = card.status === "watched"; const watched = card.status === "watched";
@@ -329,6 +347,9 @@ export function PlexPosterCard({
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined; const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
// The next episode by name: its numbers, or its title when Plex has none for it. // The next episode by name: its numbers, or its title when Plex has none for it.
const nextName = ep && episodeName(ep); 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 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). // a movie from a show (the film/TV glyph it replaces said the same thing a second time).
const corner = const corner =
@@ -363,10 +384,10 @@ export function PlexPosterCard({
label={name} label={name}
onOpen={onOpen} onOpen={onOpen}
onRestart={onRestart} onRestart={onRestart}
plays={isPlayable} plays
small={shelf && landscape} small={shelf && landscape}
pct={pct} pct={pct}
started={plexStarted(acted)} started={started}
bar={ bar={
<HoverBar <HoverBar
watched={actedWatched} watched={actedWatched}
@@ -382,7 +403,7 @@ export function PlexPosterCard({
onToggleWatched={toggle} onToggleWatched={toggle}
addLabel={t(plexAddLabelKey(acted))} addLabel={t(plexAddLabelKey(acted))}
onAdd={add} onAdd={add}
onInfo={ep ? onInfo : undefined} onInfo={showPage}
more={m} more={m}
/> />
} }
@@ -432,6 +453,7 @@ export function PlexPosterCard({
{rating} {rating}
</span> </span>
} }
note={upNext && t("plex.nextUp", { name: episodeLine(upNext) })}
summary={card.summary} summary={card.summary}
/> />
</> </>
@@ -637,12 +659,18 @@ export function CastRow({
export function RelatedStrip({ export function RelatedStrip({
related, related,
onOpen, onOpen,
onPlay,
onRestart,
menuProps, menuProps,
menuButtonProps, menuButtonProps,
openId = null, openId = null,
}: { }: {
related: PlexCard[]; related: PlexCard[];
/** A related show's page (its title and its ⓘ). */
onOpen: (c: PlexCard) => void; 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). */ /** The page's Plex menu (a related show is a Plex card like any other: DESIGN.md C1). */
menuProps?: (c: PlexCard) => Record<string, unknown>; menuProps?: (c: PlexCard) => Record<string, unknown>;
menuButtonProps?: (c: PlexCard) => Record<string, unknown>; menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
@@ -663,8 +691,8 @@ export function RelatedStrip({
card={r} card={r}
shelf shelf
navIndex={i} navIndex={i}
onOpen={() => onOpen(r)} onOpen={() => onPlay(r)}
// A related title is a show: its page is what both its picture and its title open. onRestart={() => onRestart(r)}
onPage={onOpen} onPage={onOpen}
menuProps={menuProps} menuProps={menuProps}
menuButtonProps={menuButtonProps} menuButtonProps={menuButtonProps}
@@ -78,6 +78,7 @@ function renderShow(onFilter = vi.fn(), onOpenPerson = vi.fn()) {
onPlay={() => {}} onPlay={() => {}}
onOpenSeason={() => {}} onOpenSeason={() => {}}
onOpenShow={() => {}} onOpenShow={() => {}}
onPlayShow={() => {}}
onFilter={onFilter} onFilter={onFilter}
onOpenPerson={onOpenPerson} onOpenPerson={onOpenPerson}
/> />
@@ -132,6 +132,7 @@ describe("the restart button on a show and a season", () => {
onPlay={onPlay} onPlay={onPlay}
onOpenSeason={() => {}} onOpenSeason={() => {}}
onOpenShow={() => {}} onOpenShow={() => {}}
onPlayShow={() => {}}
/>, />,
); );
const btn = await screen.findByRole("button", { name: /plex\.series\.showFromStart/ }); const btn = await screen.findByRole("button", { name: /plex\.series\.showFromStart/ });
@@ -154,6 +155,7 @@ describe("the show page's summary", () => {
onPlay={() => {}} onPlay={() => {}}
onOpenSeason={() => {}} onOpenSeason={() => {}}
onOpenShow={() => {}} onOpenShow={() => {}}
onPlayShow={() => {}}
/>, />,
); );
fireEvent.click(await screen.findByText("plex.textMore")); fireEvent.click(await screen.findByText("plex.textMore"));
+13 -1
View File
@@ -422,6 +422,7 @@ export function PlexShowView({
onPlay, onPlay,
onOpenSeason, onOpenSeason,
onOpenShow, onOpenShow,
onPlayShow,
onFilter, onFilter,
onOpenPerson, onOpenPerson,
}: { }: {
@@ -430,6 +431,8 @@ export function PlexShowView({
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void; onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
onOpenSeason: (seasonId: string) => void; onOpenSeason: (seasonId: string) => void;
onOpenShow: (id: 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; onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void; onOpenPerson?: (name: string) => void;
}) { }) {
@@ -446,7 +449,14 @@ export function PlexShowView({
const [collOpen, setCollOpen] = useState(false); const [collOpen, setCollOpen] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const relatedMenu = useContextMenu<PlexCard>(); 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 seasonMenu = useContextMenu<PlexSeasonDetail>();
const copy = useCopyItems(); const copy = useCopyItems();
@@ -743,6 +753,8 @@ export function PlexShowView({
<RelatedStrip <RelatedStrip
related={d.related} related={d.related}
onOpen={(c) => onOpenShow(c.id)} onOpen={(c) => onOpenShow(c.id)}
onPlay={(c) => onPlayShow(c, false)}
onRestart={(c) => onPlayShow(c, true)}
menuProps={relatedMenu.triggerProps} menuProps={relatedMenu.triggerProps}
menuButtonProps={relatedMenu.buttonProps} menuButtonProps={relatedMenu.buttonProps}
openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null} openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null}
@@ -18,12 +18,15 @@ export function MediaPreview({
preview, preview,
title, title,
facts, facts,
note,
summary, summary,
}: { }: {
preview: ReturnType<typeof useMediaPreview>; preview: ReturnType<typeof useMediaPreview>;
title: string; title: string;
/** One line under the title: year, length, score. */ /** One line under the title: year, length, score. */
facts?: ReactNode; facts?: ReactNode;
/** A line of its own under the facts (a show's "Next: S6·E16 — Title"). */
note?: ReactNode;
summary?: string | null; summary?: string | null;
}) { }) {
return ( return (
@@ -36,6 +39,7 @@ export function MediaPreview({
> >
<p className="text-sm font-semibold leading-snug text-fg">{title}</p> <p className="text-sm font-semibold leading-snug text-fg">{title}</p>
{facts && <div className="mt-0.5 text-xs text-muted">{facts}</div>} {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 && ( {summary && (
<p className="mt-2 line-clamp-8 whitespace-pre-line text-xs leading-relaxed text-fg"> <p className="mt-2 line-clamp-8 whitespace-pre-line text-xs leading-relaxed text-fg">
{summary} {summary}
@@ -19,6 +19,8 @@ export function useMediaPreview(enabled = true) {
}); });
return { return {
popover: pop, 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 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). */ * the root's own (its menu's right-click and long-press). */
rootProps: (own: HTMLAttributes<HTMLDivElement> & Record<string, unknown> = {}) => ({ 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();
});
});
+37
View File
@@ -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);
};
}
+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. // the everyday toggles (watched, add to playlist) on hover; details, copy and the pin live here.
export interface PlexActionHandlers { export interface PlexActionHandlers {
/** Play it: a show, the episode it would play (the surface resolves it). */
onOpen: (c: PlexCard) => void; 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; onInfo?: (c: PlexCard) => void;
onToggleWatched?: (c: PlexCard) => void; onToggleWatched?: (c: PlexCard) => void;
/** Play from the start instead of the resume point (C5). Where given, a started title lists it /** 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 => export const plexAddLabelKey = (c: PlexCard): string =>
c.type === "episode" ? "plex.playlist.addEpisode" : "plex.playlistAdd.manage"; 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"; 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 /** 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[] => { ): ActionMenuItem[] => {
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers; const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
const playable = isPlexPlayable(c); const playable = isPlexPlayable(c);
const isShow = c.type === "show";
const watched = c.status === "watched"; 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 [ return [
...(play ...(play
? [ ? [
{ {
id: "play", id: "play",
label: !playable ? t("plex.openShow") : started ? t("card.continue") : t("plex.play"), label: started ? t("card.continue") : t("plex.play"),
icon: playable ? <Play className={icon} /> : <Tv2 className={icon} />, icon: <Play className={icon} />,
onSelect: () => onOpen(c), onSelect: () => onOpen(c),
}, },
// The same words and glyph as the video card's menu (useVideoActions). // 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", id: "details",
label: t("plex.info.title"), label: isShow ? t("plex.openShow") : t("plex.info.title"),
icon: <Info className={icon} />, icon: isShow ? <Tv2 className={icon} /> : <Info className={icon} />,
separatorBefore: true, separatorBefore: true,
onSelect: () => onInfo(c), 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) : []), ...(withPin ? pin.item(pinPlex(c), c.title) : []),
]; ];
}; };
+1
View File
@@ -127,6 +127,7 @@
"episodeLine": "S{{season}}·E{{episode}} · {{title}}", "episodeLine": "S{{season}}·E{{episode}} · {{title}}",
"seasonTitle": "Season {{n}}", "seasonTitle": "Season {{n}}",
"specials": "Specials", "specials": "Specials",
"playShowFailed": "Couldn't find the episode to play — please try again",
"watchedFailed": "Couldn't update the watched state — please try again", "watchedFailed": "Couldn't update the watched state — please try again",
"seasons_one": "{{count}} season", "seasons_one": "{{count}} season",
"seasons_other": "{{count}} seasons", "seasons_other": "{{count}} seasons",
+1
View File
@@ -127,6 +127,7 @@
"episodeLine": "S{{season}}·E{{episode}} · {{title}}", "episodeLine": "S{{season}}·E{{episode}} · {{title}}",
"seasonTitle": "{{n}}. évad", "seasonTitle": "{{n}}. évad",
"specials": "Különkiadások", "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", "watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra",
"seasons_one": "{{count}} évad", "seasons_one": "{{count}} évad",
"seasons_other": "{{count}} évad", "seasons_other": "{{count}} évad",
+4
View File
@@ -644,6 +644,10 @@ export const plexApi = {
}, },
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> => plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`), 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. // 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 }> => plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, { req(`/api/plex/show/${encodeURIComponent(rk)}/state`, {
+4
View File
@@ -134,6 +134,10 @@ export const qk = {
showId !== undefined showId !== undefined
? (["plex-show", showId, ...(lang ? [lang] : [])] as const) ? (["plex-show", showId, ...(lang ? [lang] : [])] as const)
: (["plex-show"] 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, 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 // 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. // that steers the background extraction, not part of the track's identity.