fix(plex): a collection sibling is a full Plex card, and the player's info overlay plays it
- PlexInfo's collection strip gives each card the related strip's action set: the "…" menu (with Restart for a touch reader), the watched toggle and the playlist add. - `onPlayItem` is required, so no variant draws a Play that does nothing; the player's info overlay plays the sibling in the running player (`go(id, _, fromZero)` for its Restart).
This commit is contained in:
@@ -25,6 +25,8 @@ import type { PlexItemDetail } from "../lib/api";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const detail: PlexItemDetail = {
|
||||
id: "m1",
|
||||
kind: "movie",
|
||||
@@ -75,7 +77,7 @@ function wrap(ui: React.ReactElement) {
|
||||
|
||||
describe("PlexInfo enriched detail (page)", () => {
|
||||
it("renders the multi-source ratings row (IMDb / TMDb / audience)", () => {
|
||||
wrap(<PlexInfo detail={detail} variant="page" />);
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" />);
|
||||
expect(screen.getByText("IMDb")).toBeTruthy();
|
||||
expect(screen.getByText("7.8")).toBeTruthy();
|
||||
expect(screen.getByText(/TMDb/)).toBeTruthy(); // "TMDb · 1.2K" (vote count compacted)
|
||||
@@ -85,7 +87,7 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
});
|
||||
|
||||
it("renders the facts panel with budget / revenue / studios / countries", () => {
|
||||
wrap(<PlexInfo detail={detail} variant="page" />);
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" />);
|
||||
expect(screen.getByText("plex.info.facts.title")).toBeTruthy();
|
||||
expect(screen.getByText("plex.info.facts.budget")).toBeTruthy();
|
||||
expect(screen.getByText("$160M")).toBeTruthy();
|
||||
@@ -96,7 +98,7 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
|
||||
it("keyword chips filter the library on click", () => {
|
||||
const onFilter = vi.fn();
|
||||
wrap(<PlexInfo detail={detail} variant="page" onFilter={onFilter} />);
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={onFilter} />);
|
||||
fireEvent.click(screen.getByText("heist"));
|
||||
expect(onFilter).toHaveBeenCalledWith({ keywords: ["heist"] });
|
||||
});
|
||||
@@ -104,7 +106,7 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
// E28: every value that filters says so on hover — the genre, certificate and studio had no hint
|
||||
// while the keyword and the year did — and a value filters in the facts panel as in the header.
|
||||
it("every filtering value carries a filter hint", () => {
|
||||
wrap(<PlexInfo detail={detail} variant="page" onFilter={() => {}} />);
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={() => {}} />);
|
||||
for (const v of ["heist", "Crime", "R", "Studio A"])
|
||||
for (const el of screen.getAllByText(v))
|
||||
expect(el.closest("[title]")?.getAttribute("title")).toBe(`plex.info.filterValue:${v}`);
|
||||
@@ -112,7 +114,7 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
|
||||
it("the facts panel's year and certificate filter like the header's", () => {
|
||||
const onFilter = vi.fn();
|
||||
wrap(<PlexInfo detail={detail} variant="page" onFilter={onFilter} />);
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={onFilter} />);
|
||||
const [, factsCert] = screen.getAllByText("R");
|
||||
fireEvent.click(factsCert!);
|
||||
expect(onFilter).toHaveBeenLastCalledWith({ contentRatings: ["R"] });
|
||||
@@ -123,7 +125,7 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
|
||||
it("crew members open the person page on click", () => {
|
||||
const onOpenPerson = vi.fn();
|
||||
wrap(<PlexInfo detail={detail} variant="page" onOpenPerson={onOpenPerson} />);
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onOpenPerson={onOpenPerson} />);
|
||||
expect(screen.getByText("plex.info.crew")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Jane Director"));
|
||||
expect(onOpenPerson).toHaveBeenCalledWith("Jane Director");
|
||||
@@ -132,7 +134,7 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
it("RatingsRow: with no IMDb score, the audience score becomes the filterable one", () => {
|
||||
const onFilter = vi.fn();
|
||||
const noImdb = { ...detail, imdb_rating: null, imdb_id: null, imdb_url: null };
|
||||
wrap(<PlexInfo detail={noImdb} variant="page" onFilter={onFilter} />);
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={noImdb} variant="page" onFilter={onFilter} />);
|
||||
expect(screen.queryByText("IMDb")).toBeNull();
|
||||
// audience (8.4) is now the primary/filterable score → clicking it filters ratingMin.
|
||||
fireEvent.click(screen.getByText("8.4"));
|
||||
@@ -141,14 +143,14 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
|
||||
it("RatingsRow: suppresses the duplicate audience chip when it equals the IMDb score", () => {
|
||||
const dupe = { ...detail, rating: 7.8 }; // audience == imdb_rating (7.8)
|
||||
wrap(<PlexInfo detail={dupe} variant="page" />);
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={dupe} variant="page" />);
|
||||
expect(screen.getByText("IMDb")).toBeTruthy();
|
||||
expect(screen.queryByText("plex.info.audience")).toBeNull(); // duplicate audience chip suppressed
|
||||
expect(screen.getAllByText("7.8")).toHaveLength(1); // the score shows once (IMDb only)
|
||||
});
|
||||
|
||||
it("the overlay variant stays lean (no facts / keywords / crew panels)", () => {
|
||||
wrap(<PlexInfo detail={detail} variant="overlay" />);
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="overlay" />);
|
||||
expect(screen.queryByText("plex.info.facts.title")).toBeNull();
|
||||
expect(screen.queryByText("plex.info.keywords")).toBeNull();
|
||||
expect(screen.queryByText("plex.info.crew")).toBeNull();
|
||||
@@ -186,7 +188,7 @@ describe("PlexInfo layout B (page)", () => {
|
||||
};
|
||||
|
||||
it("stacks the collection, the crew and the cast in the main column, the facts beside it", () => {
|
||||
wrap(<PlexInfo detail={full} variant="page" />);
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
|
||||
const coll = heading("The Collection");
|
||||
const crew = heading("plex.info.crew");
|
||||
const cast = heading("plex.info.cast");
|
||||
@@ -205,7 +207,13 @@ describe("PlexInfo layout B (page)", () => {
|
||||
});
|
||||
|
||||
it("with nothing for the main column, the sidebar's sections take the full width", () => {
|
||||
wrap(<PlexInfo detail={{ ...detail, tmdb: { ...detail.tmdb!, crew: [] } }} variant="page" />);
|
||||
wrap(
|
||||
<PlexInfo
|
||||
onPlayItem={noop}
|
||||
detail={{ ...detail, tmdb: { ...detail.tmdb!, crew: [] } }}
|
||||
variant="page"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("heading", { name: "plex.info.crew" })).toBeNull();
|
||||
const side = column(heading("plex.info.facts.title"));
|
||||
expect(side).toBe(column(heading("plex.info.keywords")));
|
||||
@@ -213,3 +221,54 @@ describe("PlexInfo layout B (page)", () => {
|
||||
expect([...side.parentElement!.children]).toEqual([side]);
|
||||
});
|
||||
});
|
||||
|
||||
// E28 review D1 + D2: a collection sibling is a Plex card like any other — its "…" menu, its watched
|
||||
// toggle and its playlist add, as on the related strip — where it used to be a Play and nothing else.
|
||||
describe("a collection strip's card", () => {
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
const withColl: PlexItemDetail = {
|
||||
...detail,
|
||||
collections: [
|
||||
{
|
||||
id: "c1",
|
||||
title: "The Collection",
|
||||
source: "plex",
|
||||
items: [
|
||||
{
|
||||
id: "m2",
|
||||
type: "movie",
|
||||
title: "Sequel",
|
||||
thumb: "/api/plex/image/m2",
|
||||
status: "in_progress",
|
||||
position_seconds: 60,
|
||||
duration_seconds: 6000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("carries the card's whole action set, the Restart in its menu too", async () => {
|
||||
const onPlayItem = vi.fn();
|
||||
wrap(<PlexInfo detail={withColl} variant="page" onPlayItem={onPlayItem} />);
|
||||
const strip = screen.getByRole("heading", { name: "The Collection" }).parentElement!
|
||||
.parentElement!;
|
||||
const named = (n: string) =>
|
||||
[...strip.querySelectorAll("button")].find((b) => b.getAttribute("aria-label") === n);
|
||||
expect(named("plex.markWatched")).toBeDefined();
|
||||
expect(named("plex.playlistAdd.manage")).toBeDefined();
|
||||
const more = strip.querySelector<HTMLButtonElement>("button[aria-haspopup]");
|
||||
expect(more).not.toBeNull();
|
||||
fireEvent.click(more!);
|
||||
const restart = await screen.findAllByRole("menuitem", { hidden: true });
|
||||
fireEvent.click(restart.find((r) => r.textContent === "card.restartTitle")!);
|
||||
await vi.waitFor(() => expect(onPlayItem).toHaveBeenCalledWith("m2", true));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
||||
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
||||
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
||||
@@ -24,6 +24,10 @@ import { IconLink } from "./ui/LinkButton";
|
||||
import { PageHeading } from "./ui/Headings";
|
||||
import clsx from "clsx";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import { PlexMenu } from "./PlexMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { usePlexCardActions } from "./usePlexCardActions";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
|
||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||
@@ -346,8 +350,10 @@ type Props = {
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
// When provided, a cast member's photo/name opens their filmography page instead of filtering.
|
||||
onOpenPerson?: (name: string) => void;
|
||||
// Play a sibling title from a collection strip (opens its player).
|
||||
onPlayItem?: (id: string, fromStart?: boolean) => void;
|
||||
// Play a sibling title from a collection strip: the page opens its player, the overlay plays it
|
||||
// in the running one. Required, so no variant draws a collection card whose Play does nothing
|
||||
// (E28 review D1).
|
||||
onPlayItem: (id: string, fromStart?: boolean) => void;
|
||||
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
|
||||
library?: string;
|
||||
};
|
||||
@@ -416,6 +422,39 @@ export default function PlexInfo({
|
||||
onStateChange?.();
|
||||
};
|
||||
|
||||
// A collection sibling is a Plex card like any other (E28 review D2): the related strip's menu, its
|
||||
// watched toggle and its playlist add. The flip lands on this item's cached detail, whose strip it
|
||||
// is; the resync corrects it to the truth either way.
|
||||
const colMenu = useContextMenu<PlexCard>();
|
||||
const toggleColWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
optimistic: (next) =>
|
||||
qc.setQueriesData<PlexItemDetail>({ queryKey: qk.plexItem(detail.id) }, (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
collections: old.collections?.map((col) => ({
|
||||
...col,
|
||||
items: col.items.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
|
||||
})),
|
||||
}
|
||||
: old,
|
||||
),
|
||||
resync: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexItem(card.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
},
|
||||
});
|
||||
const colActions = usePlexCardActions(
|
||||
{
|
||||
onOpen: (c) => onPlayItem(c.id),
|
||||
onRestart: (c) => onPlayItem(c.id, true),
|
||||
onToggleWatched: toggleColWatched,
|
||||
},
|
||||
colMenu.open,
|
||||
);
|
||||
|
||||
const cast = showCast ? detail.cast : [];
|
||||
const overlay = variant === "overlay";
|
||||
const mutedCls = overlay ? "text-white/70" : "text-muted";
|
||||
@@ -710,7 +749,7 @@ export default function PlexInfo({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
|
||||
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav>
|
||||
{col.items.map((m, i) => (
|
||||
// The one media card, as on every other strip (E28 S3h). A grid cell, so the
|
||||
// card fills the slot the carousel stretches to its tallest card.
|
||||
@@ -719,8 +758,13 @@ export default function PlexInfo({
|
||||
card={{ ...m, type: "movie" }}
|
||||
shelf
|
||||
navIndex={i}
|
||||
onOpen={() => onPlayItem?.(m.id)}
|
||||
onRestart={onPlayItem && (() => onPlayItem(m.id, true))}
|
||||
onOpen={() => onPlayItem(m.id)}
|
||||
onRestart={() => onPlayItem(m.id, true)}
|
||||
onToggleWatched={toggleColWatched}
|
||||
onAdd={colActions.add}
|
||||
menuProps={colMenu.triggerProps}
|
||||
menuButtonProps={colMenu.buttonProps}
|
||||
menuOpen={colMenu.open && colMenu.target?.id === m.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -857,6 +901,7 @@ export default function PlexInfo({
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<PlexMenu menu={colMenu} actions={colActions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -312,6 +312,47 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E28 review D1: the info overlay drew its collection siblings with a Play that did nothing — the
|
||||
// overlay had no way to play one. It plays it in this player, at its resume point or from the start.
|
||||
describe("a collection sibling in the info overlay", () => {
|
||||
const withSibling = (id: string): PlexItemDetail => ({
|
||||
...item(id),
|
||||
cast: [],
|
||||
genres: [],
|
||||
directors: [],
|
||||
collections: [
|
||||
{
|
||||
id: "c1",
|
||||
title: "Saga",
|
||||
source: "plex",
|
||||
items: [
|
||||
{
|
||||
id: "B",
|
||||
type: "movie",
|
||||
title: "Title B",
|
||||
thumb: "",
|
||||
status: "in_progress",
|
||||
position_seconds: 300,
|
||||
duration_seconds: 3000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("plays its Restart in this player, from the start", { timeout: 10000 }, async () => {
|
||||
const { session } = mount({ details: { A: withSibling("A"), B: item("B", 300) } });
|
||||
await playingAt("/media/A", 100);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
||||
fireEvent.click(await menuItem("plex.player.infoBtn"));
|
||||
// The overlay is a lazy chunk: its first import takes a while under the test runner.
|
||||
const restart = await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 });
|
||||
fireEvent.click(restart);
|
||||
await waitFor(() => expect(lastStart(session)).toEqual(["B", 0]));
|
||||
expect(screen.queryByRole("heading", { name: "Saga" })).toBeNull(); // the overlay closed
|
||||
});
|
||||
});
|
||||
|
||||
describe("the C5 row", () => {
|
||||
it("has no Stop, Restart or ±10 s buttons, and keeps the rare tools behind the ⋯", async () => {
|
||||
mount({ details: { A: item("A") } });
|
||||
|
||||
@@ -623,6 +623,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// to that item (Back within 3 s of the next) resumes it like any other. A flag, not a consumed
|
||||
// prop: StrictMode replays this effect, and a consumed one resumed on the replay.
|
||||
const movedOnRef = useRef(false);
|
||||
// An item `go` was asked to play from its start (a collection sibling's Restart in the info
|
||||
// overlay); the opening item's own `fromStart` is the prop above.
|
||||
const startAtZeroRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!detail || detail.id !== id || startedForRef.current === id) return;
|
||||
startedForRef.current = id;
|
||||
@@ -637,7 +640,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
setAudioOrd(aOrd);
|
||||
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], subLang));
|
||||
const resume =
|
||||
detail.status !== "watched" && !(fromStart && !movedOnRef.current)
|
||||
detail.status !== "watched" &&
|
||||
!(fromStart && !movedOnRef.current) &&
|
||||
startAtZeroRef.current !== id
|
||||
? detail.position_seconds || 0
|
||||
: 0;
|
||||
void loadSession(resume);
|
||||
@@ -818,8 +823,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
);
|
||||
|
||||
const go = useCallback(
|
||||
(otherId: string | null | undefined, watchedWrite?: Promise<unknown>) => {
|
||||
(otherId: string | null | undefined, watchedWrite?: Promise<unknown>, fromZero = false) => {
|
||||
if (otherId) {
|
||||
startAtZeroRef.current = fromZero ? otherId : null;
|
||||
// A played-through item settles as watched, with no checkpoint at the credits (see
|
||||
// usePlexProgress's `finish`) — said BEFORE the pause below, whose own checkpoint on the
|
||||
// 'pause' event can land before the player has re-rendered for the next item. The write is
|
||||
@@ -1840,7 +1846,16 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<PlexInfo detail={detail} variant="overlay" onClose={() => setInfoOpen(false)} />
|
||||
<PlexInfo
|
||||
detail={detail}
|
||||
variant="overlay"
|
||||
onClose={() => setInfoOpen(false)}
|
||||
// A collection sibling plays right here (E28 review D1), as Next would.
|
||||
onPlayItem={(other, fromZero) => {
|
||||
setInfoOpen(false);
|
||||
go(other, undefined, fromZero);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user