feat(design): a Plex title's one action list (C1) — the poster's "…", details and the pin in its menu (E28 S3e2)

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.
This commit is contained in:
2026-09-26 01:51:53 +02:00
parent 75ae810866
commit a4737b92e4
17 changed files with 1003 additions and 465 deletions
+1 -8
View File
@@ -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<T>({
/>
);
}
/** 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<PlexCard> }) {
return <ComfortPinMenu menu={menu} target={pinPlex} title={(c) => c.title} />;
}
+9 -2
View File
@@ -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");
});
+28 -4
View File
@@ -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}
/>
) : (
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
@@ -234,6 +256,7 @@ function Rail({
</Carousel>
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
{videoActions.panel}
{plexActions.panel}
</section>
);
}
@@ -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;
+25 -7
View File
@@ -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<PlexCard>();
// One menu for the titles grid and one for the search's episodes (see ui/ContextMenu).
const menu = useContextMenu<PlexCard>();
const epMenu = useContextMenu<PlexCard>();
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}
/>
))}
</NavGrid>
@@ -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}
/>
))}
</NavGrid>
@@ -671,7 +688,8 @@ export default function PlexBrowse() {
</>
)}
<PlexPinMenu menu={pinMenu} />
<PlexMenu menu={menu} actions={actions} />
<PlexMenu menu={epMenu} actions={epActions} pin={false} />
<div ref={setSentinelEl} className="h-8" />
{isFetchingNextPage && (
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
+43 -26
View File
@@ -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<PlexCard>();
// One menu for the member grid (see ui/ContextMenu).
const menu = useContextMenu<PlexCard>();
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({
/>
</div>
</div>
<NavGrid className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{/* 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). */}
<NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{items.map((c, i) => (
<div key={c.id} className="relative">
<PlexPosterCard
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggleWatched}
menuProps={pinMenu.triggerProps}
/>
{canEdit && (
<IconButton
variant="media"
onClick={() => doRemove(c)}
disabled={busy}
label={t("plex.collection.removeItem")}
className="absolute right-1.5 top-1.5 z-base"
>
<X />
</IconButton>
)}
</div>
<PlexPosterCard
key={c.id}
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onToggleWatched={toggleWatched}
onAdd={actions.add}
menuProps={menu.triggerProps}
menuButtonProps={menu.buttonProps}
menuOpen={menu.open && menu.target === c}
/>
))}
</NavGrid>
</>
)}
</>
)}
<PlexPinMenu menu={pinMenu} />
<PlexMenu
menu={menu}
actions={actions}
extra={(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: <X className="w-4 h-4" />,
separatorBefore: true,
danger: true,
disabled: busy,
onSelect: () => void doRemove(c),
},
]
: []
}
/>
</div>
);
}
+13 -6
View File
@@ -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<PlexCard>();
// One menu per rail (see ui/ContextMenu), not one per poster.
const menu = useContextMenu<PlexCard>();
const actions = usePlexCardActions(
{ onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched },
menu.open,
);
const heading =
action != null ? (
<span className="inline-flex items-center gap-2">
@@ -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}
/>
</div>
))}
</Carousel>
<PlexPinMenu menu={pinMenu} />
<PlexMenu menu={menu} actions={actions} />
</section>
);
}
@@ -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<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
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>): 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<typeof PlexMenu>[0]["extra"];
}) {
const menu = useContextMenu<PlexCard>();
const actions = usePlexCardActions(handlers, menu.open);
return (
<>
<PlexPosterCard
card={c}
onOpen={() => handlers.onOpen(c)}
onToggleWatched={handlers.onToggleWatched}
onAdd={actions.add}
menuProps={menu.triggerProps}
menuButtonProps={menu.buttonProps}
menuOpen={menu.open && menu.target === c}
/>
<PlexMenu menu={menu} actions={actions} pin={pin} extra={extra} />
</>
);
}
function setup(c: PlexCard, opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {}) {
const handlers = { onOpen: vi.fn(), onInfo: vi.fn(), onToggleWatched: vi.fn() };
render(
<QueryClientProvider client={new QueryClient()}>
<FloatingTree>
<Grid c={c} handlers={handlers} {...opts} />
</FloatingTree>
</QueryClientProvider>,
);
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"));
});
});
+36
View File
@@ -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<PlexCard>;
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 (
<>
<ContextMenu
menu={menu}
ariaLabel={t("plex.menu")}
items={c ? [...actions.items(c, { pin }), ...(extra?.(c) ?? [])] : []}
/>
{actions.panel}
</>
);
}
+2 -5
View File
@@ -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<string, unknown>; 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)}
+16 -17
View File
@@ -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(
<PlexPosterCard card={movie()} onOpen={vi.fn()} onInfo={vi.fn()} onToggleWatched={vi.fn()} />,
<PlexPosterCard
card={movie()}
onOpen={vi.fn()}
onToggleWatched={vi.fn()}
onAdd={vi.fn()}
menuButtonProps={() => ({})}
/>,
);
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(
<PlexPosterCard
card={movie("watched")}
onOpen={vi.fn()}
onInfo={vi.fn()}
onToggleWatched={vi.fn()}
/>,
);
render(<PlexPosterCard card={movie("watched")} onOpen={vi.fn()} onToggleWatched={vi.fn()} />);
expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
});
@@ -67,7 +70,6 @@ describe("PlexPosterCard a11y", () => {
<PlexPosterCard
card={{ ...movie(), year: 2023, rating: 7.5, summary: "A quiet synopsis." }}
onOpen={vi.fn()}
onInfo={vi.fn()}
onToggleWatched={vi.fn()}
/>,
);
@@ -76,9 +78,7 @@ describe("PlexPosterCard a11y", () => {
});
it("omits the scrim when the card has no synopsis or rating", () => {
render(
<PlexPosterCard card={movie()} onOpen={vi.fn()} onInfo={vi.fn()} onToggleWatched={vi.fn()} />,
);
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onToggleWatched={vi.fn()} />);
expect(screen.queryByText(/synopsis/i)).toBeNull();
});
});
@@ -111,7 +111,6 @@ describe("Plex cards in a keyboard-walkable list", () => {
<PlexPosterCard
card={movie()}
onOpen={onOpen}
onInfo={vi.fn()}
onToggleWatched={vi.fn()}
navIndex={navIndex}
/>
+388 -343
View File
@@ -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<string, unknown>; open: boolean } | undefined;
const more = <T,>(
row: T,
buttonProps: ((row: T) => Record<string, unknown>) | 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 ? (
<CheckCircle2 className="w-4 h-4 text-success" />
@@ -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 (
<div className={`${HOVER_REVEAL} absolute right-1.5 top-1.5 z-base flex gap-1`}>
{onToggleWatched && (
<IconButton variant="media" onClick={onToggleWatched} label={watchedLabel}>
<WatchedIcon watched={watched} />
</IconButton>
)}
{onAdd && (
<IconButton variant="media" onClick={onAdd} label={addLabel}>
<ListPlus />
</IconButton>
)}
{m && (
<MoreButton
variant="media"
label={t("card.menu.more")}
expanded={m.open}
menuProps={m.props}
/>
)}
</div>
);
}
/** 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 (
<div
onClick={onClick}
className={`mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5 ${onClick ? "cursor-pointer" : ""} ${className}`}
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
<div className="min-w-0 flex-1">{children}</div>
{m && (
<MoreButton
className="touch-only -mr-1 -my-0.5 shrink-0"
label={t("card.menu.more")}
expanded={m.open}
menuProps={m.props}
/>
)}
</div>
);
}
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<string, unknown>;
/** The same menu's "…" (`useContextMenu.buttonProps`), on the picture on hover and beside the
* title on touch. */
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
/** 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 (
<div
className="group text-left"
@@ -69,160 +164,139 @@ export function PlexPosterCard({
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
>
<div
onClick={onOpen}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
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`}
>
<img
src={card.thumb}
alt=""
loading="lazy"
decoding="async"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{/* 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 (
<span
role="img"
aria-label={t(`plex.playable.${cls}`)}
className={`absolute top-1.5 left-1.5 z-menu w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-muted"}`}
title={t(`plex.playable.${cls}`)}
/>
);
})()}
{/* 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" && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
isPlayable,
)}`}
>
{t("plex.watched")}
</span>
)}
{/* Aggregate in-progress badge for a partially-watched show. */}
{card.type === "show" && card.status === "in_progress" && (
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
{t("plex.inProgress")}
</span>
)}
{/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */}
{card.type === "show" && card.season_count != null && (
<span className="pointer-events-none absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm transition-opacity group-hover:opacity-0">
{t("plex.seasons", { count: card.season_count })}
</span>
)}
<span
className="pointer-events-none absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90 transition-opacity group-hover:opacity-0"
title={t(
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
)}
<div className="relative">
<div
onClick={onOpen}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
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" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
</span>
{/* 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). */}
<div className="absolute inset-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div className="absolute inset-0 bg-black/25" />
<div className="absolute inset-x-0 top-[30%] flex justify-center">
<div
className="grid h-12 w-12 scale-90 place-items-center rounded-full text-accent-fg shadow-xl transition-transform duration-200 group-hover:scale-100"
style={{
background:
"radial-gradient(circle at 38% 32%, color-mix(in srgb, var(--accent) 72%, white), var(--accent))",
boxShadow:
"0 10px 22px -6px color-mix(in srgb, var(--accent) 60%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent)",
}}
<img
src={card.thumb}
alt=""
loading="lazy"
decoding="async"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{/* 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 (
<span
role="img"
aria-label={t(`plex.playable.${cls}`)}
className={`absolute top-1.5 left-1.5 z-menu w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-muted"}`}
title={t(`plex.playable.${cls}`)}
/>
);
})()}
{/* Rest-state watched badge (top-right). */}
{watched && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${yieldTop}`}
>
<Play className="w-5 h-5 translate-x-px fill-current" />
{t("plex.watched")}
</span>
)}
{/* Aggregate in-progress badge for a partially-watched show. */}
{card.type === "show" && card.status === "in_progress" && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm ${yieldTop}`}
>
{t("plex.inProgress")}
</span>
)}
{/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */}
{card.type === "show" && card.season_count != null && (
<span className="pointer-events-none absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm transition-opacity group-hover:opacity-0">
{t("plex.seasons", { count: card.season_count })}
</span>
)}
<span
className="pointer-events-none absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90 transition-opacity group-hover:opacity-0"
title={t(
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
)}
>
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
</span>
{/* 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). */}
<div className="absolute inset-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div className="absolute inset-0 bg-black/25" />
<div className="absolute inset-x-0 top-[30%] flex justify-center">
<div
className="grid h-12 w-12 scale-90 place-items-center rounded-full text-accent-fg shadow-xl transition-transform duration-200 group-hover:scale-100"
style={{
background:
"radial-gradient(circle at 38% 32%, color-mix(in srgb, var(--accent) 72%, white), var(--accent))",
boxShadow:
"0 10px 22px -6px color-mix(in srgb, var(--accent) 60%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent)",
}}
>
<Play className="w-5 h-5 translate-x-px fill-current" />
</div>
</div>
{(card.summary || card.rating != null) && (
<div className="absolute inset-x-0 bottom-0 translate-y-1.5 bg-gradient-to-t from-black/95 via-black/85 to-transparent p-2.5 transition-transform duration-200 group-hover:translate-y-0">
<div className="line-clamp-1 text-xs font-semibold leading-snug text-white">
{card.title}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-2xs text-white/75">
{(card.year || runtime) && (
<span>{[card.year, runtime].filter(Boolean).join(" · ")}</span>
)}
{card.rating != null && (
<span className="inline-flex items-center gap-0.5 text-rating-media">
<Star className="w-3 h-3 fill-current" />
{card.rating.toFixed(1)}
</span>
)}
</div>
{card.summary && (
<p className="mt-1 line-clamp-3 text-2xs leading-snug text-white/85">
{card.summary}
</p>
)}
</div>
)}
</div>
{(card.summary || card.rating != null) && (
<div className="absolute inset-x-0 bottom-0 translate-y-1.5 bg-gradient-to-t from-black/95 via-black/85 to-transparent p-2.5 transition-transform duration-200 group-hover:translate-y-0">
<div className="line-clamp-1 text-xs font-semibold leading-snug text-white">
{card.title}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-2xs text-white/75">
{(card.year || runtime) && (
<span>{[card.year, runtime].filter(Boolean).join(" · ")}</span>
)}
{card.rating != null && (
<span className="inline-flex items-center gap-0.5 text-rating-media">
<Star className="w-3 h-3 fill-current" />
{card.rating.toFixed(1)}
</span>
)}
</div>
{card.summary && (
<p className="mt-1 line-clamp-3 text-2xs leading-snug text-white/85">
{card.summary}
</p>
)}
{pct > 0 && (
<div className="absolute inset-x-0 bottom-0 z-base h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
{/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */}
{isPlayable && (
<div className="reveal absolute right-1.5 top-1.5 z-base flex gap-1">
<IconButton
variant="media"
onClick={(e) => {
e.stopPropagation();
onInfo();
}}
label={t("plex.info.title")}
>
<Info />
</IconButton>
<IconButton
variant="media"
onClick={(e) => {
e.stopPropagation();
onToggleWatched(card);
}}
label={watchedLabel}
>
<WatchedIcon watched={card.status === "watched"} />
</IconButton>
</div>
)}
{pct > 0 && (
<div className="absolute inset-x-0 bottom-0 z-base h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
<HoverBar
watched={watched}
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={toggle}
addLabel={t(plexAddLabelKey(card))}
onAdd={add}
more={m}
/>
</div>
<div
onClick={onOpen}
className="mt-1.5 rounded-lg px-2 py-1.5 cursor-pointer"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
<Caption onClick={onOpen} more={m}>
<div className="text-sm font-medium line-clamp-2 leading-tight min-h-[2lh] hover:text-accent">
{card.title}
</div>
<div className="text-xs text-muted">
{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
</div>
</div>
</Caption>
</div>
);
}
@@ -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<string, unknown>;
menuButtonProps?: (se: PlexSeasonDetail) => Record<string, unknown>;
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 (
<div
className="group text-left"
{...menuProps?.(se)}
{...(navIndex !== undefined && navItemAttrs(navIndex, se.title))}
>
<div
onClick={onOpen}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
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`}
>
<img
src={se.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
<div className="flex flex-col items-center gap-1 text-white">
<Play className="w-9 h-9" fill="currentColor" />
<span className="text-xs font-medium">{t("plex.openShow")}</span>
<div className="relative">
<div
onClick={onOpen}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
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`}
>
<img
src={se.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
<div className="flex flex-col items-center gap-1 text-white">
<Play className="w-9 h-9" fill="currentColor" />
<span className="text-xs font-medium">{t("plex.series.openSeason")}</span>
</div>
</div>
{watched && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${
onToggleWatched || onAdd || m ? HOVER_YIELD : ""
}`}
>
{t("plex.watched")}
</span>
)}
{/* 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" && (
<span className="pointer-events-none absolute top-1.5 left-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm">
{t("plex.inProgress")}
</span>
)}
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
{t("plex.series.episodeCount", { count: se.episode_count })}
</span>
</div>
{/* Quick watched toggle (whole season), on hover. */}
{onToggleWatched && (
<IconButton
variant="media"
onClick={(e) => {
e.stopPropagation();
onToggleWatched();
}}
label={watchedLabel}
className="absolute top-1.5 right-1.5 reveal"
>
<WatchedIcon watched={watched} />
</IconButton>
)}
{/* Add whole season to a playlist, on hover. */}
{onAdd && (
<IconButton
variant="media"
onClick={(e) => {
e.stopPropagation();
onAdd();
}}
label={t("plex.playlist.addSeason")}
className="absolute bottom-1.5 right-1.5 reveal"
>
<ListPlus />
</IconButton>
)}
{watched && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
!!onToggleWatched,
)}`}
>
{t("plex.watched")}
</span>
)}
{/* 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" && (
<span className="pointer-events-none absolute top-1.5 left-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm">
{t("plex.inProgress")}
</span>
)}
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
{t("plex.series.episodeCount", { count: se.episode_count })}
</span>
</div>
<div
onClick={onOpen}
className="mt-1.5 rounded-lg px-2 py-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
{se.title}
<HoverBar
watched={watched}
watchedLabel={
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addSeason")}
onAdd={onAdd}
more={m}
/>
</div>
<Caption onClick={onOpen} more={m} className="text-sm font-medium leading-tight">
<div className="line-clamp-2 hover:text-accent">{se.title}</div>
</Caption>
</div>
);
}
@@ -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<string, unknown>;
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
/** The id whose menu is open. */
openId?: string | null;
}) {
const { t } = useTranslation();
return (
@@ -430,32 +493,16 @@ export function RelatedStrip({
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
{related.map((r, i) => (
<Pressable
key={r.id}
{...navItemAttrs(i)}
{...menuProps?.(r)}
onClick={() => onOpen(r.id)}
className="group w-32.5 shrink-0"
>
<div
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200`}
>
<img
src={r.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{r.status === "watched" && (
<span className="absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
{t("plex.watched")}
</span>
)}
</div>
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight min-h-[2lh]">
{r.title}
</div>
</Pressable>
<div key={r.id} className="w-37.5 shrink-0">
<PlexPosterCard
card={r}
navIndex={i}
onOpen={() => onOpen(r)}
menuProps={menuProps}
menuButtonProps={menuButtonProps}
menuOpen={openId === r.id}
/>
</div>
))}
</Carousel>
</section>
@@ -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<string, unknown>;
menuButtonProps?: (c: PlexCard) => Record<string, unknown>;
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 (
<div className="group" {...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))}>
<div
onClick={onPlay}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
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"
>
<img
src={ep.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
<Play className="w-9 h-9 text-white" fill="currentColor" />
</div>
{/* Quick watched toggle (single episode), on hover. */}
{onToggleWatched && (
<IconButton
variant="media"
onClick={(e) => {
e.stopPropagation();
onToggleWatched();
}}
label={watchedLabel}
className="absolute top-1.5 right-1.5 reveal"
>
<WatchedIcon watched={ep.status === "watched"} />
</IconButton>
)}
{ep.status === "watched" && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
onToggleWatched ? "reveal-idle" : ""
}`}
>
<CheckCircle2 className="w-4 h-4 text-success" />
</span>
)}
{pct > 0 && (
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
<div
className="group"
{...menuProps?.(ep)}
{...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))}
>
<div className="relative">
<div
onClick={onPlay}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
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"
>
<img
src={ep.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
<Play className="w-9 h-9 text-white" fill="currentColor" />
</div>
)}
</div>
<div
className="mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
<div className="min-w-0 flex-1">
{withShowTitle && ep.show_title && (
<div className="text-2xs text-muted truncate">
{ep.show_title}
{ep.season_number != null && ep.episode_number != null
? ` · S${ep.season_number}·E${ep.episode_number}`
: ""}
{watched && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
onToggleWatched || onAdd || m ? HOVER_YIELD : ""
}`}
>
<CheckCircle2 className="w-4 h-4 text-success" />
</span>
)}
{pct > 0 && (
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
<div className="text-sm font-medium leading-tight">
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
{ep.title}
</div>
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
</div>
{onAdd && (
<IconButton onClick={onAdd} label={t("plex.playlist.addEpisode")}>
<ListPlus />
</IconButton>
)}
<HoverBar
watched={watched}
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addEpisode")}
onAdd={onAdd}
more={m}
/>
</div>
<Caption more={m}>
{withShowTitle && ep.show_title && (
<div className="text-2xs text-muted truncate">
{ep.show_title}
{ep.season_number != null && ep.episode_number != null
? ` · S${ep.season_number}·E${ep.episode_number}`
: ""}
</div>
)}
<div className="text-sm font-medium leading-tight">
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
{ep.title}
</div>
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
</Caption>
</div>
);
}
+116 -27
View File
@@ -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<PlexCard>();
// 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<PlexCard>();
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 ? (
<section className="mb-5">
@@ -160,9 +191,8 @@ export function PlexPersonView({
navIndex={i}
card={c}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
menuProps={pinMenu.triggerProps}
{...cardMenu(c)}
/>
))}
</NavGrid>
@@ -252,9 +282,8 @@ export function PlexPersonView({
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
menuProps={pinMenu.triggerProps}
{...cardMenu(c)}
/>
</div>
))}
@@ -272,7 +301,7 @@ export function PlexPersonView({
)}
</div>
)}
<PlexPinMenu menu={pinMenu} />
<PlexMenu menu={menu} actions={actions} />
</div>
);
}
@@ -313,7 +342,10 @@ export function PlexShowView({
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [collOpen, setCollOpen] = useState(false);
const [busy, setBusy] = useState(false);
const relatedPin = useContextMenu<PlexCard>();
const relatedMenu = useContextMenu<PlexCard>();
const related = usePlexCardActions({ onOpen: (c) => onOpenShow(c.id) }, relatedMenu.open);
const seasonMenu = useContextMenu<PlexSeasonDetail>();
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: <Tv2 className="w-4 h-4" />,
onSelect: () => onOpenSeason(se.id),
},
{
id: "watched",
label: seWatched
? t("plex.series.markSeasonUnwatched")
: t("plex.series.markSeasonWatched"),
icon: seWatched ? <Undo2 className="w-4 h-4" /> : <Check className="w-4 h-4" />,
separatorBefore: true,
onSelect: () => void markSeason(se.id, !seWatched),
},
...(add
? [
{
id: "playlist",
label: t("plex.playlist.addSeason"),
icon: <ListPlus className="w-4 h-4" />,
onSelect: add,
},
]
: []),
...copy(se.title, null).map((it) => ({ ...it, separatorBefore: true })),
];
};
return (
<div className="p-4 max-w-300 mx-auto glass-media">
@@ -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}
/>
))}
</NavGrid>
@@ -543,13 +612,20 @@ export function PlexShowView({
{d.related.length > 0 && showRelated && (
<RelatedStrip
related={d.related}
onOpen={onOpenShow}
menuProps={relatedPin.triggerProps}
onOpen={(c) => onOpenShow(c.id)}
menuProps={relatedMenu.triggerProps}
menuButtonProps={relatedMenu.buttonProps}
openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null}
/>
)}
</>
)}
<PlexPinMenu menu={relatedPin} />
<PlexMenu menu={relatedMenu} actions={related} />
<ContextMenu
menu={seasonMenu}
ariaLabel={t("plex.menu")}
items={seasonMenu.target ? seasonItems(seasonMenu.target) : []}
/>
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => 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<PlexCard>();
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(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 (
<div className="p-4 max-w-300 mx-auto glass-media">
@@ -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}
/>
))}
</NavGrid>
@@ -716,6 +804,7 @@ export function PlexSeasonView({
)}
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
<PlexMenu menu={epMenu} actions={episodes} pin={false} />
</div>
);
}
+8
View File
@@ -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";
@@ -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<PlexAddTarget | null>(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 ? <Play className={icon} /> : <Tv2 className={icon} />,
onSelect: () => onOpen(c),
},
]
: []),
...(playable && onToggleWatched
? [
{
id: "watched",
label: watched ? t("plex.markUnwatched") : t("plex.markWatched"),
icon: watched ? <Undo2 className={icon} /> : <Check className={icon} />,
separatorBefore: true,
onSelect: () => onToggleWatched(c),
},
]
: []),
...(playable
? [
{
id: "playlist",
label: addLabel(c),
icon: <ListPlus className={icon} />,
separatorBefore: !onToggleWatched,
onSelect: () => add(c),
},
]
: []),
...(playable && onInfo
? [
{
id: "details",
label: t("plex.info.title"),
icon: <Info className={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 && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />;
return { items, add, panel };
}
export type PlexCardActions = ReturnType<typeof usePlexCardActions>;
+2
View File
@@ -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": {
+2
View File
@@ -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": {
+5 -20
View File
@@ -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 {