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:
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-2
@@ -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 |
|
||||
|
||||
@@ -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