From a4737b92e4d30d475e9eb4c121ae6ebc450c1bf8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 26 Sep 2026 01:51:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(design):=20a=20Plex=20title's=20one=20acti?= =?UTF-8?q?on=20list=20(C1)=20=E2=80=94=20the=20poster's=20"=E2=80=A6",=20?= =?UTF-8?q?details=20and=20the=20pin=20in=20its=20menu=20(E28=20S3e2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Plex poster list (library, Discover rails, person, show, season, collection, Home's rails) now opens one menu per list from the right-click, the long-press, Shift+F10 and the card's "…": play/open, watched, add to playlist, media info, copy title, the pin (usePlexCardActions + PlexMenu, replacing the pin-only PlexPinMenu). The card keeps its state at rest and shows watched / add to playlist / "…" on its picture on hover (ui/reveal.ts, shared with the video card); on touch the toggles are gone and a 44px "…" sits beside the title, while the watched pill stays. The hover ⓘ left the card; an episode's add button moved from its caption to the hover row. Seasons get their own short menu (open, watched, add, copy), labelled "Open season" (the card's overlay said "Open show"). Related shows are ordinary poster cards now. A collection's admin "Remove" X moved into its menu, still behind the confirmation. The collection grid is two columns on a phone. The unused .reveal-idle rule is gone. --- frontend/src/components/ComfortPinMenu.tsx | 9 +- frontend/src/components/Home.dom.test.tsx | 11 +- frontend/src/components/Home.tsx | 32 +- frontend/src/components/PlexBrowse.tsx | 32 +- .../src/components/PlexCollectionView.tsx | 69 +- frontend/src/components/PlexDiscover.tsx | 19 +- frontend/src/components/PlexMenu.dom.test.tsx | 202 +++++ frontend/src/components/PlexMenu.tsx | 36 + frontend/src/components/VideoCard.tsx | 7 +- .../src/components/plexCards.dom.test.tsx | 33 +- frontend/src/components/plexCards.tsx | 731 ++++++++++-------- frontend/src/components/plexViews.tsx | 143 +++- frontend/src/components/ui/reveal.ts | 8 + .../src/components/usePlexCardActions.tsx | 107 +++ frontend/src/i18n/locales/en/plex.json | 2 + frontend/src/i18n/locales/hu/plex.json | 2 + frontend/src/index.css | 25 +- 17 files changed, 1003 insertions(+), 465 deletions(-) create mode 100644 frontend/src/components/PlexMenu.dom.test.tsx create mode 100644 frontend/src/components/PlexMenu.tsx create mode 100644 frontend/src/components/ui/reveal.ts create mode 100644 frontend/src/components/usePlexCardActions.tsx 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 {