diff --git a/frontend/src/components/ComfortPinMenu.tsx b/frontend/src/components/ComfortPinMenu.tsx index 480f9723..e408e1ce 100644 --- a/frontend/src/components/ComfortPinMenu.tsx +++ b/frontend/src/components/ComfortPinMenu.tsx @@ -1,7 +1,6 @@ import { useTranslation } from "react-i18next"; -import { type PlexCard } from "../lib/api"; -import { pinPlex, useComfortPin, type PinTarget } from "./comfortPin"; +import { useComfortPin, type PinTarget } from "./comfortPin"; import { ContextMenu } from "./ui/ContextMenu"; import { type ContextMenuController } from "./ui/useContextMenu"; @@ -37,9 +36,3 @@ export function ComfortPinMenu({ /> ); } - -/** The Plex poster grids and rails — five of them, all pinning the same way. One component so the - * show-vs-item decision and the toast's name are written once rather than five times. */ -export function PlexPinMenu({ menu }: { menu: ContextMenuController }) { - return c.title} />; -} diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx index 3778bcdd..a0400a4f 100644 --- a/frontend/src/components/Home.dom.test.tsx +++ b/frontend/src/components/Home.dom.test.tsx @@ -700,8 +700,15 @@ describe("Home", () => { comfort: [], }); mount(); - fireEvent.click(await screen.findByLabelText("plex.info.title")); - expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie", replace: true }); + // Since E28 C1 the tile's ⓘ is its menu's "Media info" row, opened from the tile's "…". + fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!); + const info = screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === "plex.info.title"); + fireEvent.click(info!); + await waitFor(() => + expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie", replace: true }), + ); expect(setPage).toHaveBeenCalledWith("plex"); }); diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 46e4e9a7..657de2b4 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -39,6 +39,7 @@ import SegmentedToggle from "./ui/SegmentedToggle"; import { LoadingState, StateMessage } from "./ui/QueryState"; import VideoCard from "./VideoCard"; import { useVideoActions } from "./useVideoActions"; +import { usePlexCardActions } from "./usePlexCardActions"; import { PlexPosterCard } from "./plexCards"; import { useNavigationActions } from "./navigationContext"; import { usePlexActions } from "./plexContext"; @@ -126,6 +127,21 @@ function Rail({ // A video tile's own action list (E28 C1), the same one the feed's menu shows; the rail keeps its // Play row (it resolves `next`) and its shelf rows around it. const videoActions = useVideoActions({ onState, onToggleSave }, menu.open); + // A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does). + const plexActions = usePlexCardActions( + { + onOpen: (p) => { + const c = cards.find((x) => x.plex === p); + if (c) onPlay(c); + }, + onInfo: (p) => { + const c = cards.find((x) => x.plex === p); + if (c) onInfo(c); + }, + onToggleWatched, + }, + menu.open, + ); const target = menu.target; const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1; const items: ActionMenuItem[] = !target @@ -145,7 +161,11 @@ function Rail({ ? videoActions .items(target.video, menu.anchor, { pin: false }) .map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it)) - : []), + : target.plex + ? plexActions + .items(target.plex, { play: false, pin: false }) + .map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it)) + : []), ...(onMove && target.pin_id != null ? [ ...SHELF_MOVES.map(([to, key, Icon], i) => ({ @@ -222,9 +242,11 @@ function Rail({ card={c.plex} navIndex={i} onOpen={() => onPlay(c)} - onInfo={() => onInfo(c)} onToggleWatched={onToggleWatched} + onAdd={plexActions.add} menuProps={menuFor[i]} + menuButtonProps={buttonFor[i]} + menuOpen={menu.open && menu.target === c} /> ) : ( @@ -234,6 +256,7 @@ function Rail({ {videoActions.panel} + {plexActions.panel} ); } @@ -593,8 +616,9 @@ export default function Home() { [setJumpTo, setPage], ); - // The ⓘ affordance on a Plex tile: the item's own page in the Plex module, exactly where the same - // click goes from the Plex grid. A show tile's card click lands there too — a show is not playable. + // The "Media info" row of a Plex tile's menu: the item's own page in the Plex module, exactly where + // the same row goes from the Plex grid. A show tile's card click lands there too — a show is not + // playable. const onInfo = useCallback( (card: DiscoverCard) => { if (!card.plex) return; diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index a4042f89..4ad5e188 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -40,8 +40,9 @@ import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilter import { usePlex, usePlexActions } from "./plexContext"; import { usePublishPlexView } from "./plexViewContext"; import { EpisodeCard, PlexPosterCard } from "./plexCards"; -import { PlexPinMenu } from "./ComfortPinMenu"; +import { PlexMenu } from "./PlexMenu"; import { useContextMenu } from "./ui/useContextMenu"; +import { usePlexCardActions } from "./usePlexCardActions"; import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews"; import { dispatch, useKeyScope } from "../lib/keymap"; import NavGrid from "./ui/NavGrid"; @@ -139,7 +140,8 @@ export default function PlexBrowse() { }, [openPlaylist, sub, setPlaylistOpen]); // The header search typeahead picked a title → open it the same way a card click would (a movie's - // info page like the card's ⓘ, a show's drill-down), then clear the hand-off so it doesn't re-open. + // info page like the card menu's "Media info", a show's drill-down), then clear the hand-off so it + // doesn't re-open. useEffect(() => { if (jumpTo != null) { if (jumpTo.type !== "show") infoScrollRef.current = 0; // a jumped-to info page starts at the top @@ -197,8 +199,9 @@ export default function PlexBrowse() { // makes it safe, and nothing here reads the value during render. // eslint-disable-next-line react-hooks/immutability const rememberInfoScroll = (px: number) => void (infoScrollRef.current = px); - // One comfort-pin menu for the titles grid (see ui/ContextMenu). - const pinMenu = useContextMenu(); + // One menu for the titles grid and one for the search's episodes (see ui/ContextMenu). + const menu = useContextMenu(); + const epMenu = useContextMenu(); const scroller = () => document.querySelector("main"); useLayoutEffect(() => { const el = scroller(); @@ -363,6 +366,14 @@ export default function PlexBrowse() { optimistic: (next) => optimisticStatus(card.id, next), resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }), }); + const actions = usePlexCardActions( + { onOpen: onCard, onInfo, onToggleWatched: toggleWatched }, + menu.open, + ); + const epActions = usePlexCardActions( + { onOpen: onCard, onToggleWatched: toggleWatched }, + epMenu.open, + ); // Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid. // Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person // (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed). @@ -642,9 +653,11 @@ export default function PlexBrowse() { navIndex={i} card={c} onOpen={() => onCard(c)} - onInfo={() => onInfo(c)} onToggleWatched={toggleWatched} - menuProps={pinMenu.triggerProps} + onAdd={actions.add} + menuProps={menu.triggerProps} + menuButtonProps={menu.buttonProps} + menuOpen={menu.open && menu.target === c} /> ))} @@ -663,6 +676,10 @@ export default function PlexBrowse() { withShowTitle onPlay={() => onCard(ep)} onToggleWatched={() => toggleWatched(ep)} + onAdd={() => epActions.add(ep)} + menuProps={epMenu.triggerProps} + menuButtonProps={epMenu.buttonProps} + menuOpen={epMenu.open && epMenu.target === ep} /> ))} @@ -671,7 +688,8 @@ export default function PlexBrowse() { )} - + +
{isFetchingNextPage && (

{t("plex.loading")}

diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 8a237d43..819c515b 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -15,8 +15,9 @@ import { type SortState } from "../lib/columnSort"; import { useConfirm } from "./confirmContext"; import { BackBtn, ActionBtn, DetailHero } from "./plexDetailUi"; import { PlexPosterCard } from "./plexCards"; -import { PlexPinMenu } from "./ComfortPinMenu"; +import { PlexMenu } from "./PlexMenu"; import { useContextMenu } from "./ui/useContextMenu"; +import { usePlexCardActions } from "./usePlexCardActions"; import PlexLibraryPicker from "./PlexLibraryPicker"; import { toggleWatchState } from "../lib/plexWatch"; import { LoadingState, StateMessage } from "./ui/QueryState"; @@ -66,8 +67,8 @@ export default function PlexCollectionView({ const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - // One comfort-pin menu for the member grid (see ui/ContextMenu). - const pinMenu = useContextMenu(); + // One menu for the member grid (see ui/ContextMenu). + const menu = useContextMenu(); const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; // The member order is a PREFERENCE (server-synced, adopted on login), not an ephemeral filter — the // same treatment the feed's view and the player's auto-advance get. It is deliberately its OWN key @@ -183,6 +184,10 @@ export default function PlexCollectionView({ qc.invalidateQueries({ queryKey: qk.plexCollections("detail", collectionId) }); }, }); + const actions = usePlexCardActions( + { onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched: toggleWatched }, + menu.open, + ); async function doRename() { const title = name.trim(); @@ -416,36 +421,48 @@ export default function PlexCollectionView({ />
- + {/* Two columns on a phone, like every other poster grid (≥140px a card): three left + an ~85px card whose title shared its line with the touch "…" (E28 C1). */} + {items.map((c, i) => ( -
- onOpenCard(c)} - onInfo={() => onInfoCard(c)} - onToggleWatched={toggleWatched} - menuProps={pinMenu.triggerProps} - /> - {canEdit && ( - doRemove(c)} - disabled={busy} - label={t("plex.collection.removeItem")} - className="absolute right-1.5 top-1.5 z-base" - > - - - )} -
+ onOpenCard(c)} + onToggleWatched={toggleWatched} + onAdd={actions.add} + menuProps={menu.triggerProps} + menuButtonProps={menu.buttonProps} + menuOpen={menu.open && menu.target === c} + /> ))}
)} )} - + + canEdit + ? [ + { + // A write to the shared Plex library: a menu row since E28 C1 (the card's hover + // row owns its corner now), still behind the confirmation in `doRemove`. + id: "collection-remove", + label: t("plex.collection.removeItem"), + icon: , + separatorBefore: true, + danger: true, + disabled: busy, + onSelect: () => void doRemove(c), + }, + ] + : [] + } + /> ); } diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index 1acd8ccb..8f46b235 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -10,8 +10,9 @@ import { toggleWatchState } from "../lib/plexWatch"; import { usePlex } from "./plexContext"; import { Carousel } from "./ui/Carousel"; import { PlexPosterCard } from "./plexCards"; -import { PlexPinMenu } from "./ComfortPinMenu"; +import { PlexMenu } from "./PlexMenu"; import { useContextMenu } from "./ui/useContextMenu"; +import { usePlexCardActions } from "./usePlexCardActions"; import { LoadingState, StateMessage } from "./ui/QueryState"; import NavGrid from "./ui/NavGrid"; import { IconButton } from "./ui/IconButton"; @@ -44,8 +45,12 @@ function CardRail({ onToggleWatched: (c: PlexCard) => void; action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button) }) { - // One comfort-pin menu per rail (see ui/ContextMenu), not one per poster. - const pinMenu = useContextMenu(); + // One menu per rail (see ui/ContextMenu), not one per poster. + const menu = useContextMenu(); + const actions = usePlexCardActions( + { onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched }, + menu.open, + ); const heading = action != null ? ( @@ -70,14 +75,16 @@ function CardRail({ card={c} navIndex={i} onOpen={() => onOpenCard(c)} - onInfo={() => onInfoCard(c)} onToggleWatched={onToggleWatched} - menuProps={pinMenu.triggerProps} + onAdd={actions.add} + menuProps={menu.triggerProps} + menuButtonProps={menu.buttonProps} + menuOpen={menu.open && menu.target === c} /> ))} - + ); } diff --git a/frontend/src/components/PlexMenu.dom.test.tsx b/frontend/src/components/PlexMenu.dom.test.tsx new file mode 100644 index 00000000..9d222d44 --- /dev/null +++ b/frontend/src/components/PlexMenu.dom.test.tsx @@ -0,0 +1,202 @@ +// @vitest-environment jsdom +// +// E28 C1: a Plex title's ONE action list. The poster's right-click and its "…" open the same menu, +// which holds what the card no longer shows (details, copy, the pin) beside the everyday toggles; +// the card's hover "add" and the menu's "Add to playlist" open the same dialog. +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { FloatingTree } from "@floating-ui/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import type { PlexCard } from "../lib/api"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); +vi.mock("../lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + api: { + ...actual.api, + comfortPins: () => Promise.resolve({ items: [] }), + plexPlaylists: () => Promise.resolve({ playlists: [] }), + }, + }; +}); +const copyText = vi.fn((_: string) => Promise.resolve(true)); +vi.mock("../lib/clipboard", () => ({ copyText: (s: string) => copyText(s) })); + +import { PlexMenu } from "./PlexMenu"; +import { PlexPosterCard } from "./plexCards"; +import { useContextMenu } from "./ui/useContextMenu"; +import { usePlexCardActions, type PlexActionHandlers } from "./usePlexCardActions"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// The surface is visibility:hidden in jsdom (floating-ui never positions): matched on text. +const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent); +const item = (label: string) => { + const found = screen + .queryAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === label); + if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`); + return found; +}; + +const card = (extra: Partial): PlexCard => ({ + id: "m1", + type: "movie", + title: "A film", + thumb: "", + status: "new", + ...extra, +}); + +function Grid({ + c, + handlers, + pin, + extra, +}: { + c: PlexCard; + handlers: PlexActionHandlers; + pin?: boolean; + extra?: Parameters[0]["extra"]; +}) { + const menu = useContextMenu(); + const actions = usePlexCardActions(handlers, menu.open); + return ( + <> + handlers.onOpen(c)} + onToggleWatched={handlers.onToggleWatched} + onAdd={actions.add} + menuProps={menu.triggerProps} + menuButtonProps={menu.buttonProps} + menuOpen={menu.open && menu.target === c} + /> + + + ); +} + +function setup(c: PlexCard, opts: Omit[0], "c" | "handlers"> = {}) { + const handlers = { onOpen: vi.fn(), onInfo: vi.fn(), onToggleWatched: vi.fn() }; + render( + + + + + , + ); + return { handlers }; +} +// The card root holds the menu's trigger props (right-click anywhere on it). +const rightClick = () => + fireEvent.contextMenu(screen.getByText("A film", { selector: ".line-clamp-2" })); +const moreButtons = () => screen.getAllByRole("button", { name: "card.menu.more" }); + +describe("a Plex title's menu", () => { + it("lists a movie's whole action list, in its groups", () => { + setup(card({})); + rightClick(); + expect(labels()).toEqual([ + "plex.play", + "plex.markWatched", + "plex.playlistAdd.manage", + "plex.info.title", + "common.copy.title", + "home.pin.add", + ]); + }); + + it("a show opens its page and has no watched, add or details rows", () => { + setup(card({ type: "show" })); + rightClick(); + expect(labels()).toEqual(["plex.openShow", "common.copy.title", "home.pin.add"]); + }); + + it("the card's '…' opens the same menu, and says so", () => { + setup(card({})); + const hoverMore = moreButtons()[0]!; + expect(hoverMore.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(hoverMore); + expect(labels()).toContain("plex.info.title"); + expect(moreButtons()[0]!.getAttribute("aria-expanded")).toBe("true"); + }); + + it("each row fires the handler the card's own controls do", async () => { + const { handlers } = setup(card({ status: "watched" })); + rightClick(); + fireEvent.click(item("plex.markUnwatched")); + // A row acts a task after the menu closes (closeThenAct), so the assertions wait for it. + await vi.waitFor(() => + expect(handlers.onToggleWatched).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })), + ); + rightClick(); + fireEvent.click(item("plex.info.title")); + await vi.waitFor(() => + expect(handlers.onInfo).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })), + ); + rightClick(); + fireEvent.click(item("plex.play")); + await vi.waitFor(() => + expect(handlers.onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })), + ); + }); + + it("copies the title inside the click", () => { + setup(card({})); + rightClick(); + fireEvent.click(item("common.copy.title")); + expect(copyText).toHaveBeenCalledWith("A film"); + }); + + it("the hover 'add' and the menu row open the same playlist dialog", async () => { + setup(card({})); + fireEvent.click(screen.getByRole("button", { name: "plex.playlistAdd.manage" })); + expect(await screen.findByText("plex.playlistAdd.title")).toBeTruthy(); + cleanup(); + setup(card({})); + rightClick(); + fireEvent.click(item("plex.playlistAdd.manage")); + expect(await screen.findByText("plex.playlistAdd.title")).toBeTruthy(); + }); + + it("an episode list carries no pin, and a host's own rows come last", async () => { + const remove = vi.fn(); + setup(card({ type: "episode" }), { + pin: false, + extra: (c) => [{ id: "x", label: "remove", danger: true, onSelect: () => remove(c.id) }], + }); + rightClick(); + expect(labels()).not.toContain("home.pin.add"); + // The row names what it adds, as the episode card's own hover button does. + expect(labels()).toContain("plex.playlist.addEpisode"); + expect(screen.getByRole("button", { name: "plex.playlist.addEpisode" })).toBeTruthy(); + expect(labels()[labels().length - 1]).toBe("remove"); + fireEvent.click(item("remove")); + await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1")); + }); +}); diff --git a/frontend/src/components/PlexMenu.tsx b/frontend/src/components/PlexMenu.tsx new file mode 100644 index 00000000..3549eaba --- /dev/null +++ b/frontend/src/components/PlexMenu.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from "react-i18next"; +import { type PlexCard } from "../lib/api"; +import { type ActionMenuItem } from "./ui/ActionMenu"; +import { ContextMenu } from "./ui/ContextMenu"; +import { type ContextMenuController } from "./ui/useContextMenu"; +import { type PlexCardActions } from "./usePlexCardActions"; + +/** A Plex poster list's one menu: every trigger — right-click, long-press, Shift+F10 and each card's + * "…" — opens it with the title's whole action list (`usePlexCardActions`, held by the list because + * its cards' hover "add" opens the same dialog). Render ONE per list, outside the cards. */ +export function PlexMenu({ + menu, + actions, + pin = true, + extra, +}: { + menu: ContextMenuController; + actions: PlexCardActions; + /** False for a list of episodes: an episode is not a comfort-shelf kind. */ + pin?: boolean; + /** The host list's own rows for a title, after the shared ones (a collection's "Remove"). */ + extra?: (c: PlexCard) => ActionMenuItem[]; +}) { + const { t } = useTranslation(); + const c = menu.target; + return ( + <> + + {actions.panel} + + ); +} diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 34ea9393..7d2eebd5 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -26,6 +26,7 @@ import { IconButton } from "./ui/IconButton"; import { Button } from "./ui/Button"; import { SURFACE } from "./ui/surfaces"; import { MoreButton } from "./ui/MoreButton"; +import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal"; /** The "…" that opens the video's action list, and whether its menu is the open one. */ type More = { props: Record; open: boolean }; @@ -100,10 +101,6 @@ function Actions({ ); } -// The thumbnail's hover chrome: shown on hover or keyboard focus on a mouse, removed on touch. -const HOVER_REVEAL = - "reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity"; - // Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click // keep the native "open youtube.com in a new tab" behavior via the underlying href. function openInApp( @@ -252,7 +249,7 @@ function Thumb({ className={clsx( "absolute bottom-1.5 right-1.5 bg-black/80 text-white text-2xs font-medium px-1.5 py-0.5 rounded-sm", // The hover row takes this corner; the duration steps aside while it shows. - bar && "transition-opacity group-hover:opacity-0 group-focus-within:opacity-0", + bar && HOVER_YIELD, )} > {formatDuration(video.duration_seconds)} diff --git a/frontend/src/components/plexCards.dom.test.tsx b/frontend/src/components/plexCards.dom.test.tsx index 90c039a0..ac38efea 100644 --- a/frontend/src/components/plexCards.dom.test.tsx +++ b/frontend/src/components/plexCards.dom.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom // // Plex card a11y: locks the accessible names on the icon-only card controls (the E4 a11y sweep). These -// buttons are icon-only (WatchedIcon / Info / ListPlus, no text), so their name comes solely from the +// buttons are icon-only (WatchedIcon / ListPlus / the "…", no text), so their name comes solely from the // 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"; @@ -42,23 +42,26 @@ const season = (status = "new"): PlexSeasonDetail => ({ afterEach(cleanup); describe("PlexPosterCard a11y", () => { - it("names the watched-toggle (mark-watched when unwatched) and the info button", () => { + it("names the hover row: the watched-toggle, add to playlist and the menu's '…'", () => { render( - , + ({})} + />, ); expect(screen.getByRole("button", { name: "plex.markWatched" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "plex.info.title" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "plex.playlistAdd.manage" })).toBeTruthy(); + // Two: the hover row's and its touch twin beside the title (CSS shows one per device). + expect(screen.getAllByRole("button", { name: "card.menu.more" })).toHaveLength(2); + // Details moved into the menu (E28 C1): the card has no ⓘ of its own any more. + expect(screen.queryByRole("button", { name: "plex.info.title" })).toBeNull(); }); it("names the watched-toggle as mark-unwatched when the movie is watched", () => { - render( - , - ); + render(); expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy(); }); @@ -67,7 +70,6 @@ describe("PlexPosterCard a11y", () => { , ); @@ -76,9 +78,7 @@ describe("PlexPosterCard a11y", () => { }); it("omits the scrim when the card has no synopsis or rating", () => { - render( - , - ); + render(); expect(screen.queryByText(/synopsis/i)).toBeNull(); }); }); @@ -111,7 +111,6 @@ describe("Plex cards in a keyboard-walkable list", () => { diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index f7dbfb6e..fbca598c 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -1,30 +1,38 @@ import { useTranslation } from "react-i18next"; -import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Star, Tv2 } from "lucide-react"; -import { type CSSProperties } from "react"; +import { CheckCircle2, Circle, Film, ListPlus, Play, Star, Tv2 } from "lucide-react"; +import { type CSSProperties, type ReactNode } from "react"; import { formatRuntime } from "../lib/format"; import { Carousel } from "./ui/Carousel"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; import { navItemAttrs } from "../lib/listNav"; import { PLAYABLE_TINT, playableClass } from "./plexPlayable"; +import { plexAddLabelKey } from "./usePlexCardActions"; import { IconButton } from "./ui/IconButton"; +import { MoreButton } from "./ui/MoreButton"; import { Pressable } from "./ui/Pressable"; +import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal"; import { SURFACE } from "./ui/surfaces"; // 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. +// +// E28 C1 (docs/DESIGN.md "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 +// opens the title's one action list (`usePlexCardActions`); on touch those toggles are gone and a 44px +// "…" sits beside the title. The list's menu is the one door every device has. -// The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal -// control (the watched-toggle). When that control is present it takes over on touch, so the pill yields -// via `.reveal-idle` (hidden on touch, faded on hover); when there's no such control the pill is the -// sole indicator and just fades on hover. (The single-episode card, where the pill is itself the -// full-size watched marker with no fade, keeps its own inline rule.) -const swapBadgeReveal = (hasControl: boolean): string => - hasControl ? "reveal-idle" : "group-hover:opacity-0"; +/** A card's "…": this card's menu-button props (`useContextMenu.buttonProps`) and whether its menu + * is the open one. */ +type More = { props: Record; open: boolean } | undefined; + +const more = ( + row: T, + buttonProps: ((row: T) => Record) | undefined, + open: boolean, +): More => (buttonProps ? { props: buttonProps(row), open } : undefined); // Watched-toggle icon. Distinguishes state by SHAPE (a filled check vs an empty circle), not color -// alone — on touch the toggle is the only surviving watched indicator (the text pill is hidden), so a -// green-vs-white CheckCircle2 would be a use-of-color (WCAG 1.4.1) gap. Watched = CheckCircle2 (green), -// not-watched = Circle (empty, white). +// alone (WCAG 1.4.1). Watched = CheckCircle2 (green), not-watched = Circle (empty, white). function WatchedIcon({ watched }: { watched: boolean }) { return watched ? ( @@ -33,34 +41,121 @@ function WatchedIcon({ watched }: { watched: boolean }) { ); } +/** The hover row on a card's picture (top-right). A sibling of the picture's own button, never + * inside it, so no control nests in another. */ +function HoverBar({ + watched, + watchedLabel, + onToggleWatched, + addLabel, + onAdd, + more: m, +}: { + watched: boolean; + watchedLabel: string; + onToggleWatched?: () => void; + addLabel: string; + onAdd?: () => void; + more: More; +}) { + const { t } = useTranslation(); + if (!onToggleWatched && !onAdd && !m) return null; + return ( +
+ {onToggleWatched && ( + + + + )} + {onAdd && ( + + + + )} + {m && ( + + )} +
+ ); +} + +/** The caption under a card's picture, with the touch "…" at its end (the hover row's twin). */ +function Caption({ + onClick, + more: m, + className = "", + children, +}: { + onClick?: () => void; + more: More; + className?: string; + children: ReactNode; +}) { + const { t } = useTranslation(); + return ( +
+
{children}
+ {m && ( + + )} +
+ ); +} + export function PlexPosterCard({ card, onOpen, - onInfo, onToggleWatched, + onAdd, menuProps, + menuButtonProps, + menuOpen = false, navIndex, }: { card: PlexCard; onOpen: () => void; - onInfo: () => void; - onToggleWatched: (c: PlexCard) => void; - /** Right-click / long-press props for the GRID's one context menu (E24 S3 — the comfort pin). - * A function of the card rather than a ready-made object, so every card surface in the app takes - * `menuProps={menu.triggerProps}` and reads the same at every call site. */ + onToggleWatched?: (c: PlexCard) => void; + /** Add a movie or an episode to a playlist (the list's `usePlexCardActions().add`). */ + onAdd?: (c: PlexCard) => void; + /** Right-click / long-press props for the list's ONE context menu. A function of the card rather + * than a ready-made object, so every card surface takes `menuProps={menu.triggerProps}`. */ menuProps?: (c: PlexCard) => Record; + /** The same menu's "…" (`useContextMenu.buttonProps`), on the picture on hover and beside the + * title on touch. */ + menuButtonProps?: (c: PlexCard) => Record; + /** This card's menu is the open one (the "…" says so with `aria-expanded`). */ + menuOpen?: boolean; /** Position in a keyboard-walkable grid or rail (lib/listNav); the poster is what Enter opens. */ navIndex?: number; }) { const { t } = useTranslation(); const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched"; const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down - const watchedLabel = card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched"); + const watched = card.status === "watched"; const runtime = formatRuntime(card.duration_seconds); const pct = inProgress && card.duration_seconds ? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100)) : 0; + const m = more(card, menuButtonProps, menuOpen); + const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(card) : undefined; + const add = isPlayable && onAdd ? () => onAdd(card) : undefined; + // A corner pill steps aside while the hover row covers its corner; at rest, and on touch, it stays. + const yieldTop = toggle || add || m ? HOVER_YIELD : ""; return (
-
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onOpen(); - } - }} - className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`} - > - - {/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-menu keeps the dot - ABOVE the full-card hover-reveal overlay below (absolute inset-0, no pointer-events-none): - without it the overlay sits on top and swallows the cursor, so the dot's native `title` - tooltip never fired on hover (a regression once the reveal overlay was added). */} - {card.playable && - (() => { - const cls = playableClass(card.playable, card.transcode_hw); - return ( - - ); - })()} - {/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card - (reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */} - {card.status === "watched" && ( - - {t("plex.watched")} - - )} - {/* Aggregate in-progress badge for a partially-watched show. */} - {card.type === "show" && card.status === "in_progress" && ( - - {t("plex.inProgress")} - - )} - {/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */} - {card.type === "show" && card.season_count != null && ( - - {t("plex.seasons", { count: card.season_count })} - - )} - +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(); + } + }} + className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`} > - {card.type === "show" ? : } - - - {/* Hover reveal: a soft dim, a less-flat play ring, and a bottom scrim that slides up with the - synopsis + rating (the info you'd otherwise click through for). */} -
-
-
-
+ {/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-menu keeps the + dot ABOVE the full-card hover-reveal overlay below (absolute inset-0, no + pointer-events-none): without it the overlay sits on top and swallows the cursor, so the + dot's native `title` tooltip never fired on hover. */} + {card.playable && + (() => { + const cls = playableClass(card.playable, card.transcode_hw); + return ( + + ); + })()} + {/* Rest-state watched badge (top-right). */} + {watched && ( + - + {t("plex.watched")} + + )} + {/* Aggregate in-progress badge for a partially-watched show. */} + {card.type === "show" && card.status === "in_progress" && ( + + {t("plex.inProgress")} + + )} + {/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */} + {card.type === "show" && card.season_count != null && ( + + {t("plex.seasons", { count: card.season_count })} + + )} + + {card.type === "show" ? : } + + + {/* Hover reveal: a soft dim, a less-flat play ring, and a bottom scrim that slides up with + the synopsis + rating (the info you'd otherwise click through for). */} +
+
+
+
+ +
+ {(card.summary || card.rating != null) && ( +
+
+ {card.title} +
+
+ {(card.year || runtime) && ( + {[card.year, runtime].filter(Boolean).join(" · ")} + )} + {card.rating != null && ( + + + {card.rating.toFixed(1)} + + )} +
+ {card.summary && ( +

+ {card.summary} +

+ )} +
+ )}
- {(card.summary || card.rating != null) && ( -
-
- {card.title} -
-
- {(card.year || runtime) && ( - {[card.year, runtime].filter(Boolean).join(" · ")} - )} - {card.rating != null && ( - - - {card.rating.toFixed(1)} - - )} -
- {card.summary && ( -

- {card.summary} -

- )} + + {pct > 0 && ( +
+
)}
- - {/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */} - {isPlayable && ( -
- { - e.stopPropagation(); - onInfo(); - }} - label={t("plex.info.title")} - > - - - { - e.stopPropagation(); - onToggleWatched(card); - }} - label={watchedLabel} - > - - -
- )} - - {pct > 0 && ( -
-
-
- )} +
-
+
{card.title}
{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
-
+
); } @@ -232,106 +306,90 @@ export function SeasonCard({ onOpen, onToggleWatched, onAdd, + menuProps, + menuButtonProps, + menuOpen = false, navIndex, }: { se: PlexSeasonDetail; onOpen: () => void; onToggleWatched?: () => void; onAdd?: () => void; + /** The show page's season menu (see `PlexPosterCard`). */ + menuProps?: (se: PlexSeasonDetail) => Record; + menuButtonProps?: (se: PlexSeasonDetail) => Record; + menuOpen?: boolean; navIndex?: number; }) { const { t } = useTranslation(); const watched = se.status === "watched"; - const watchedLabel = watched - ? t("plex.series.markSeasonUnwatched") - : t("plex.series.markSeasonWatched"); + const m = more(se, menuButtonProps, menuOpen); return (
-
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onOpen(); - } - }} - className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`} - > - -
-
- - {t("plex.openShow")} +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(); + } + }} + className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`} + > + +
+
+ + {t("plex.series.openSeason")} +
+ {watched && ( + + {t("plex.watched")} + + )} + {/* In-progress lives at the top-LEFT (the hover row owns the top-right) and stays visible: + a season has no progress bar, so this badge is its sole in-progress cue. */} + {se.status === "in_progress" && ( + + {t("plex.inProgress")} + + )} + + {t("plex.series.episodeCount", { count: se.episode_count })} +
- {/* Quick watched toggle (whole season), on hover. */} - {onToggleWatched && ( - { - e.stopPropagation(); - onToggleWatched(); - }} - label={watchedLabel} - className="absolute top-1.5 right-1.5 reveal" - > - - - )} - {/* Add whole season to a playlist, on hover. */} - {onAdd && ( - { - e.stopPropagation(); - onAdd(); - }} - label={t("plex.playlist.addSeason")} - className="absolute bottom-1.5 right-1.5 reveal" - > - - - )} - {watched && ( - - {t("plex.watched")} - - )} - {/* In-progress lives at the top-LEFT (the watched-toggle owns the top-right) and stays visible - on touch: unlike movie/episode cards, a season has no progress bar, and the always-shown - toggle reads as unwatched for a partial season — so this badge is the sole in-progress cue - and must not be a reveal-idle swap-badge (which display:none-s on touch). */} - {se.status === "in_progress" && ( - - {t("plex.inProgress")} - - )} - - {t("plex.series.episodeCount", { count: se.episode_count })} - -
-
- {se.title} +
+ +
{se.title}
+
); } @@ -415,11 +473,16 @@ export function RelatedStrip({ related, onOpen, menuProps, + menuButtonProps, + openId = null, }: { related: PlexCard[]; - onOpen: (id: string) => void; - /** The comfort-pin context menu's trigger (a related show is pinnable like any poster). */ + onOpen: (c: PlexCard) => void; + /** The page's Plex menu (a related show is a Plex card like any other: E28 C1). */ menuProps?: (c: PlexCard) => Record; + menuButtonProps?: (c: PlexCard) => Record; + /** The id whose menu is open. */ + openId?: string | null; }) { const { t } = useTranslation(); return ( @@ -430,32 +493,16 @@ export function RelatedStrip({

{t("plex.series.related")}

{related.map((r, i) => ( - onOpen(r.id)} - className="group w-32.5 shrink-0" - > -
- - {r.status === "watched" && ( - - {t("plex.watched")} - - )} -
-
- {r.title} -
-
+
+ onOpen(r)} + menuProps={menuProps} + menuButtonProps={menuButtonProps} + menuOpen={openId === r.id} + /> +
))}
@@ -468,6 +515,9 @@ export function EpisodeCard({ onAdd, onToggleWatched, withShowTitle, + menuProps, + menuButtonProps, + menuOpen = false, navIndex, }: { ep: PlexCard; @@ -475,93 +525,88 @@ export function EpisodeCard({ onAdd?: () => void; onToggleWatched?: () => void; withShowTitle?: boolean; + /** The episode list's menu (see `PlexPosterCard`). */ + menuProps?: (c: PlexCard) => Record; + menuButtonProps?: (c: PlexCard) => Record; + menuOpen?: boolean; navIndex?: number; }) { const { t } = useTranslation(); - const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; - const watchedLabel = ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched"); + const watched = ep.status === "watched"; + const inProgress = (ep.position_seconds ?? 0) > 0 && !watched; const pct = inProgress && ep.duration_seconds ? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100)) : 0; + const m = more(ep, menuButtonProps, menuOpen); return ( -
-
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onPlay(); - } - }} - className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer" - > - -
- -
- {/* Quick watched toggle (single episode), on hover. */} - {onToggleWatched && ( - { - e.stopPropagation(); - onToggleWatched(); - }} - label={watchedLabel} - className="absolute top-1.5 right-1.5 reveal" - > - - - )} - {ep.status === "watched" && ( - - - - )} - {pct > 0 && ( -
-
+
+
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onPlay(); + } + }} + className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer" + > + +
+
- )} -
-
-
- {withShowTitle && ep.show_title && ( -
- {ep.show_title} - {ep.season_number != null && ep.episode_number != null - ? ` · S${ep.season_number}·E${ep.episode_number}` - : ""} + {watched && ( + + + + )} + {pct > 0 && ( +
+
)} -
- {!withShowTitle && {ep.episode_number}.} - {ep.title} -
-
{formatRuntime(ep.duration_seconds)}
- {onAdd && ( - - - - )} +
+ + {withShowTitle && ep.show_title && ( +
+ {ep.show_title} + {ep.season_number != null && ep.episode_number != null + ? ` · S${ep.season_number}·E${ep.episode_number}` + : ""} +
+ )} +
+ {!withShowTitle && {ep.episode_number}.} + {ep.title} +
+
{formatRuntime(ep.duration_seconds)}
+
); } diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 8808141b..e8f1d0ef 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -1,9 +1,26 @@ import { lazy, Suspense, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, Layers, ListPlus, Play, RotateCcw, Star, User } from "lucide-react"; +import { + ArrowLeft, + Check, + Layers, + ListPlus, + Play, + RotateCcw, + Star, + Tv2, + Undo2, + User, +} from "lucide-react"; import { qk } from "../lib/queryKeys"; -import { api, type PlexCard, type PlexFilters, type PlexPersonDetail } from "../lib/api"; +import { + api, + type PlexCard, + type PlexFilters, + type PlexPersonDetail, + type PlexSeasonDetail, +} from "../lib/api"; import { plexLang } from "../i18n"; import { pickRatingBadge } from "../lib/plexRating"; import { notifyActionError } from "../lib/actionErrors"; @@ -19,7 +36,10 @@ import { } from "./plexDetailUi"; import { epLabel, tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail"; import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards"; -import { PlexPinMenu } from "./ComfortPinMenu"; +import { PlexMenu } from "./PlexMenu"; +import { usePlexCardActions } from "./usePlexCardActions"; +import { useCopyItems } from "./copyItems"; +import { ContextMenu } from "./ui/ContextMenu"; import { useContextMenu } from "./ui/useContextMenu"; import { Button } from "./ui/Button"; import { Carousel } from "./ui/Carousel"; @@ -110,10 +130,10 @@ export function PlexPersonView({ }) { const { t } = useTranslation(); const qc = useQueryClient(); - // ONE comfort-pin menu for this whole page — the "known for" rail and the two filmography grids - // are three lists on one screen, and the popover anchors at the pointer, so a second controller - // would only mean a second closed popover to keep shut (see ui/ContextMenu). - const pinMenu = useContextMenu(); + // ONE menu for this whole page — the "known for" rail and the two filmography grids are three + // lists on one screen, and the popover anchors where it opened, so a second controller would only + // mean a second closed popover to keep shut (see ui/ContextMenu). + const menu = useContextMenu(); const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) }); // E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls // out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control. @@ -149,6 +169,17 @@ export function PlexPersonView({ }, }); + const actions = usePlexCardActions( + { onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched: toggle }, + menu.open, + ); + const cardMenu = (c: PlexCard) => ({ + onAdd: actions.add, + menuProps: menu.triggerProps, + menuButtonProps: menu.buttonProps, + menuOpen: menu.open && menu.target === c, + }); + const section = (titleKey: string, cards: PlexCard[]) => cards.length > 0 ? (
@@ -160,9 +191,8 @@ export function PlexPersonView({ navIndex={i} card={c} onOpen={() => onOpenCard(c)} - onInfo={() => onInfoCard(c)} onToggleWatched={toggle} - menuProps={pinMenu.triggerProps} + {...cardMenu(c)} /> ))} @@ -252,9 +282,8 @@ export function PlexPersonView({ card={c} navIndex={i} onOpen={() => onOpenCard(c)} - onInfo={() => onInfoCard(c)} onToggleWatched={toggle} - menuProps={pinMenu.triggerProps} + {...cardMenu(c)} />
))} @@ -272,7 +301,7 @@ export function PlexPersonView({ )}
)} - +
); } @@ -313,7 +342,10 @@ export function PlexShowView({ const [addTarget, setAddTarget] = useState(null); const [collOpen, setCollOpen] = useState(false); const [busy, setBusy] = useState(false); - const relatedPin = useContextMenu(); + const relatedMenu = useContextMenu(); + const related = usePlexCardActions({ onOpen: (c) => onOpenShow(c.id) }, relatedMenu.open); + const seasonMenu = useContextMenu(); + const copy = useCopyItems(); const show = d?.show; const showLib = show?.library ?? undefined; @@ -359,6 +391,49 @@ export function PlexShowView({ qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); qc.invalidateQueries({ queryKey: qk.plexLibrary() }); } + // A season's add target (none for an empty season, which has nothing to add). + const addSeason = (se: PlexSeasonDetail) => + show && se.episodes.length > 0 + ? () => + setAddTarget({ + kind: "group", + ratingKeys: se.episodes.map((e) => e.id), + title: `${show.title} — ${se.title}`, + }) + : undefined; + // A season's one action list (E28 C1): the rows its card shows on hover, plus copy. + const seasonItems = (se: PlexSeasonDetail) => { + const seWatched = se.status === "watched"; + const add = addSeason(se); + return [ + { + id: "open", + label: t("plex.series.openSeason"), + icon: , + onSelect: () => onOpenSeason(se.id), + }, + { + id: "watched", + label: seWatched + ? t("plex.series.markSeasonUnwatched") + : t("plex.series.markSeasonWatched"), + icon: seWatched ? : , + separatorBefore: true, + onSelect: () => void markSeason(se.id, !seWatched), + }, + ...(add + ? [ + { + id: "playlist", + label: t("plex.playlist.addSeason"), + icon: , + onSelect: add, + }, + ] + : []), + ...copy(se.title, null).map((it) => ({ ...it, separatorBefore: true })), + ]; + }; return (
@@ -522,16 +597,10 @@ export function PlexShowView({ se={se} onOpen={() => onOpenSeason(se.id)} onToggleWatched={() => markSeason(se.id, se.status !== "watched")} - onAdd={ - se.episodes.length > 0 - ? () => - setAddTarget({ - kind: "group", - ratingKeys: se.episodes.map((e) => e.id), - title: `${show.title} — ${se.title}`, - }) - : undefined - } + onAdd={addSeason(se)} + menuProps={seasonMenu.triggerProps} + menuButtonProps={seasonMenu.buttonProps} + menuOpen={seasonMenu.open && seasonMenu.target === se} /> ))} @@ -543,13 +612,20 @@ export function PlexShowView({ {d.related.length > 0 && showRelated && ( onOpenShow(c.id)} + menuProps={relatedMenu.triggerProps} + menuButtonProps={relatedMenu.buttonProps} + openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null} /> )} )} - + + {addTarget && setAddTarget(null)} />} {collOpen && show && showLib && ( @@ -585,6 +661,7 @@ export function PlexSeasonView({ }); const d = q.data; const se = d?.seasons.find((s) => s.id === seasonId); + const epMenu = useContextMenu(); const [addTarget, setAddTarget] = useState(null); const [busy, setBusy] = useState(false); @@ -621,6 +698,14 @@ export function PlexSeasonView({ qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); qc.invalidateQueries({ queryKey: qk.plexLibrary() }); } + // The episode grid's one menu (an episode is not a comfort-shelf kind, so no pin). + const episodes = usePlexCardActions( + { + onOpen: (ep) => onPlay(ep.id, allKeys), + onToggleWatched: (ep) => void markEpisode(ep), + }, + epMenu.open, + ); return (
@@ -707,8 +792,11 @@ export function PlexSeasonView({ navIndex={i} ep={ep} onPlay={() => onPlay(ep.id, allKeys)} - onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })} + onAdd={() => episodes.add(ep)} onToggleWatched={() => markEpisode(ep)} + menuProps={epMenu.triggerProps} + menuButtonProps={epMenu.buttonProps} + menuOpen={epMenu.open && epMenu.target === ep} /> ))} @@ -716,6 +804,7 @@ export function PlexSeasonView({ )} {addTarget && setAddTarget(null)} />} +
); } diff --git a/frontend/src/components/ui/reveal.ts b/frontend/src/components/ui/reveal.ts new file mode 100644 index 00000000..23ceffe1 --- /dev/null +++ b/frontend/src/components/ui/reveal.ts @@ -0,0 +1,8 @@ +// A card's hover chrome (E28 C1): shown when the card is hovered or holds keyboard focus on a mouse, +// removed on touch (`.reveal-hover`, index.css), where the card offers the same actions through its +// "…" beside the title. One string, so the video card and the Plex cards reveal the same way. +export const HOVER_REVEAL = + "reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity"; + +/** What sits under that chrome at rest (a duration, a watched pill) steps aside while it shows. */ +export const HOVER_YIELD = "transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"; diff --git a/frontend/src/components/usePlexCardActions.tsx b/frontend/src/components/usePlexCardActions.tsx new file mode 100644 index 00000000..65c6c40c --- /dev/null +++ b/frontend/src/components/usePlexCardActions.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, Info, ListPlus, Play, Tv2, Undo2 } from "lucide-react"; + +import { type PlexCard } from "../lib/api"; +import { pinPlex, useComfortPin } from "./comfortPin"; +import { useCopyItems } from "./copyItems"; +import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; +import { type ActionMenuItem } from "./ui/ActionMenu"; + +// E28 C1 — a Plex title's ONE action list (docs/DESIGN.md "Action placement"): the right-click, the +// long-press, Shift+F10 and the card's "…" open a menu built here, so every poster grid and rail +// lists the same actions in the same order. The card keeps its state (watched, progress) at rest and +// the everyday toggles (watched, add to playlist) on hover; details, copy and the pin live here. + +export interface PlexActionHandlers { + onOpen: (c: PlexCard) => void; + /** The media-info page. Omitted where a surface has none (the episode lists). */ + onInfo?: (c: PlexCard) => void; + onToggleWatched?: (c: PlexCard) => void; +} + +const icon = "w-4 h-4"; + +/** The "add to playlist" row and button: an episode says which it adds, as the season page does. */ +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. */ +const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show"; + +/** `open` is the host menu's own flag (the shelf is fetched only once a menu opened). Returns the + * item builder, `add` for the card's own hover button, and the dialog to render beside the menu + * (one per list, so a card does not mount a modal of its own). */ +export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean) { + const { t } = useTranslation(); + const pin = useComfortPin(open); + const copy = useCopyItems(); + const addLabel = (c: PlexCard) => t(plexAddLabelKey(c)); + const [adding, setAdding] = useState(null); + // A whole show needs its episode keys, which only the show page has ("Add whole show" there), so + // only a leaf is added from a card. + const add = (c: PlexCard) => setAdding({ kind: "single", ratingKey: c.id, title: c.title }); + + /** `play: false` where the host menu leads with its own Play row (Home's shelf resolves `next`); + * `pin: false` where the host carries its own shelf rows or the object is not a pin kind. */ + const items = ( + c: PlexCard, + { play = true, pin: withPin = true }: { play?: boolean; pin?: boolean } = {}, + ): ActionMenuItem[] => { + const { onOpen, onInfo, onToggleWatched } = handlers; + const playable = isPlexPlayable(c); + const watched = c.status === "watched"; + return [ + ...(play + ? [ + { + id: "play", + label: playable ? t("plex.play") : t("plex.openShow"), + icon: playable ? : , + onSelect: () => onOpen(c), + }, + ] + : []), + ...(playable && onToggleWatched + ? [ + { + id: "watched", + label: watched ? t("plex.markUnwatched") : t("plex.markWatched"), + icon: watched ? : , + separatorBefore: true, + onSelect: () => onToggleWatched(c), + }, + ] + : []), + ...(playable + ? [ + { + id: "playlist", + label: addLabel(c), + icon: , + separatorBefore: !onToggleWatched, + onSelect: () => add(c), + }, + ] + : []), + ...(playable && onInfo + ? [ + { + id: "details", + label: t("plex.info.title"), + icon: , + separatorBefore: true, + onSelect: () => onInfo(c), + }, + ] + : []), + ...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !(playable && onInfo) })), + ...(withPin ? pin.item(pinPlex(c), c.title) : []), + ]; + }; + + const panel = adding && setAdding(null)} />; + return { items, add, panel }; +} + +export type PlexCardActions = ReturnType; diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index bae95a57..cc3dd3ae 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -122,6 +122,7 @@ "play": "Play", "resume": "Resume", "openShow": "Open show", + "menu": "Media", "markWatched": "Mark watched", "markUnwatched": "Mark unwatched", "watchedFailed": "Couldn't update the watched state — please try again.", @@ -140,6 +141,7 @@ "markShowUnwatched": "Mark show unwatched", "markSeasonWatched": "Mark season watched", "markSeasonUnwatched": "Mark season unwatched", + "openSeason": "Open season", "addShowCollection": "Add to collection" }, "collEditor": { diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 2c166d66..912dc15d 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -122,6 +122,7 @@ "play": "Lejátszás", "resume": "Folytatás", "openShow": "Sorozat megnyitása", + "menu": "Média", "markWatched": "Megnézettnek jelöl", "markUnwatched": "Nem-nézettnek jelöl", "watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra.", @@ -140,6 +141,7 @@ "markShowUnwatched": "Sorozat jelölés visszavonása", "markSeasonWatched": "Egész évad megnézve", "markSeasonUnwatched": "Évad jelölés visszavonása", + "openSeason": "Évad megnyitása", "addShowCollection": "Kollekcióhoz adás" }, "collEditor": { diff --git a/frontend/src/index.css b/frontend/src/index.css index 3e4acd3d..a9e1ccce 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -713,13 +713,7 @@ body { but on a touch device — which never hovers — it is invisible AND still tappable. So the reveal is gated on `@media (hover: hover)`: only a hover-capable pointer hides it until the group is hovered or focused (keyboard too, via `:focus-within`); a touch device keeps the default opacity:1 and the - control is always shown. `.reveal-idle` is the counterpart badge a control replaces (e.g. the - "watched" pill under the watched-toggle): visible at rest, faded when the group is hovered, and - dropped entirely on touch — where the toggle is always present, so the two would otherwise collide - at the same corner. Requires a `.group` ancestor (Tailwind's group marker). */ -.reveal-idle { - transition: opacity 150ms ease; -} + control is always shown. Requires a `.group` ancestor (Tailwind's group marker). */ @media (hover: hover) { .reveal { opacity: 0; @@ -730,29 +724,20 @@ body { .reveal:focus-visible { opacity: 1; } - /* Fade the idle badge whenever its companion control is revealed — on hover AND on keyboard - focus-within (the `.reveal` trigger set above lists both), else the two overlap at the same - corner for a keyboard user. */ - .group:hover .reveal-idle, - .group:focus-within .reveal-idle { - opacity: 0; - } /* The mirror of `.reveal-hover`: a control that exists ONLY on touch, because a mouse reaches the - same action another way — the video card's "…" beside the title, whose mouse twin sits in the - hover row on the picture (E28 C1). `display: none`, so it is no Tab stop either. */ + same action another way — a video or Plex card's "…" beside the title, whose mouse twin sits + in the hover row on the picture (E28 C1, `ui/reveal.ts`). `display: none`, so it is no Tab stop either. */ .touch-only { display: none; } } @media (hover: none) { - .reveal-idle { - display: none; - } /* Sibling of `.reveal` for a MOUSE-ONLY hover-reveal: unlike `.reveal` (shown at rest on touch), a `.reveal-hover` control is removed entirely on a device that can't hover, so it is never an invisible tap/Tab target. Pair it with the site's own `opacity-0 group-hover:… group-focus-within:…` (which still governs the hover + keyboard case). For a full-card/edge affordance whose action is - already reachable on touch another way — VideoCard's play overlay (tapping the card plays/resumes) + already reachable on touch another way — a card's hover row (its "…" beside the title opens the + same actions: `ui/reveal.ts`), VideoCard's play overlay (tapping the card plays/resumes) and the Carousel arrows (swiping scrolls the strip) — where a permanent scrim/edge button is not wanted. Group-agnostic (encodes only the touch removal), so it works under any `.group`/`group\/…`. */ .reveal-hover {