diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 454f5098..a3a3717a 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -33,7 +33,7 @@ import { type ActionMenuItem } from "./ui/ActionMenu"; import { Button } from "./ui/Button"; import { Carousel } from "./ui/Carousel"; import { ContextMenu } from "./ui/ContextMenu"; -import { useContextMenu } from "./ui/useContextMenu"; +import { useContextMenu, useItemTriggers } from "./ui/useContextMenu"; import SegmentedToggle from "./ui/SegmentedToggle"; import { LoadingState, StateMessage } from "./ui/QueryState"; import VideoCard from "./VideoCard"; @@ -158,6 +158,10 @@ function Rail({ : pin.item(pinTargetForCard(target), target.title)), ]; + // One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the + // card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile. + const menuFor = useItemTriggers(cards, menu.triggerProps); + // An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when // you have not pinned anything teaches nothing about how to fill it. if (!cards.length) { @@ -190,10 +194,7 @@ function Rail({ onState={onState} onToggleSave={onToggleSave} onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)} - // Bound to the UNIFIED card, not the native payload the card component holds: the - // menu acts on the shelf ENTRY (its pin id, its place in the order), which the - // `Video` inside it does not carry. - menuProps={() => menu.triggerProps(c)} + menuProps={menuFor[i]} /> ) : c.plex ? ( onPlay(c)} onInfo={() => onInfo(c)} onToggleWatched={onToggleWatched} - menuProps={() => menu.triggerProps(c)} + menuProps={menuFor[i]} /> ) : ( diff --git a/frontend/src/components/comfortPin.tsx b/frontend/src/components/comfortPin.tsx index 0ae04392..888b22b1 100644 --- a/frontend/src/components/comfortPin.tsx +++ b/frontend/src/components/comfortPin.tsx @@ -11,11 +11,12 @@ import { type ActionMenuItem } from "./ui/ActionMenu"; // E24 S3 — "put this on my comfort shelf", from wherever you happen to be looking at the thing. // -// The shelf is the Home surface's object, but the GESTURE belongs on every card and row in the app: -// you decide something is comfort media while you are watching it, not while you are on a page that -// already lists it. So this file is the one place the pin lives, and every surface composes the same -// `ActionMenuItem` into the menu it already has (`Playlists`, `PlaylistsRail`) or mounts the -// `ComfortPinMenu` wrapper when it has none. +// The shelf is the Home surface's object, but the GESTURE belongs wherever a pinnable thing is +// shown — a video, a channel, a playlist, a Plex show or movie: you decide something is comfort media +// while you are watching it. (Locked decision 3: an episode, a season and a person are not pin kinds, +// so their cards and strips carry no pin.) This file is the one place the pin lives; every surface +// composes the same `ActionMenuItem` into the menu it already has (`Playlists`, `PlaylistsRail`) or +// mounts the `ComfortPinMenu` wrapper when it has none. // // Feature layer, not `components/ui/`: it speaks a domain API slice, which the platform boundary // (eslint `no-restricted-imports`) forbids a `ui/` primitive from doing. The MENU SURFACE that diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index c196546e..67ec246e 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -412,9 +412,12 @@ export function CastStrip({ export function RelatedStrip({ related, onOpen, + menuProps, }: { related: PlexCard[]; onOpen: (id: string) => void; + /** The comfort-pin context menu's trigger (a related show is pinnable like any poster). */ + menuProps?: (c: PlexCard) => Record; }) { const { t } = useTranslation(); return ( @@ -428,8 +431,9 @@ export function RelatedStrip({