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:
2026-09-27 14:44:38 +02:00
parent efa4aeeeda
commit 70f98b352f
4 changed files with 180 additions and 20 deletions
+51 -6
View File
@@ -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>
);
}