From 70f98b352f80358983400be1d14afb4977ea78b6 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 14:44:38 +0200 Subject: [PATCH] fix(plex): a collection sibling is a full Plex card, and the player's info overlay plays it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- frontend/src/components/PlexInfo.dom.test.tsx | 81 ++++++++++++++++--- frontend/src/components/PlexInfo.tsx | 57 +++++++++++-- .../PlexPlayer.controls.dom.test.tsx | 41 ++++++++++ frontend/src/components/PlexPlayer.tsx | 21 ++++- 4 files changed, 180 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx index 2cbe06a0..2ae35f1c 100644 --- a/frontend/src/components/PlexInfo.dom.test.tsx +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -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(); + wrap(); 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(); + wrap(); 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(); + wrap(); 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( {}} />); + wrap( {}} />); 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(); + wrap(); 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(); + wrap(); 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(); + wrap(); 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(); + wrap(); 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(); + wrap(); 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(); + wrap(); 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(); + wrap( + , + ); 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(); + 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("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)); + }); +}); diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 714cfaff..e4468862 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -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) => 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(); + const toggleColWatched = (card: PlexCard) => + toggleWatchState(card, t, { + optimistic: (next) => + qc.setQueriesData({ 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({ )} - + {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} /> ))} @@ -857,6 +901,7 @@ export default function PlexInfo({ onClose={() => setPlaylistOpen(false)} /> )} + ); } diff --git a/frontend/src/components/PlexPlayer.controls.dom.test.tsx b/frontend/src/components/PlexPlayer.controls.dom.test.tsx index ec26ecb5..2d4b641b 100644 --- a/frontend/src/components/PlexPlayer.controls.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.controls.dom.test.tsx @@ -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") } }); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 968492a9..63413c58 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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(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) => { + (otherId: string | null | undefined, watchedWrite?: Promise, 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()} > - setInfoOpen(false)} /> + setInfoOpen(false)} + // A collection sibling plays right here (E28 review D1), as Next would. + onPlayItem={(other, fromZero) => { + setInfoOpen(false); + go(other, undefined, fromZero); + }} + />