feat(design): one media card, and Home's hero and shelves as cards (E28 S3h)
- ui/MediaCard + mediaCardStyles: one frame for every video, Plex title and Home tile; the meta line pinned to the bottom edge, the length a badge on the picture, the media Play only where the click plays, the hover row at the picture's bottom-right (the owner's pick C). - Shelf density: on any strip a one-line title over one meta line; mixed Home shelves are landscape; rails turn content-visibility off (a card's reserved height had become the strip's). Home slot 232px, so the Play and the hover row no longer touch; a landscape shelf takes the small Play. - Home hero is a card (hover row, the whole menu, a touch "…"), three quarters wide and centred; channel / playlist tiles get Play, open and "…"; a show tile acts on its next episode, ⓘ opens the show. - Plex preview: a ~0.9 s mouse rest opens a passive panel with the whole title, facts and synopsis (popover `pointerOnly`); the synopsis and title tooltips are gone. - Compact list row B: the title leads, "…" at the end, toggles over the date on hover. - Media-info layout B (collections, crew, cast beside the facts sidebar; an empty column is not drawn); filtering values are chips there and on the show page; "Cast" over the cast-only strips. - Browse tiles hover like a card; prose capped by the `measure` utility.
This commit is contained in:
@@ -51,7 +51,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{ch.description ? (
|
{ch.description ? (
|
||||||
<p className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
<p className="measure text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||||
{linkify(ch.description)}
|
{linkify(ch.description)}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
|
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||||
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
import { act, render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
@@ -785,3 +785,58 @@ describe("Home · keyboard", () => {
|
|||||||
expect(rails.map((rail) => rail.querySelectorAll("[data-nav-index]").length)).toEqual([2, 2]);
|
expect(rails.map((rail) => rail.querySelectorAll("[data-nav-index]").length)).toEqual([2, 2]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E28 S3h (the owner, 2026-09-26/27): the hero stands in three quarters of a wide page with its text
|
||||||
|
// centred beside the picture; one fact line, no repeated year, and prose only where there is some,
|
||||||
|
// at a readable measure.
|
||||||
|
describe("Home's hero text", () => {
|
||||||
|
const film = {
|
||||||
|
source: "plex",
|
||||||
|
kind: "movie",
|
||||||
|
id: "m1",
|
||||||
|
title: "A Film",
|
||||||
|
subtitle: "2003",
|
||||||
|
thumb: null,
|
||||||
|
aspect: "poster",
|
||||||
|
duration_seconds: 9240,
|
||||||
|
position_seconds: 0,
|
||||||
|
remaining_seconds: null,
|
||||||
|
status: "new",
|
||||||
|
video: null,
|
||||||
|
plex: {
|
||||||
|
id: "m1",
|
||||||
|
type: "movie",
|
||||||
|
title: "A Film",
|
||||||
|
thumb: "",
|
||||||
|
year: 2003,
|
||||||
|
rating: 7.8,
|
||||||
|
summary: "What the film is about.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("a film: the year once, in the fact line with the length and score; its summary at the measure", async () => {
|
||||||
|
discover.mockResolvedValue({ ...payload("v1"), hero: { tier: "surprise", card: film } });
|
||||||
|
mount();
|
||||||
|
const title = await screen.findByRole("heading", { name: "A Film" });
|
||||||
|
const text = title.parentElement!;
|
||||||
|
expect(within(text).getByText(/^2003 · \S/)).toBeTruthy();
|
||||||
|
expect(within(text).getByText("7.8")).toBeTruthy();
|
||||||
|
// the subtitle was the year alone: it is not printed a second time
|
||||||
|
expect(within(text).queryByText("2003")).toBeNull();
|
||||||
|
expect(within(text).getByText("What the film is about.").className).toContain("measure");
|
||||||
|
expect(title.closest("section")!.className).toContain("lg:w-3/4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a video: the channel as the subtitle, views and age as the facts, and no summary line", async () => {
|
||||||
|
discover.mockResolvedValue({
|
||||||
|
...payload("v1"),
|
||||||
|
hero: { tier: "surprise", card: ytCard("v9", "A video") },
|
||||||
|
continue_watching: [],
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
const text = (await screen.findByRole("heading", { name: "A video" })).parentElement!;
|
||||||
|
expect(within(text).getByText("A channel")).toBeTruthy();
|
||||||
|
expect(within(text).getByText(/^10 card\.views · /)).toBeTruthy();
|
||||||
|
expect(text.querySelector("p.measure")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -65,7 +65,13 @@ vi.mock("../lib/api", () => ({
|
|||||||
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
|
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
|
const openChannel = vi.fn();
|
||||||
|
vi.mock("./navigationContext", () => ({
|
||||||
|
useNavigationActions: () => ({
|
||||||
|
setPage: vi.fn(),
|
||||||
|
openChannel: (...a: unknown[]) => openChannel(...a),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
vi.mock("./plexContext", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
|
vi.mock("./plexContext", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
|
||||||
|
|
||||||
import Home from "./Home";
|
import Home from "./Home";
|
||||||
@@ -150,6 +156,7 @@ const item = (label: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
openChannel.mockReset();
|
||||||
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
|
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
|
||||||
window.history.replaceState(null, "");
|
window.history.replaceState(null, "");
|
||||||
discover.mockReset().mockResolvedValue(payload());
|
discover.mockReset().mockResolvedValue(payload());
|
||||||
@@ -166,6 +173,8 @@ describe("Home — the comfort shelf's context menu", () => {
|
|||||||
fireEvent.contextMenu(await screen.findByText("Comfort B"));
|
fireEvent.contextMenu(await screen.findByText("Comfort B"));
|
||||||
expect(labels()).toEqual([
|
expect(labels()).toEqual([
|
||||||
"home.hero.play",
|
"home.hero.play",
|
||||||
|
// A channel tile opens its channel (the owner, 2026-09-27: it had nothing a channel offers).
|
||||||
|
"card.menu.openChannel",
|
||||||
"home.shelf.moveFirst",
|
"home.shelf.moveFirst",
|
||||||
"home.shelf.moveLeft",
|
"home.shelf.moveLeft",
|
||||||
"home.shelf.moveRight",
|
"home.shelf.moveRight",
|
||||||
@@ -341,3 +350,28 @@ describe("Home — the comfort shelf's context menu", () => {
|
|||||||
expect(item("home.pin.add").disabled).toBe(true);
|
expect(item("home.pin.add").disabled).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The owner, 2026-09-27: every Home card offers what the same card offers elsewhere.
|
||||||
|
describe("Home — a card's own actions", () => {
|
||||||
|
it("opens a pinned channel from its tile's ↗", async () => {
|
||||||
|
mount();
|
||||||
|
await screen.findByText("Comfort B");
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getAllByRole("button", { name: "card.menu.openChannel" })[1] as HTMLElement,
|
||||||
|
);
|
||||||
|
expect(openChannel).toHaveBeenCalledWith("UCb", "Comfort B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the hero the card's whole menu, not the pin row alone", async () => {
|
||||||
|
discover.mockResolvedValue({
|
||||||
|
...payload(),
|
||||||
|
hero: { tier: "surprise", card: base("h1", "Hero video") },
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
fireEvent.contextMenu(await screen.findByRole("heading", { name: "Hero video" }));
|
||||||
|
const rows = labels();
|
||||||
|
expect(rows[0]).toBe("home.hero.play");
|
||||||
|
expect(rows).toContain("card.markWatched");
|
||||||
|
expect(rows).toContain("card.menu.openChannel");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+407
-161
@@ -6,9 +6,11 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsLeft,
|
ChevronsLeft,
|
||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
|
ArrowUpRight,
|
||||||
PinOff,
|
PinOff,
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Star,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
@@ -26,10 +28,9 @@ import {
|
|||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent";
|
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent";
|
||||||
import { formatDuration } from "../lib/format";
|
import { formatDuration, formatRuntime, formatViews, relativeTime } from "../lib/format";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
|
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
|
||||||
import { ComfortPinMenu } from "./ComfortPinMenu";
|
|
||||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { Carousel } from "./ui/Carousel";
|
import { Carousel } from "./ui/Carousel";
|
||||||
@@ -37,15 +38,29 @@ import { ContextMenu } from "./ui/ContextMenu";
|
|||||||
import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
|
import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
|
||||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import VideoCard from "./VideoCard";
|
import VideoCard, { VideoHoverActions } from "./VideoCard";
|
||||||
import { useVideoActions } from "./useVideoActions";
|
import { useVideoActions } from "./useVideoActions";
|
||||||
import { usePlexCardActions } from "./usePlexCardActions";
|
import { usePlexCardActions } from "./usePlexCardActions";
|
||||||
import { PlexPosterCard } from "./plexCards";
|
import { PlexHoverBar, PlexPosterCard } from "./plexCards";
|
||||||
|
import { usePlaylistsActions } from "./playlistsContext";
|
||||||
import { useNavigationActions } from "./navigationContext";
|
import { useNavigationActions } from "./navigationContext";
|
||||||
import { usePlexActions } from "./plexContext";
|
import { usePlexActions } from "./plexContext";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { IconButton } from "./ui/IconButton";
|
||||||
|
import { MoreButton } from "./ui/MoreButton";
|
||||||
|
import { HOVER_YIELD } from "./ui/reveal";
|
||||||
|
import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard";
|
||||||
|
import {
|
||||||
|
MEDIA_BAR,
|
||||||
|
MEDIA_HOVER_LAYER,
|
||||||
|
MEDIA_IMG,
|
||||||
|
MEDIA_PICTURE,
|
||||||
|
mediaPictureBox,
|
||||||
|
mediaTitleClass,
|
||||||
|
} from "./ui/mediaCardStyles";
|
||||||
|
|
||||||
// The Home surface (E24) — "Mit nézzek most?".
|
// The Home surface (E24) — "Mit nézzek most?".
|
||||||
//
|
//
|
||||||
@@ -68,11 +83,12 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
|
|||||||
// SUBSET, so a band added server-side and to `DISCOVER_BUDGETS` typechecked green while this row
|
// SUBSET, so a band added server-side and to `DISCOVER_BUDGETS` typechecked green while this row
|
||||||
// silently kept offering four.
|
// silently kept offering four.
|
||||||
|
|
||||||
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
|
// A rail tile's slot width. `cardsSmall` is the feed's own dense card, so the slot holds the same
|
||||||
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
|
// object the feed shows — not a Home-only variant that would drift from it. 232, not the 200 it was:
|
||||||
// drift from it. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
|
// at 200 the picture was 184×103 and its centred Play overlapped the hover row docked under it by
|
||||||
|
// 2px (the owner saw them run together, 2026-09-27); at 232 they stand ~7px apart. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
|
||||||
// see ui/README): the card also takes it as its MEASURED width, which must be real pixels.
|
// see ui/README): the card also takes it as its MEASURED width, which must be real pixels.
|
||||||
const YT_SLOT = 200;
|
const YT_SLOT = 232;
|
||||||
|
|
||||||
// How many already-offered cards the reroll remembers. Matched to the server's own cap
|
// How many already-offered cards the reroll remembers. Matched to the server's own cap
|
||||||
// (`routes/discover.MAX_EXCLUDE`) rather than to a smaller number of our own: at 8 the window rolled
|
// (`routes/discover.MAX_EXCLUDE`) rather than to a smaller number of our own: at 8 the window rolled
|
||||||
@@ -89,6 +105,151 @@ const SHELF_MOVES: readonly [ShelfMove, string, typeof ChevronLeft][] = [
|
|||||||
["last", "home.shelf.moveLast", ChevronsRight],
|
["last", "home.shelf.moveLast", ChevronsRight],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** A Home card's ONE action list — a shelf tile's and the hero's alike: Play (it resolves `next`),
|
||||||
|
* the card's own rows (a video's or a Plex title's, as the feed and the library show them; a
|
||||||
|
* channel's or a playlist's open row), then the shelf's rows. */
|
||||||
|
function useHomeCardMenu({
|
||||||
|
cards,
|
||||||
|
onPlay,
|
||||||
|
onInfo,
|
||||||
|
onState,
|
||||||
|
onToggleSave,
|
||||||
|
onToggleWatched,
|
||||||
|
onMove,
|
||||||
|
moveSaving = false,
|
||||||
|
}: {
|
||||||
|
cards: DiscoverCard[];
|
||||||
|
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
|
||||||
|
onInfo: (card: DiscoverCard) => void;
|
||||||
|
onState: (id: string, status: VideoStatus) => void;
|
||||||
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
|
onToggleWatched: (card: PlexCard) => void;
|
||||||
|
onMove?: (card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => void;
|
||||||
|
moveSaving?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
|
||||||
|
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
|
||||||
|
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
|
||||||
|
const menu = useContextMenu<DiscoverCard>();
|
||||||
|
const pin = useComfortPin(menu.open);
|
||||||
|
// 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 openPage = useOpenShelfPage();
|
||||||
|
const target = menu.target;
|
||||||
|
// What the pin row acts on: a comfort hero's pin is the shelf entry it came from — a pinned show's
|
||||||
|
// next episode is not itself pinned, and looking the episode up offered "Pin" under "From your
|
||||||
|
// shelf", a second pin (the owner's call, 2026-09-19). Any other card is its own.
|
||||||
|
const pinned = target ? (target.pinned_from ?? target) : null;
|
||||||
|
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
|
||||||
|
const items: ActionMenuItem[] = !target
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: "play",
|
||||||
|
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
|
||||||
|
icon: <Play className="w-4 h-4" />,
|
||||||
|
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
|
||||||
|
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
|
||||||
|
// than dropped, so the menu does not change shape from tile to tile.
|
||||||
|
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
|
||||||
|
onSelect: () => onPlay(target),
|
||||||
|
},
|
||||||
|
...(target.video
|
||||||
|
? 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))
|
||||||
|
: target.kind === "channel" || target.kind === "playlist"
|
||||||
|
? [{ ...openPage.item(target), separatorBefore: true }]
|
||||||
|
: []),
|
||||||
|
...(onMove && target.pin_id != null
|
||||||
|
? [
|
||||||
|
...SHELF_MOVES.map(([to, key, Icon], i) => ({
|
||||||
|
id: `shelf-${to}`,
|
||||||
|
label: t(key),
|
||||||
|
icon: <Icon className="w-4 h-4" />,
|
||||||
|
separatorBefore: i === 0,
|
||||||
|
// The same rule the move itself runs, so a row is never enabled onto a no-op.
|
||||||
|
disabled: moveSaving || shelfMoveTarget(shelfIdx, to, cards.length) < 0,
|
||||||
|
note: moveSaving && i === 0 ? t("home.shelf.moveSaving") : undefined,
|
||||||
|
onSelect: () => onMove(target, to, cards),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
id: "shelf-unpin",
|
||||||
|
label: t("home.pin.remove"),
|
||||||
|
icon: <PinOff className="w-4 h-4" />,
|
||||||
|
separatorBefore: true,
|
||||||
|
// The only row here that removes a curated entry, and it sat in the same colour as
|
||||||
|
// the four harmless reorders. `danger` matches the shared unpin row in
|
||||||
|
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
|
||||||
|
// rail below) composes for the same command — the two must not disagree.
|
||||||
|
danger: true,
|
||||||
|
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
|
||||||
|
// other surface it can never be in the "not known yet" state.
|
||||||
|
onSelect: () => pin.remove(target.pin_id as number, target.title),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: pin.item(pinTargetForCard(pinned ?? target), (pinned ?? target).title)),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
menu,
|
||||||
|
items,
|
||||||
|
panels: (
|
||||||
|
<>
|
||||||
|
{videoActions.panel}
|
||||||
|
{plexActions.panel}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
plexAdd: plexActions.add,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pinned channel's or playlist's own page: the shelf tile's ↗ and its menu's open row. */
|
||||||
|
function useOpenShelfPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { openChannel, setPage } = useNavigationActions();
|
||||||
|
const { setSelectedId } = usePlaylistsActions();
|
||||||
|
const open = (card: DiscoverCard) => {
|
||||||
|
if (card.kind === "channel") openChannel(card.id, card.title);
|
||||||
|
else {
|
||||||
|
setSelectedId(Number(card.id));
|
||||||
|
setPage("playlists");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const label = (card: DiscoverCard) =>
|
||||||
|
card.kind === "channel" ? t("card.menu.openChannel") : t("home.shelf.openPlaylist");
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
label,
|
||||||
|
item: (card: DiscoverCard): ActionMenuItem => ({
|
||||||
|
id: "open",
|
||||||
|
label: label(card),
|
||||||
|
icon: <ArrowUpRight className="w-4 h-4" />,
|
||||||
|
onSelect: () => open(card),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function Rail({
|
function Rail({
|
||||||
title,
|
title,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
@@ -118,83 +279,16 @@ function Rail({
|
|||||||
/** A reorder is being written: the move rows wait for it (one write at a time). */
|
/** A reorder is being written: the move rows wait for it (one write at a time). */
|
||||||
moveSaving?: boolean;
|
moveSaving?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { menu, items, panels, plexAdd } = useHomeCardMenu({
|
||||||
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
|
cards,
|
||||||
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
|
onPlay,
|
||||||
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
|
onInfo,
|
||||||
const menu = useContextMenu<DiscoverCard>();
|
onState,
|
||||||
const pin = useComfortPin(menu.open);
|
onToggleSave,
|
||||||
// A video tile's own action list (E28 C1), the same one the feed's menu shows; the rail keeps its
|
onToggleWatched,
|
||||||
// Play row (it resolves `next`) and its shelf rows around it.
|
onMove,
|
||||||
const videoActions = useVideoActions({ onState, onToggleSave }, menu.open);
|
moveSaving,
|
||||||
// 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
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
id: "play",
|
|
||||||
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
|
|
||||||
icon: <Play className="w-4 h-4" />,
|
|
||||||
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
|
|
||||||
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
|
|
||||||
// than dropped, so the menu does not change shape from tile to tile.
|
|
||||||
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
|
|
||||||
onSelect: () => onPlay(target),
|
|
||||||
},
|
|
||||||
...(target.video
|
|
||||||
? 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) => ({
|
|
||||||
id: `shelf-${to}`,
|
|
||||||
label: t(key),
|
|
||||||
icon: <Icon className="w-4 h-4" />,
|
|
||||||
separatorBefore: i === 0,
|
|
||||||
// The same rule the move itself runs, so a row is never enabled onto a no-op.
|
|
||||||
disabled: moveSaving || shelfMoveTarget(shelfIdx, to, cards.length) < 0,
|
|
||||||
note: moveSaving && i === 0 ? t("home.shelf.moveSaving") : undefined,
|
|
||||||
onSelect: () => onMove(target, to, cards),
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
id: "shelf-unpin",
|
|
||||||
label: t("home.pin.remove"),
|
|
||||||
icon: <PinOff className="w-4 h-4" />,
|
|
||||||
separatorBefore: true,
|
|
||||||
// The only row here that removes a curated entry, and it sat in the same colour as
|
|
||||||
// the four harmless reorders. `danger` matches the shared unpin row in
|
|
||||||
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
|
|
||||||
// rail below) composes for the same command — the two must not disagree.
|
|
||||||
danger: true,
|
|
||||||
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
|
|
||||||
// other surface it can never be in the "not known yet" state.
|
|
||||||
onSelect: () => pin.remove(target.pin_id as number, target.title),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: pin.item(pinTargetForCard(target), target.title)),
|
|
||||||
];
|
|
||||||
|
|
||||||
// One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the
|
// One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the
|
||||||
// card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile.
|
// card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile.
|
||||||
@@ -221,13 +315,19 @@ function Rail({
|
|||||||
{cards.map((c, i) => (
|
{cards.map((c, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${c.source}:${c.kind}:${c.id}`}
|
key={`${c.source}:${c.kind}:${c.id}`}
|
||||||
className={c.aspect === "poster" ? "w-37.5 shrink-0" : "shrink-0"}
|
// Every shelf card is landscape, a Plex movie or show too (its wide art): a shelf mixes
|
||||||
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
|
// YouTube and Plex, and a poster beside a 16:9 card stretched the whole row to the
|
||||||
|
// poster's height, leaving a hole in every video card (the owner's pick A, 2026-09-26 —
|
||||||
|
// Plex's and Netflix's own "Continue watching" rows do the same). A grid cell, so the card
|
||||||
|
// fills the slot: every card's meta line (pinned to its frame's bottom) sits on one line.
|
||||||
|
className="grid shrink-0 grid-cols-1"
|
||||||
|
style={{ width: YT_SLOT }}
|
||||||
>
|
>
|
||||||
{c.video ? (
|
{c.video ? (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
video={c.video}
|
video={c.video}
|
||||||
view="cardsSmall"
|
view="cardsSmall"
|
||||||
|
shelf
|
||||||
width={YT_SLOT}
|
width={YT_SLOT}
|
||||||
navIndex={i}
|
navIndex={i}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
@@ -240,23 +340,33 @@ function Rail({
|
|||||||
) : c.plex ? (
|
) : c.plex ? (
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
card={c.plex}
|
card={c.plex}
|
||||||
|
wide
|
||||||
|
shelf
|
||||||
navIndex={i}
|
navIndex={i}
|
||||||
onOpen={() => onPlay(c)}
|
onOpen={() => onPlay(c)}
|
||||||
onToggleWatched={onToggleWatched}
|
onToggleWatched={onToggleWatched}
|
||||||
onAdd={plexActions.add}
|
onAdd={plexAdd}
|
||||||
|
next={c.next?.plex}
|
||||||
|
onInfo={() => onInfo(c)}
|
||||||
menuProps={menuFor[i]}
|
menuProps={menuFor[i]}
|
||||||
menuButtonProps={buttonFor[i]}
|
menuButtonProps={buttonFor[i]}
|
||||||
menuOpen={menu.open && menu.target === c}
|
menuOpen={menu.open && menu.target === c}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
|
<ShelfTile
|
||||||
|
card={c}
|
||||||
|
onPlay={onPlay}
|
||||||
|
menuProps={menuFor[i]?.()}
|
||||||
|
menuButtonProps={buttonFor[i]?.()}
|
||||||
|
menuOpen={menu.open && menu.target === c}
|
||||||
|
navIndex={i}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
|
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
|
||||||
{videoActions.panel}
|
{panels}
|
||||||
{plexActions.panel}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -268,45 +378,90 @@ function ShelfTile({
|
|||||||
card,
|
card,
|
||||||
onPlay,
|
onPlay,
|
||||||
menuProps,
|
menuProps,
|
||||||
|
menuButtonProps,
|
||||||
|
menuOpen = false,
|
||||||
navIndex,
|
navIndex,
|
||||||
}: {
|
}: {
|
||||||
card: DiscoverCard;
|
card: DiscoverCard;
|
||||||
onPlay: (card: DiscoverCard) => void;
|
onPlay: (card: DiscoverCard) => void;
|
||||||
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
|
menuProps?: Record<string, unknown>;
|
||||||
|
/** The rail menu's "…" for this tile. */
|
||||||
|
menuButtonProps?: Record<string, unknown>;
|
||||||
|
menuOpen?: boolean;
|
||||||
navIndex?: number;
|
navIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const openPage = useOpenShelfPage();
|
||||||
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
|
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
|
||||||
const countLabel =
|
const countLabel =
|
||||||
card.kind === "channel"
|
card.kind === "channel"
|
||||||
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
|
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
|
||||||
: t("home.shelf.itemCount", { count: count ?? 0 });
|
: t("home.shelf.itemCount", { count: count ?? 0 });
|
||||||
|
// The one media card (ui/MediaCard), like the videos and posters beside it on the shelf: the
|
||||||
|
// picture is the play button, the count the meta line pinned to the frame's bottom.
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<MediaCard
|
||||||
selectable
|
dense
|
||||||
onClick={() => card.next && onPlay(card)}
|
rootProps={{
|
||||||
{...menuProps?.(card)}
|
...menuProps,
|
||||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
...(navIndex !== undefined && navItemAttrs(navIndex)),
|
||||||
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
|
}}
|
||||||
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
|
className={!card.next ? "opacity-60" : undefined}
|
||||||
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
|
picture={
|
||||||
// than it looks: a real `disabled` button DOES still receive `contextmenu` and `pointerdown`,
|
// The ratio lives on a wrapper, as in the other cards: MEDIA_PICTURE fills its parent, and
|
||||||
// so mouse and long-press would both survive it — what it loses is FOCUS, and with it
|
// on the stretched frame itself that would be the whole card's height.
|
||||||
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
|
<div className={mediaPictureBox("wide")}>
|
||||||
// alive to all three input paths.
|
<Pressable
|
||||||
aria-disabled={!card.next}
|
onClick={() => card.next && onPlay(card)}
|
||||||
className={`${SURFACE.card} glass-hover block w-full overflow-hidden transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent`}
|
aria-label={card.title}
|
||||||
>
|
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
|
||||||
<span className="block aspect-video bg-card overflow-hidden">
|
// playable, but it IS the tile you most want to take off the shelf, and the only way to
|
||||||
{card.thumb ? (
|
// do that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is
|
||||||
<img src={card.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
|
// narrower than it looks: a real `disabled` button DOES still receive `contextmenu` and
|
||||||
) : null}
|
// `pointerdown`, so mouse and long-press would both survive it — what it loses is FOCUS,
|
||||||
</span>
|
// and with it Shift+F10, the only keyboard route to the menu. Inert to the click (the
|
||||||
<span className="block p-2">
|
// handler guards), alive to all three input paths.
|
||||||
<span className="block text-xs font-medium truncate">{card.title}</span>
|
aria-disabled={!card.next}
|
||||||
<span className="block text-2xs text-muted">{countLabel}</span>
|
data-nav-open=""
|
||||||
</span>
|
className={MEDIA_PICTURE}
|
||||||
</Pressable>
|
>
|
||||||
|
{card.thumb ? (
|
||||||
|
<img src={card.thumb} alt="" loading="lazy" className={MEDIA_IMG} />
|
||||||
|
) : null}
|
||||||
|
{/* The Play only when there is something to play: the click plays the channel's newest
|
||||||
|
unwatched video, the playlist's first. */}
|
||||||
|
<div className={MEDIA_HOVER_LAYER}>{card.next && <MediaPlayGlyph small />}</div>
|
||||||
|
</Pressable>
|
||||||
|
{/* The rest of what a tile like this offers, as the video and Plex cards beside it do:
|
||||||
|
its own page, and the "…" to the whole list (a sibling of the picture's button). */}
|
||||||
|
<div className={clsx(MEDIA_BAR, "flex gap-1")}>
|
||||||
|
<IconButton
|
||||||
|
variant="media"
|
||||||
|
onClick={() => openPage.open(card)}
|
||||||
|
label={openPage.label(card)}
|
||||||
|
>
|
||||||
|
<ArrowUpRight />
|
||||||
|
</IconButton>
|
||||||
|
{menuButtonProps && (
|
||||||
|
<MoreButton
|
||||||
|
variant="media"
|
||||||
|
label={t("card.menu.more")}
|
||||||
|
expanded={menuOpen}
|
||||||
|
menuProps={menuButtonProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<span className={`min-h-6 ${mediaTitleClass(true, true)} select-text`} title={card.title}>
|
||||||
|
{card.title}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
meta={<span className="block text-xs leading-6 text-muted">{countLabel}</span>}
|
||||||
|
more={menuButtonProps && { props: menuButtonProps, open: menuOpen }}
|
||||||
|
moreLabel={t("card.menu.more")}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,19 +469,34 @@ function Hero({
|
|||||||
tier,
|
tier,
|
||||||
card,
|
card,
|
||||||
onPlay,
|
onPlay,
|
||||||
|
onInfo,
|
||||||
|
onState,
|
||||||
|
onToggleSave,
|
||||||
|
onToggleWatched,
|
||||||
onReroll,
|
onReroll,
|
||||||
rerolling,
|
rerolling,
|
||||||
}: {
|
}: {
|
||||||
tier: HeroTier;
|
tier: HeroTier;
|
||||||
card: DiscoverCard;
|
card: DiscoverCard;
|
||||||
onPlay: (card: DiscoverCard) => void;
|
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
|
||||||
|
onInfo: (card: DiscoverCard) => void;
|
||||||
|
onState: (id: string, status: VideoStatus) => void;
|
||||||
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
|
onToggleWatched: (card: PlexCard) => void;
|
||||||
onReroll: () => void;
|
onReroll: () => void;
|
||||||
rerolling: boolean;
|
rerolling: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// The hero is the likeliest thing on the page to be comfort media — it is what the surface just
|
// The hero is a card like any other (the owner, 2026-09-27): the same hover row on its picture and
|
||||||
// answered with — so it answers the same right-click every card in the app does.
|
// the same whole action list on its "…", right-click and long-press — it was a pin row alone.
|
||||||
const pinMenu = useContextMenu<DiscoverCard>();
|
const { menu, items, panels, plexAdd } = useHomeCardMenu({
|
||||||
|
cards: [card],
|
||||||
|
onPlay,
|
||||||
|
onInfo,
|
||||||
|
onState,
|
||||||
|
onToggleSave,
|
||||||
|
onToggleWatched,
|
||||||
|
});
|
||||||
const started = card.position_seconds > 0;
|
const started = card.position_seconds > 0;
|
||||||
const left = card.remaining_seconds;
|
const left = card.remaining_seconds;
|
||||||
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
|
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
|
||||||
@@ -340,41 +510,111 @@ function Hero({
|
|||||||
? t("home.hero.comfort")
|
? t("home.hero.comfort")
|
||||||
: t("home.hero.surprise");
|
: t("home.hero.surprise");
|
||||||
|
|
||||||
return (
|
// One line of facts under the title: a film's year, length and score; a video's views and age.
|
||||||
<section
|
// The picture's column is exactly the picture's width (it was 18rem around a 12rem poster: a 6rem
|
||||||
{...pinMenu.triggerProps(card)}
|
// hole), and the text stands centred beside it, so a video — shorter picture, fewer facts — fills
|
||||||
className={`${SURFACE.panel} mb-6 p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]`}
|
// its hero as well as a film does (the owner's pick, 2026-09-26).
|
||||||
>
|
const plex = card.plex;
|
||||||
<Pressable
|
const video = card.video;
|
||||||
onClick={() => onPlay(card)}
|
const facts = plex
|
||||||
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${
|
? [plex.year, formatRuntime(card.duration_seconds)]
|
||||||
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
|
: video
|
||||||
}`}
|
? // The channel is the subtitle already.
|
||||||
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
|
[
|
||||||
>
|
video.view_count != null && `${formatViews(video.view_count)} ${t("card.views")}`,
|
||||||
{card.thumb ? <img src={card.thumb} alt="" className="w-full h-full object-cover" /> : null}
|
relativeTime(video.published_at),
|
||||||
{card.duration_seconds != null && (
|
]
|
||||||
<span className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums">
|
: [];
|
||||||
{formatDuration(card.duration_seconds)}
|
const factLine = facts.filter(Boolean).join(" · ");
|
||||||
</span>
|
// A feed video carries no description, so only a film or an episode has a summary line.
|
||||||
)}
|
const summary = plex?.summary;
|
||||||
{started && card.duration_seconds ? (
|
// A film's subtitle is its year, which the fact line already says.
|
||||||
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
|
const subtitle = card.subtitle !== String(plex?.year ?? "") ? card.subtitle : null;
|
||||||
<span
|
|
||||||
className="block h-full bg-accent"
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col">
|
return (
|
||||||
|
// Three quarters of a wide page, centred, so the one answer stands in focus (the owner, 2026-09-27);
|
||||||
|
// a narrower window gives it the whole width back. The padding is even on every side.
|
||||||
|
<section
|
||||||
|
{...menu.triggerProps(card)}
|
||||||
|
className={`${SURFACE.panel} mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-center lg:mx-auto lg:w-3/4`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`group relative shrink-0 ${
|
||||||
|
card.aspect === "poster" ? "aspect-[2/3] w-40 sm:w-48" : "aspect-video w-full sm:w-72"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onClick={() => onPlay(card)}
|
||||||
|
className="relative block h-full w-full overflow-hidden rounded-lg border border-border bg-card"
|
||||||
|
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
|
||||||
|
>
|
||||||
|
{card.thumb ? (
|
||||||
|
<img src={card.thumb} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : null}
|
||||||
|
{card.duration_seconds != null && (
|
||||||
|
// It steps aside while the hover row covers its corner, as a card's length does.
|
||||||
|
<span
|
||||||
|
className={`absolute bottom-1.5 right-1.5 rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums ${HOVER_YIELD}`}
|
||||||
|
>
|
||||||
|
{formatDuration(card.duration_seconds)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{started && card.duration_seconds ? (
|
||||||
|
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
|
||||||
|
<span
|
||||||
|
className="block h-full bg-accent"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className={MEDIA_HOVER_LAYER}>
|
||||||
|
<MediaPlayGlyph />
|
||||||
|
</div>
|
||||||
|
</Pressable>
|
||||||
|
{/* The card's own hover row, as on every card: a sibling of the picture's button. */}
|
||||||
|
{card.video ? (
|
||||||
|
<div className={MEDIA_BAR}>
|
||||||
|
<VideoHoverActions
|
||||||
|
video={card.video}
|
||||||
|
onState={onState}
|
||||||
|
onToggleSave={onToggleSave}
|
||||||
|
media
|
||||||
|
more={{ props: menu.buttonProps(card), open: menu.open }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : card.plex ? (
|
||||||
|
<PlexHoverBar
|
||||||
|
card={card.plex}
|
||||||
|
onToggleWatched={onToggleWatched}
|
||||||
|
onAdd={plexAdd}
|
||||||
|
moreProps={menu.buttonProps(card)}
|
||||||
|
moreOpen={menu.open}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
|
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
|
||||||
<h2 className="mt-1 text-lg font-semibold leading-tight">{card.title}</h2>
|
<h2 className="mt-1 text-lg font-semibold leading-tight">{card.title}</h2>
|
||||||
{card.subtitle && <p className="mt-0.5 text-xs text-muted truncate">{card.subtitle}</p>}
|
{subtitle && <p className="mt-0.5 text-xs text-muted truncate">{subtitle}</p>}
|
||||||
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3">
|
{(factLine || plex?.rating != null) && (
|
||||||
|
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
|
||||||
|
{factLine && <span>{factLine}</span>}
|
||||||
|
{plex?.rating != null && (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
{factLine && <span aria-hidden>·</span>}
|
||||||
|
<Star className="h-3 w-3 fill-current text-rating" aria-hidden />
|
||||||
|
{plex.rating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* Only when there is one (a missing summary leaves no gap, the stack is centred), at the
|
||||||
|
reading measure: the eye finds the next line's start again (it ran ~130 characters). */}
|
||||||
|
{summary && <p className="mt-2 measure line-clamp-2 text-sm text-muted">{summary}</p>}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-4">
|
||||||
<Button icon={<Play className="h-4 w-4" />} onClick={() => onPlay(card)}>
|
<Button icon={<Play className="h-4 w-4" />} onClick={() => onPlay(card)}>
|
||||||
{started ? t("home.hero.continue") : t("home.hero.play")}
|
{started ? t("home.hero.continue") : t("home.hero.play")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -386,16 +626,18 @@ function Hero({
|
|||||||
>
|
>
|
||||||
{t("home.hero.reroll")}
|
{t("home.hero.reroll")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* On touch the picture's hover row is gone; the "…" stands here instead, as it sits
|
||||||
|
beside a card's title. */}
|
||||||
|
<MoreButton
|
||||||
|
className="touch-only"
|
||||||
|
label={t("card.menu.more")}
|
||||||
|
expanded={menu.open}
|
||||||
|
menuProps={menu.buttonProps(card)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* A comfort hero acts on the pin it came from: a pinned show's next episode is not itself
|
<ContextMenu menu={menu} ariaLabel={card.title} items={items} />
|
||||||
pinned, and looking the episode up offered "Pin" under "From your shelf" — a second pin.
|
{panels}
|
||||||
The owner's call (2026-09-19): the row unpins the show. */}
|
|
||||||
<ComfortPinMenu
|
|
||||||
menu={pinMenu}
|
|
||||||
target={(c) => pinTargetForCard(c.pinned_from ?? c)}
|
|
||||||
title={(c) => (c.pinned_from ?? c).title}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -776,6 +1018,10 @@ export default function Home() {
|
|||||||
tier={hero.tier}
|
tier={hero.tier}
|
||||||
card={hero.card}
|
card={hero.card}
|
||||||
onPlay={onPlay}
|
onPlay={onPlay}
|
||||||
|
onInfo={onInfo}
|
||||||
|
onState={onState}
|
||||||
|
onToggleSave={onToggleSave}
|
||||||
|
onToggleWatched={onToggleWatched}
|
||||||
onReroll={() => reroll()}
|
onReroll={() => reroll()}
|
||||||
rerolling={rerolling}
|
rerolling={rerolling}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export default function PlexCollectionView({
|
|||||||
{d.summary && (
|
{d.summary && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<p
|
<p
|
||||||
className={`text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-3"}`}
|
className={`measure text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-3"}`}
|
||||||
>
|
>
|
||||||
{d.summary}
|
{d.summary}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -70,8 +70,9 @@ function CardRail({
|
|||||||
keyboardNav
|
keyboardNav
|
||||||
>
|
>
|
||||||
{cards.map((c, i) => (
|
{cards.map((c, i) => (
|
||||||
<div key={c.id} className="w-37.5 shrink-0">
|
<div key={c.id} className="grid w-37.5 shrink-0 grid-cols-1">
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
|
shelf
|
||||||
card={c}
|
card={c}
|
||||||
navIndex={i}
|
navIndex={i}
|
||||||
onOpen={() => onOpenCard(c)}
|
onOpen={() => onOpenCard(c)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// countries), the keyword chips (→ onFilter), and the full crew (→ onOpenPerson). i18n is
|
// countries), the keyword chips (→ onFilter), and the full crew (→ onOpenPerson). i18n is
|
||||||
// key-passthrough; PlexInfo is prop-driven (no queries) so it just needs a QueryClient for its cache
|
// key-passthrough; PlexInfo is prop-driven (no queries) so it just needs a QueryClient for its cache
|
||||||
// reads. The overlay variant is asserted to stay lean (no facts/keywords/crew).
|
// reads. The overlay variant is asserted to stay lean (no facts/keywords/crew).
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
@@ -156,3 +156,60 @@ describe("PlexInfo enriched detail (page)", () => {
|
|||||||
expect(screen.getByText("IMDb")).toBeTruthy();
|
expect(screen.getByText("IMDb")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E28 S3h, the owner's layout B: a main column (collection strips → crew → cast) beside a sidebar of
|
||||||
|
// facts (details, keywords); a column with nothing in it is not rendered and the other takes the width.
|
||||||
|
describe("PlexInfo layout B (page)", () => {
|
||||||
|
const TWO_COLUMNS = "lg:grid-cols-[minmax(0,1fr)_20rem]";
|
||||||
|
// The strips are Carousels, which watch their size.
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
});
|
||||||
|
const heading = (name: string) => screen.getByRole("heading", { name });
|
||||||
|
const column = (el: HTMLElement) => el.closest("div.flex-col") as HTMLElement;
|
||||||
|
const full: PlexItemDetail = {
|
||||||
|
...detail,
|
||||||
|
cast: [{ name: "An Actor", role: "Hero" }],
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
id: "c1",
|
||||||
|
title: "The Collection",
|
||||||
|
source: "plex",
|
||||||
|
items: [{ id: "m2", type: "movie", title: "Sequel", thumb: "/api/plex/image/m2" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("stacks the collection, the crew and the cast in the main column, the facts beside it", () => {
|
||||||
|
wrap(<PlexInfo detail={full} variant="page" />);
|
||||||
|
const coll = heading("The Collection");
|
||||||
|
const crew = heading("plex.info.crew");
|
||||||
|
const cast = heading("plex.info.cast");
|
||||||
|
const facts = heading("plex.info.facts.title");
|
||||||
|
const follows = (a: HTMLElement, b: HTMLElement) =>
|
||||||
|
!!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||||
|
expect(follows(coll, crew) && follows(crew, cast)).toBe(true);
|
||||||
|
const main = column(coll);
|
||||||
|
expect(main.contains(crew) && main.contains(cast)).toBe(true);
|
||||||
|
const side = column(facts);
|
||||||
|
expect(side).not.toBe(main);
|
||||||
|
expect(side).toBe(column(heading("plex.info.keywords")));
|
||||||
|
// the two columns are the two children of the one grid
|
||||||
|
expect(main.parentElement!.className).toContain(TWO_COLUMNS);
|
||||||
|
expect([...main.parentElement!.children]).toEqual([main, side]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with nothing for the main column, the sidebar's sections take the full width", () => {
|
||||||
|
wrap(<PlexInfo detail={{ ...detail, tmdb: { ...detail.tmdb!, crew: [] } }} variant="page" />);
|
||||||
|
expect(screen.queryByRole("heading", { name: "plex.info.crew" })).toBeNull();
|
||||||
|
const side = column(heading("plex.info.facts.title"));
|
||||||
|
expect(side).toBe(column(heading("plex.info.keywords")));
|
||||||
|
expect(side.parentElement!.className).not.toContain(TWO_COLUMNS);
|
||||||
|
expect([...side.parentElement!.children]).toEqual([side]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { SURFACE } from "./ui/surfaces";
|
|||||||
import { Badge } from "./ui/Badge";
|
import { Badge } from "./ui/Badge";
|
||||||
import { IconLink } from "./ui/LinkButton";
|
import { IconLink } from "./ui/LinkButton";
|
||||||
import { PageHeading } from "./ui/Headings";
|
import { PageHeading } from "./ui/Headings";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { PlexPosterCard } from "./plexCards";
|
||||||
|
|
||||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||||
@@ -156,6 +158,7 @@ function RatingsRow({
|
|||||||
|
|
||||||
// A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries,
|
// A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries,
|
||||||
// pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something.
|
// pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something.
|
||||||
|
// What filters the library is an edged chip, a plain fact stays flat text (E28 S3h, the S3d1 rule).
|
||||||
function FactsPanel({
|
function FactsPanel({
|
||||||
detail,
|
detail,
|
||||||
onFilter,
|
onFilter,
|
||||||
@@ -175,9 +178,9 @@ function FactsPanel({
|
|||||||
label: t("plex.info.facts.release"),
|
label: t("plex.info.facts.release"),
|
||||||
node: (
|
node: (
|
||||||
<Filterable
|
<Filterable
|
||||||
|
tag
|
||||||
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
|
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
|
||||||
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
|
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
|
||||||
className="hover:text-accent"
|
|
||||||
>
|
>
|
||||||
{year}
|
{year}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
@@ -195,10 +198,7 @@ function FactsPanel({
|
|||||||
key: "cert",
|
key: "cert",
|
||||||
label: t("plex.info.facts.cert"),
|
label: t("plex.info.facts.cert"),
|
||||||
node: (
|
node: (
|
||||||
<Filterable
|
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
|
||||||
onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}
|
|
||||||
className="hover:text-accent"
|
|
||||||
>
|
|
||||||
{cert}
|
{cert}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
),
|
),
|
||||||
@@ -212,17 +212,15 @@ function FactsPanel({
|
|||||||
key: "studios",
|
key: "studios",
|
||||||
label: t("plex.info.facts.studios"),
|
label: t("plex.info.facts.studios"),
|
||||||
node: (
|
node: (
|
||||||
<span className="flex flex-wrap gap-x-1.5 gap-y-0.5">
|
<span className="flex flex-wrap gap-1.5">
|
||||||
{studios.map((s, i) => (
|
{studios.map((s) => (
|
||||||
<span key={s}>
|
<Filterable
|
||||||
<Filterable
|
key={s}
|
||||||
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
|
tag
|
||||||
className="hover:text-accent"
|
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
|
||||||
>
|
>
|
||||||
{s}
|
{s}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
{i < studios.length - 1 ? "," : ""}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@@ -237,7 +235,7 @@ function FactsPanel({
|
|||||||
return (
|
return (
|
||||||
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
||||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
|
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
|
||||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
<dl className="grid grid-cols-[auto_1fr] items-baseline gap-x-4 gap-y-2 text-sm">
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<div key={r.key} className="contents">
|
<div key={r.key} className="contents">
|
||||||
<dt className="text-muted">{r.label}</dt>
|
<dt className="text-muted">{r.label}</dt>
|
||||||
@@ -291,9 +289,8 @@ function CrewPanel({
|
|||||||
return (
|
return (
|
||||||
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
||||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
|
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
|
||||||
{/* Two columns inside the (now narrow) side box — viewport media-queries can't see the box width,
|
{/* In the main column (layout B): as many name columns as its width holds. */}
|
||||||
so a fixed small column count keeps names from over-spreading. */}
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-x-3 gap-y-1.5">
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
|
|
||||||
{crew.map((c, i) => (
|
{crew.map((c, i) => (
|
||||||
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
|
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
|
||||||
{onOpenPerson ? (
|
{onOpenPerson ? (
|
||||||
@@ -536,7 +533,8 @@ export default function PlexInfo({
|
|||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||||
{detail.year != null && (
|
{detail.year != null && (
|
||||||
<Filterable
|
<Filterable
|
||||||
className={mutedCls}
|
tag
|
||||||
|
onDark={overlay}
|
||||||
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
|
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
|
||||||
onClick={
|
onClick={
|
||||||
onFilter
|
onFilter
|
||||||
@@ -549,7 +547,8 @@ export default function PlexInfo({
|
|||||||
)}
|
)}
|
||||||
{detail.content_rating && (
|
{detail.content_rating && (
|
||||||
<Filterable
|
<Filterable
|
||||||
className={mutedCls}
|
tag
|
||||||
|
onDark={overlay}
|
||||||
onClick={
|
onClick={
|
||||||
onFilter
|
onFilter
|
||||||
? () => onFilter({ contentRatings: [detail.content_rating!] })
|
? () => onFilter({ contentRatings: [detail.content_rating!] })
|
||||||
@@ -564,7 +563,8 @@ export default function PlexInfo({
|
|||||||
) : null}
|
) : null}
|
||||||
{detail.studio && (
|
{detail.studio && (
|
||||||
<Filterable
|
<Filterable
|
||||||
className={mutedCls}
|
tag
|
||||||
|
onDark={overlay}
|
||||||
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
|
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
|
||||||
>
|
>
|
||||||
{detail.studio}
|
{detail.studio}
|
||||||
@@ -608,25 +608,24 @@ export default function PlexInfo({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{detail.directors.length > 0 && (
|
{detail.directors.length > 0 && (
|
||||||
<p className={`mt-2 text-sm ${mutedCls}`}>
|
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
|
||||||
{t("plex.info.director")}:{" "}
|
<span>{t("plex.info.director")}:</span>
|
||||||
{detail.directors.map((d, i) => (
|
{detail.directors.map((d) => (
|
||||||
<span key={d}>
|
<Filterable
|
||||||
<Filterable
|
key={d}
|
||||||
className="font-medium"
|
tag
|
||||||
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
|
onDark={overlay}
|
||||||
>
|
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
|
||||||
{d}
|
>
|
||||||
</Filterable>
|
{d}
|
||||||
{i < detail.directors.length - 1 ? ", " : ""}
|
</Filterable>
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detail.summary && (
|
{detail.summary && (
|
||||||
<p
|
<p
|
||||||
className={`mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
|
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
|
||||||
>
|
>
|
||||||
{detail.summary}
|
{detail.summary}
|
||||||
</p>
|
</p>
|
||||||
@@ -679,100 +678,18 @@ export default function PlexInfo({
|
|||||||
</div>
|
</div>
|
||||||
{/* End hero panel. */}
|
{/* End hero panel. */}
|
||||||
|
|
||||||
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. Details / Keywords /
|
{/* Below the hero (E28 S3h, the owner's layout B, 2026-09-26): a main column — the collection
|
||||||
Crew sit side by side (each its natural height, items-start) so no single box hogs the full
|
strips first (the owner's most used), then the crew, then the cast — beside a narrow
|
||||||
width with dead space; each is independently hideable from the customize menu. */}
|
sidebar of facts (details, keywords), the TMDB/IMDb shape. Every section is per-user
|
||||||
{!overlay && (showFacts || showKeywords || showCrew) && (
|
toggleable, so the layout is built from what is ON: a column with nothing in it is not
|
||||||
<div className="grid items-start gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
rendered and the other takes the full width; inside a column the sections stack, so a
|
||||||
{showFacts && <FactsPanel detail={detail} onFilter={onFilter} />}
|
hidden one leaves no hole. Narrow windows stack the sidebar under the main column. The
|
||||||
{showKeywords && (
|
overlay (over the running player) stays lean: its strips and its cast, stacked. */}
|
||||||
<KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} />
|
{(() => {
|
||||||
)}
|
const collections = (detail.collections ?? []).filter(
|
||||||
{showCrew && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
|
(col) => !hiddenSources.includes(col.source),
|
||||||
</div>
|
);
|
||||||
)}
|
const strips = collections.map((col) => (
|
||||||
|
|
||||||
{/* Cast row (toggleable), circular photos. */}
|
|
||||||
{cast.length > 0 && (
|
|
||||||
<div className={overlay ? "mt-5" : `${SURFACE.panel} p-4 sm:p-5`}>
|
|
||||||
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
|
||||||
{t("plex.info.cast")}
|
|
||||||
</h2>
|
|
||||||
<Carousel
|
|
||||||
ariaLabel={t("plex.info.cast")}
|
|
||||||
gap="gap-3"
|
|
||||||
keyboardNav={!!(onOpenPerson || onFilter)}
|
|
||||||
>
|
|
||||||
{cast.map((c, i) => {
|
|
||||||
const clickable = !!(onOpenPerson || onFilter);
|
|
||||||
const inner = (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
|
|
||||||
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
|
|
||||||
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
|
|
||||||
>
|
|
||||||
{c.thumb ? (
|
|
||||||
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={`grid h-full w-full place-items-center text-lg opacity-40 ${overlay ? "text-white" : ""}`}
|
|
||||||
>
|
|
||||||
{c.name.charAt(0)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
|
|
||||||
clickable ? "group-hover/cast:text-accent" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{c.name}
|
|
||||||
</div>
|
|
||||||
{c.role && (
|
|
||||||
<div
|
|
||||||
className={`truncate text-2xs ${overlay ? "text-white/55" : "text-muted"}`}
|
|
||||||
>
|
|
||||||
{c.role}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return clickable ? (
|
|
||||||
<Pressable
|
|
||||||
selectable
|
|
||||||
key={i}
|
|
||||||
align="center"
|
|
||||||
{...navItemAttrs(i)}
|
|
||||||
onClick={() =>
|
|
||||||
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
onOpenPerson
|
|
||||||
? t("plex.info.viewPerson", { name: c.name })
|
|
||||||
: t("plex.info.filterActor", { name: c.name })
|
|
||||||
}
|
|
||||||
className="group/cast w-20 shrink-0"
|
|
||||||
>
|
|
||||||
{inner}
|
|
||||||
</Pressable>
|
|
||||||
) : (
|
|
||||||
<div key={i} className="w-20 shrink-0 text-center">
|
|
||||||
{inner}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Carousel>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* (Full crew now lives in the enriched Details/Keywords/Crew grid above the cast row.) */}
|
|
||||||
|
|
||||||
{/* Collection strips — the other titles in this movie's collection(s), directly playable.
|
|
||||||
Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */}
|
|
||||||
{detail.collections
|
|
||||||
?.filter((col) => !hiddenSources.includes(col.source))
|
|
||||||
.map((col) => (
|
|
||||||
<div key={col.id} className={overlay ? "mt-6" : `${SURFACE.panel} p-4 sm:p-5`}>
|
<div key={col.id} className={overlay ? "mt-6" : `${SURFACE.panel} p-4 sm:p-5`}>
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||||
@@ -795,62 +712,134 @@ export default function PlexInfo({
|
|||||||
</div>
|
</div>
|
||||||
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
|
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
|
||||||
{col.items.map((m, i) => (
|
{col.items.map((m, i) => (
|
||||||
<Pressable
|
// The one media card, as on every other strip (E28 S3h). A grid cell, so the
|
||||||
selectable
|
// card fills the slot the carousel stretches to its tallest card.
|
||||||
key={m.id}
|
<div key={m.id} className="grid w-37.5 shrink-0 grid-cols-1">
|
||||||
{...navItemAttrs(i)}
|
<PlexPosterCard
|
||||||
onClick={() => onPlayItem?.(m.id)}
|
card={{ ...m, type: "movie" }}
|
||||||
disabled={!onPlayItem}
|
shelf
|
||||||
className="group/col w-28 shrink-0"
|
navIndex={i}
|
||||||
>
|
onOpen={() => onPlayItem?.(m.id)}
|
||||||
<div
|
/>
|
||||||
className={`relative aspect-2/3 overflow-hidden rounded-lg border ${
|
</div>
|
||||||
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={m.thumb}
|
|
||||||
alt=""
|
|
||||||
className="h-full w-full object-cover transition group-hover/col:scale-105"
|
|
||||||
/>
|
|
||||||
{m.status === "watched" && (
|
|
||||||
<span className="absolute right-1 top-1 rounded-sm bg-black/70 px-1 text-[0.5625rem] text-white">
|
|
||||||
✓
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{onPlayItem && (
|
|
||||||
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/col:opacity-100">
|
|
||||||
<Play className="h-8 w-8 text-white" fill="currentColor" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(m.position_seconds ?? 0) > 0 &&
|
|
||||||
m.status !== "watched" &&
|
|
||||||
m.duration_seconds ? (
|
|
||||||
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-black/40">
|
|
||||||
<div
|
|
||||||
className="h-full bg-accent"
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(100, ((m.position_seconds ?? 0) / m.duration_seconds) * 100)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`mt-1 truncate text-xs font-medium ${overlay ? "text-white/90" : ""}`}
|
|
||||||
>
|
|
||||||
{m.title}
|
|
||||||
</div>
|
|
||||||
{m.year ? (
|
|
||||||
<div className={`text-2xs ${overlay ? "text-white/55" : "text-muted"}`}>
|
|
||||||
{m.year}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
))}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
</div>
|
</div>
|
||||||
))}
|
));
|
||||||
|
const castRow = cast.length > 0 && (
|
||||||
|
<div className={overlay ? "mt-5" : `${SURFACE.panel} p-4 sm:p-5`}>
|
||||||
|
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||||
|
{t("plex.info.cast")}
|
||||||
|
</h2>
|
||||||
|
<Carousel
|
||||||
|
ariaLabel={t("plex.info.cast")}
|
||||||
|
gap="gap-3"
|
||||||
|
keyboardNav={!!(onOpenPerson || onFilter)}
|
||||||
|
>
|
||||||
|
{cast.map((c, i) => {
|
||||||
|
const clickable = !!(onOpenPerson || onFilter);
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
|
||||||
|
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
|
||||||
|
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
|
||||||
|
>
|
||||||
|
{c.thumb ? (
|
||||||
|
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`grid h-full w-full place-items-center text-lg opacity-40 ${overlay ? "text-white" : ""}`}
|
||||||
|
>
|
||||||
|
{c.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
|
||||||
|
clickable ? "group-hover/cast:text-accent" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</div>
|
||||||
|
{c.role && (
|
||||||
|
<div
|
||||||
|
className={`truncate text-2xs ${overlay ? "text-white/55" : "text-muted"}`}
|
||||||
|
>
|
||||||
|
{c.role}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return clickable ? (
|
||||||
|
<Pressable
|
||||||
|
selectable
|
||||||
|
key={i}
|
||||||
|
align="center"
|
||||||
|
{...navItemAttrs(i)}
|
||||||
|
onClick={() =>
|
||||||
|
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
onOpenPerson
|
||||||
|
? t("plex.info.viewPerson", { name: c.name })
|
||||||
|
: t("plex.info.filterActor", { name: c.name })
|
||||||
|
}
|
||||||
|
className="group/cast w-20 shrink-0"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<div key={i} className="w-20 shrink-0 text-center">
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (overlay)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{strips}
|
||||||
|
{castRow}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const crewBox = showCrew && hasCrew && (
|
||||||
|
<CrewPanel crew={tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />
|
||||||
|
);
|
||||||
|
const factsBox = showFacts && hasFacts && (
|
||||||
|
<FactsPanel detail={detail} onFilter={onFilter} />
|
||||||
|
);
|
||||||
|
const keywordsBox = showKeywords && hasKeywords && (
|
||||||
|
<KeywordsPanel keywords={tmdb?.keywords ?? []} onFilter={onFilter} />
|
||||||
|
);
|
||||||
|
const hasMain = strips.length > 0 || !!crewBox || !!castRow;
|
||||||
|
const hasSide = !!factsBox || !!keywordsBox;
|
||||||
|
if (!hasMain && !hasSide) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"grid items-start gap-3",
|
||||||
|
hasMain && hasSide && "lg:grid-cols-[minmax(0,1fr)_20rem]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasMain && (
|
||||||
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
|
{strips}
|
||||||
|
{crewBox}
|
||||||
|
{castRow}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasSide && (
|
||||||
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
|
{factsBox}
|
||||||
|
{keywordsBox}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{editorOpen && library && (
|
{editorOpen && library && (
|
||||||
<PlexCollectionEditor
|
<PlexCollectionEditor
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
|
import { MEDIA_IMG } from "./ui/mediaCardStyles";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
|
|
||||||
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
|
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
|
||||||
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
|
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
|
||||||
// (a full-bleed poster collage drowned it). A group with no art falls back to an all-colour tile, never
|
// (a full-bleed poster collage drowned it). A group with no art falls back to an all-colour tile, never
|
||||||
// blank. Shared by the Discover genre tiles and the Playlist/Collection browse tiles.
|
// blank. Shared by the Discover genre tiles and the Playlist/Collection browse tiles.
|
||||||
|
//
|
||||||
|
// Hover = the media card's (E28 S3h): the shadow lifts and the art zooms inside its half, plus the
|
||||||
|
// accent edge glass-hover gives a pressable panel. It had `glass-hover` on a tile with no border, so
|
||||||
|
// the only visible change was a shadow under a colour band — the owner read it as no hover at all.
|
||||||
export default function PosterTile({
|
export default function PosterTile({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
@@ -32,11 +37,11 @@ export default function PosterTile({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={title}
|
title={title}
|
||||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||||
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl transition ${className ?? ""}`}
|
className={`group relative flex flex-col overflow-hidden rounded-xl border border-border shadow-md transition hover:border-accent/55 hover:shadow-2xl ${className ?? ""}`}
|
||||||
>
|
>
|
||||||
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
|
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
|
||||||
<div
|
<div
|
||||||
className="grid h-1/2 min-h-0 grid-cols-2"
|
className="grid h-1/2 min-h-0 grid-cols-2 overflow-hidden"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
style={posters.length === 0 ? { background: band } : undefined}
|
style={posters.length === 0 ? { background: band } : undefined}
|
||||||
>
|
>
|
||||||
@@ -48,7 +53,7 @@ export default function PosterTile({
|
|||||||
src={`/api/plex/image/${posters[i]}`}
|
src={`/api/plex/image/${posters[i]}`}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="h-full w-full object-cover"
|
className={MEDIA_IMG}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div key={i} style={{ background: hueTileGradient(hueOf(title + i)) }} />
|
<div key={i} style={{ background: hueTileGradient(hueOf(title + i)) }} />
|
||||||
|
|||||||
@@ -91,3 +91,65 @@ describe.each(Object.keys(FEED_VIEW_SPEC) as FeedView[])("the %s view's actions"
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E28 S3h, the owner's pick B (2026-09-26): the compact row had a leading action column that sat
|
||||||
|
// empty at rest. Now the title leads, the "…" is always at the row's end, and on hover the quick
|
||||||
|
// toggles float in beside it over the date, which steps aside.
|
||||||
|
describe("the compact row (rowsCompact)", () => {
|
||||||
|
it("leads with the title and ends with the '…'; the toggles reveal beside it over a yielding date", () => {
|
||||||
|
renderCard("rowsCompact");
|
||||||
|
const row = screen.getByTestId("video-card");
|
||||||
|
expect(row.firstElementChild!.textContent).toBe("Cicada 3301");
|
||||||
|
const more = screen.getByRole("button", { name: "card.menu.more" });
|
||||||
|
// the layer that floats left of the "…" (right-full) and shows on hover only
|
||||||
|
const toggles = screen
|
||||||
|
.getByRole("button", { name: "card.markWatched" })
|
||||||
|
.closest<HTMLElement>(".right-full");
|
||||||
|
expect(toggles!.className).toContain("reveal-hover");
|
||||||
|
// the toggles and the "…" share the row's last slot (the resume bar is drawn after it)
|
||||||
|
const slot = more.parentElement!;
|
||||||
|
expect(slot.contains(toggles)).toBe(true);
|
||||||
|
const inFlow = [...row.children].filter((c) => !c.className.includes("absolute"));
|
||||||
|
expect(inFlow[inFlow.length - 1]).toBe(slot);
|
||||||
|
expect(row.querySelector(".w-48")!.className).toContain("group-hover:opacity-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// On a shelf (Home's strips) the card is one title line over the channel alone, so every card on
|
||||||
|
// the strip is the same height (E28 S3h, the owner's shelf density).
|
||||||
|
describe("the card on a shelf", () => {
|
||||||
|
function renderSmall(shelf: boolean) {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<VideoCard
|
||||||
|
video={video}
|
||||||
|
view="cardsSmall"
|
||||||
|
width={0}
|
||||||
|
shelf={shelf}
|
||||||
|
onState={() => {}}
|
||||||
|
onToggleSave={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return screen.getByTestId("video-card");
|
||||||
|
}
|
||||||
|
const titleOf = (card: HTMLElement) =>
|
||||||
|
[...card.querySelectorAll("*")].find(
|
||||||
|
(e) => e.childElementCount === 0 && e.textContent === "Cicada 3301",
|
||||||
|
)!;
|
||||||
|
|
||||||
|
it("cuts the title to one line and drops the views · age line", () => {
|
||||||
|
const card = renderSmall(true);
|
||||||
|
const title = titleOf(card).closest(".font-medium")!;
|
||||||
|
expect(title.className).toContain("truncate");
|
||||||
|
expect(title.className).not.toContain("line-clamp-2");
|
||||||
|
expect(card.querySelector("span[title]")).toBeNull();
|
||||||
|
expect(screen.getByRole("link", { name: "LEMMiNO" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("off a shelf keeps two title lines and the age", () => {
|
||||||
|
const card = renderSmall(false);
|
||||||
|
expect(titleOf(card).closest(".font-medium")!.className).toContain("line-clamp-2");
|
||||||
|
expect(card.querySelector("span[title]")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ import { Button } from "./ui/Button";
|
|||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import { MoreButton } from "./ui/MoreButton";
|
import { MoreButton } from "./ui/MoreButton";
|
||||||
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
|
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
|
||||||
|
import {
|
||||||
|
MEDIA_BADGE,
|
||||||
|
MEDIA_BAR,
|
||||||
|
MEDIA_HOVER_LAYER,
|
||||||
|
MEDIA_IMG,
|
||||||
|
MEDIA_PICTURE,
|
||||||
|
mediaTitleClass,
|
||||||
|
} from "./ui/mediaCardStyles";
|
||||||
|
import { MediaCard, MediaProgress } from "./ui/MediaCard";
|
||||||
|
|
||||||
/** The "…" that opens the video's action list, and whether its menu is the open one. */
|
/** The "…" that opens the video's action list, and whether its menu is the open one. */
|
||||||
type More = { props: Record<string, unknown>; open: boolean };
|
type More = { props: Record<string, unknown>; open: boolean };
|
||||||
@@ -35,7 +44,8 @@ type More = { props: Record<string, unknown>; open: boolean };
|
|||||||
// a "…" for everything else (download, details, copy, hide, reset, pin), which is the list's ONE
|
// a "…" for everything else (download, details, copy, hide, reset, pin), which is the list's ONE
|
||||||
// menu (useVideoActions). On a mouse the row shows on hover or keyboard focus; on touch the toggles
|
// menu (useVideoActions). On a mouse the row shows on hover or keyboard focus; on touch the toggles
|
||||||
// are gone (the "…" and the long-press are the way in) and only the "…" stays.
|
// are gone (the "…" and the long-press are the way in) and only the "…" stays.
|
||||||
function Actions({
|
/** The card's hover row (watched, save, add, "…"); Home's hero docks the same row on its picture. */
|
||||||
|
export function VideoHoverActions({
|
||||||
video,
|
video,
|
||||||
onState,
|
onState,
|
||||||
onToggleSave,
|
onToggleSave,
|
||||||
@@ -64,8 +74,8 @@ function Actions({
|
|||||||
onToggleSave(video.id, !video.saved);
|
onToggleSave(video.id, !video.saved);
|
||||||
};
|
};
|
||||||
const variant = media ? "media" : "ghost";
|
const variant = media ? "media" : "ghost";
|
||||||
// Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail and the
|
// Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail; the
|
||||||
// one-line row's w-32 column (ROW_COL.actions).
|
// one-line row floats three of them (no "…": its own stays put) over its date on hover.
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex", dense ? "gap-0.5" : "gap-1")}>
|
<div className={clsx("flex", dense ? "gap-0.5" : "gap-1")}>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -171,7 +181,7 @@ function RowMetaCells({ video }: { video: Video }) {
|
|||||||
{/* role="img" + aria-label names each marker ONCE and reliably; `title` alone wouldn't (a
|
{/* role="img" + aria-label names each marker ONCE and reliably; `title` alone wouldn't (a
|
||||||
bare span has no role, and screen readers don't dependably surface title on one), while a
|
bare span has no role, and screen readers don't dependably surface title on one), while a
|
||||||
title plus an sr-only twin gets it announced twice. These are indicators, NOT the controls
|
title plus an sr-only twin gets it announced twice. These are indicators, NOT the controls
|
||||||
in Actions — the label must not tell anyone to click something that doesn't respond. */}
|
in VideoHoverActions — the label must not tell anyone to click something that doesn't respond. */}
|
||||||
<span>
|
<span>
|
||||||
{StatusIcon && (
|
{StatusIcon && (
|
||||||
<span role="img" aria-label={status.label} title={status.label}>
|
<span role="img" aria-label={status.label} title={status.label}>
|
||||||
@@ -220,7 +230,10 @@ function Thumb({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("relative", className)}>
|
// min-h-0: the caller's aspect box must hold its ratio before the image arrives — an aspect-ratio
|
||||||
|
// box otherwise grows to its content, and an off-screen, not-yet-loaded thumbnail measured 338px
|
||||||
|
// tall, which stretched a whole Home shelf to it (ui/mediaCardStyles `mediaPictureBox`).
|
||||||
|
<div className={clsx("relative min-h-0", className)}>
|
||||||
{/* Named by the title: its only text is the duration badge, so a screen reader heard the first
|
{/* Named by the title: its only text is the duration badge, so a screen reader heard the first
|
||||||
Tab stop of every card as "44:10" (E28 S3d4). */}
|
Tab stop of every card as "44:10" (E28 S3d4). */}
|
||||||
<a
|
<a
|
||||||
@@ -229,7 +242,7 @@ function Thumb({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
aria-label={video.title ?? undefined}
|
aria-label={video.title ?? undefined}
|
||||||
onClick={(e) => openInApp(e, video, onOpen)}
|
onClick={(e) => openInApp(e, video, onOpen)}
|
||||||
className="block relative h-full w-full rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow"
|
className={MEDIA_PICTURE}
|
||||||
>
|
>
|
||||||
{video.thumbnail_url ? (
|
{video.thumbnail_url ? (
|
||||||
<img
|
<img
|
||||||
@@ -239,7 +252,7 @@ function Thumb({
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
width={1280}
|
width={1280}
|
||||||
height={720}
|
height={720}
|
||||||
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
className={MEDIA_IMG}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full" />
|
<div className="w-full h-full" />
|
||||||
@@ -247,7 +260,8 @@ function Thumb({
|
|||||||
{video.duration_seconds != null ? (
|
{video.duration_seconds != null ? (
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
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",
|
MEDIA_BADGE,
|
||||||
|
"bottom-1.5 right-1.5",
|
||||||
// The hover row takes this corner; the duration steps aside while it shows.
|
// The hover row takes this corner; the duration steps aside while it shows.
|
||||||
bar && HOVER_YIELD,
|
bar && HOVER_YIELD,
|
||||||
)}
|
)}
|
||||||
@@ -278,7 +292,7 @@ function Thumb({
|
|||||||
`reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a
|
`reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a
|
||||||
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
|
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
|
||||||
{onOpen && (
|
{onOpen && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity reveal-hover">
|
<div className={MEDIA_HOVER_LAYER}>
|
||||||
{inProgress ? (
|
{inProgress ? (
|
||||||
<>
|
<>
|
||||||
{small ? (
|
{small ? (
|
||||||
@@ -338,15 +352,9 @@ function Thumb({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
|
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
|
||||||
{pct > 0 && (
|
<MediaProgress pct={pct} />
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
|
|
||||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</a>
|
</a>
|
||||||
{bar && (
|
{bar && <div className={MEDIA_BAR}>{bar}</div>}
|
||||||
<div className={clsx(HOVER_REVEAL, "absolute bottom-2.5 right-1.5 z-base")}>{bar}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -359,7 +367,7 @@ function Thumb({
|
|||||||
// Keep this table and the classes in step; each number is measured against the worst case in the
|
// Keep this table and the classes in step; each number is measured against the worst case in the
|
||||||
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
|
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
|
||||||
const ROW_COL = {
|
const ROW_COL = {
|
||||||
actions: 128, // w-32 (four buttons: 4×28 + 3×4 = 124; E28 C1 moved the rest into the "…")
|
more: 28, // the "…" at the row's end (size-7); the quick actions float over the date on hover
|
||||||
channel: 128, // w-32
|
channel: 128, // w-32
|
||||||
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
|
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
|
||||||
views: 64, // w-16
|
views: 64, // w-16
|
||||||
@@ -373,7 +381,7 @@ const ROW_TITLE_MIN = 120; // not a column: the floor below which a title isn't
|
|||||||
// once everything to its left AND a readable title already fit. Calibrated at the default text
|
// once everything to its left AND a readable title already fit. Calibrated at the default text
|
||||||
// size; at 130% the columns grow ~30% while these don't, so they read slightly eager — the title
|
// size; at 130% the columns grow ~30% while these don't, so they read slightly eager — the title
|
||||||
// gets tighter there, but it can't vanish, since the last column standing down frees ~200px.
|
// gets tighter there, but it can't vanish, since the last column standing down frees ~200px.
|
||||||
const ROW_BASE = ROW_PAD + ROW_COL.actions + ROW_GAP + ROW_TITLE_MIN;
|
const ROW_BASE = ROW_PAD + ROW_TITLE_MIN + ROW_GAP + ROW_COL.more;
|
||||||
const ROW_FITS = {
|
const ROW_FITS = {
|
||||||
channel: ROW_BASE + ROW_GAP + ROW_COL.channel,
|
channel: ROW_BASE + ROW_GAP + ROW_COL.channel,
|
||||||
meta: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta,
|
meta: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta,
|
||||||
@@ -401,6 +409,7 @@ function VideoCard({
|
|||||||
menuButtonProps,
|
menuButtonProps,
|
||||||
menuOpen = false,
|
menuOpen = false,
|
||||||
navIndex,
|
navIndex,
|
||||||
|
shelf = false,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
view: FeedView;
|
view: FeedView;
|
||||||
@@ -422,6 +431,8 @@ function VideoCard({
|
|||||||
menuOpen?: boolean;
|
menuOpen?: boolean;
|
||||||
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
|
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
|
||||||
navIndex?: number;
|
navIndex?: number;
|
||||||
|
/** On a shelf (a horizontal strip): a one-line title over the channel alone (ui/mediaCardStyles). */
|
||||||
|
shelf?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { openChannel } = useNavigationActions();
|
const { openChannel } = useNavigationActions();
|
||||||
@@ -493,8 +504,8 @@ function VideoCard({
|
|||||||
title={titleClamped ? (video.title ?? undefined) : undefined}
|
title={titleClamped ? (video.title ?? undefined) : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
// min-h-6: a one-line title is still a 24 target (B1).
|
// min-h-6: a one-line title is still a 24 target (B1).
|
||||||
"min-h-6 select-text font-medium leading-snug hover:text-accent",
|
"min-h-6 select-text hover:text-accent",
|
||||||
noThumb ? "block truncate" : "line-clamp-2",
|
noThumb ? "block truncate font-medium leading-snug" : mediaTitleClass(!!spec.dense, shelf),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{video.title}
|
{video.title}
|
||||||
@@ -523,7 +534,7 @@ function VideoCard({
|
|||||||
// the toggles inside it are gone and only the "…" is left.
|
// the toggles inside it are gone and only the "…" is left.
|
||||||
const actions = (
|
const actions = (
|
||||||
<div className="reveal">
|
<div className="reveal">
|
||||||
<Actions
|
<VideoHoverActions
|
||||||
video={video}
|
video={video}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
@@ -569,29 +580,53 @@ function VideoCard({
|
|||||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||||
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
|
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
|
||||||
>
|
>
|
||||||
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
|
{/* The title leads (E28 S3h, the owner's pick B, 2026-09-26): the actions used to lead in a
|
||||||
they're the shortest pointer trip from it. ROW_COL.actions counts their width. */}
|
128px column that sat EMPTY at rest, a hole at the start of every row. Now the row's
|
||||||
<div className="shrink-0 w-32">{actions}</div>
|
end holds a faint "…" (always there: the menu's door on every device), and on hover or
|
||||||
{/* Everything else is shrink-0, so the title is the only thing that can give — which means
|
keyboard focus the quick toggles float in beside it OVER the date, which steps aside —
|
||||||
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
|
the Gmail row. On touch the toggles are gone (reveal-hover) and the "…" stays.
|
||||||
from the right as the row narrows, least important first: a title with no date beats a
|
Everything but the title is shrink-0, so the title is the only thing that can give —
|
||||||
date with no title. `width` is the ROW's own, so this holds whatever the rail and the
|
which means once the fixed columns outgrow the row, it gives ALL of it and vanishes.
|
||||||
filter panel are doing. Until the first measure lands (width 0) show everything: the
|
Columns drop from the right as the row narrows, least important first: a title with no
|
||||||
row is off-screen for that frame anyway, and guessing narrow would flash. */}
|
date beats a date with no title. `width` is the ROW's own, so this holds whatever the
|
||||||
|
rail and the filter panel are doing. Until the first measure lands (width 0) show
|
||||||
|
everything: the row is off-screen for that frame anyway, and guessing narrow would
|
||||||
|
flash. */}
|
||||||
<div className="min-w-0 flex-1">{title}</div>
|
<div className="min-w-0 flex-1">{title}</div>
|
||||||
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelLink}</div>}
|
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelLink}</div>}
|
||||||
{fits(ROW_FITS.meta) && <RowMetaCells video={video} />}
|
{fits(ROW_FITS.meta) && (
|
||||||
|
<div className={clsx(!fits(ROW_FITS.views) && HOVER_YIELD)}>
|
||||||
|
<RowMetaCells video={video} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
|
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
|
||||||
holds the widest bare form ("101.7K" at 48) in either language — dropping the word
|
holds the widest bare form ("101.7K" at 48) in either language — dropping the word
|
||||||
took this from 144 and handed the difference to the title. */}
|
took this from 144 and handed the difference to the title. */}
|
||||||
{fits(ROW_FITS.views) && (
|
{fits(ROW_FITS.views) && (
|
||||||
<div className="shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate">
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate",
|
||||||
|
!fits(ROW_FITS.published) && HOVER_YIELD,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{viewsCell(false)}
|
{viewsCell(false)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{fits(ROW_FITS.published) && (
|
{fits(ROW_FITS.published) && (
|
||||||
<div className="shrink-0 w-48 text-sm text-muted truncate">{published}</div>
|
<div className={clsx("shrink-0 w-48 text-sm text-muted truncate", HOVER_YIELD)}>
|
||||||
|
{published}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div
|
||||||
|
className={clsx(HOVER_REVEAL, "absolute inset-y-0 right-full mr-1 flex items-center")}
|
||||||
|
>
|
||||||
|
<VideoHoverActions video={video} onState={onState} onToggleSave={onToggleSave} />
|
||||||
|
</div>
|
||||||
|
{more && (
|
||||||
|
<MoreButton label={t("card.menu.more")} expanded={more.open} menuProps={more.props} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{/* Thumb draws this over the image; with no image it goes on the row itself. */}
|
{/* Thumb draws this over the image; with no image it goes on the row itself. */}
|
||||||
{pct > 0 && (
|
{pct > 0 && (
|
||||||
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
|
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
|
||||||
@@ -649,56 +684,85 @@ function VideoCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The card's action row sits on its picture (the C1 reference: shown on hover, never taking a line
|
// The card's action row sits on its picture (the C1 reference: shown on hover, never taking a line
|
||||||
// of its own), and on touch its "…" sits beside the title as a 44px target.
|
// of its own), and on touch its "…" sits beside the title as a 44px target. The frame, the pinned
|
||||||
|
// meta line and the picture's pieces are the one media card's (ui/MediaCard), shared with Plex.
|
||||||
// NOTE: do NOT add h-full here. A percentage height against the grid's content-derived height is
|
// NOTE: do NOT add h-full here. A percentage height against the grid's content-derived height is
|
||||||
// circular, and VirtualFeed measures each row with a ResizeObserver — the two together oscillate
|
// circular, and VirtualFeed measures each row with a ResizeObserver — the two together oscillate
|
||||||
// by a pixel or two forever, which shows up as the whole page juddering.
|
// by a pixel or two forever, which shows up as the whole page juddering. The grid stretches the
|
||||||
|
// card to its row on its own, which is what the pinned meta line needs.
|
||||||
return (
|
return (
|
||||||
<div
|
<MediaCard
|
||||||
data-testid="video-card"
|
rootProps={{
|
||||||
data-video-id={video.id}
|
"data-testid": "video-card",
|
||||||
{...menuProps?.(video)}
|
"data-video-id": video.id,
|
||||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
...menuProps?.(video),
|
||||||
className={clsx(
|
...(navIndex !== undefined && navItemAttrs(navIndex)),
|
||||||
`cv-card group ${SURFACE.card} glass-hover transition-all duration-150`,
|
}}
|
||||||
"flex flex-col",
|
className="cv-card"
|
||||||
spec.dense ? "p-2" : "p-2.5",
|
dense={spec.dense}
|
||||||
watched && "opacity-55",
|
dim={watched}
|
||||||
)}
|
// The small card's image is in the SAME size class as a row's (its column bottoms out at
|
||||||
>
|
// 180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide
|
||||||
{/* The small card's image is in the SAME size class as a row's (its column bottoms out at
|
// and would spill straight out of it.
|
||||||
180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide
|
picture={
|
||||||
and would spill straight out of it. */}
|
<Thumb
|
||||||
<Thumb
|
video={video}
|
||||||
video={video}
|
className="aspect-video"
|
||||||
className="aspect-video"
|
onOpen={onOpen}
|
||||||
onOpen={onOpen}
|
small={spec.dense}
|
||||||
small={spec.dense}
|
bar={
|
||||||
bar={
|
<VideoHoverActions
|
||||||
<Actions video={video} onState={onState} onToggleSave={onToggleSave} more={more} media />
|
video={video}
|
||||||
}
|
onState={onState}
|
||||||
/>
|
onToggleSave={onToggleSave}
|
||||||
<div className={clsx("flex gap-3 px-0.5 pb-0.5 flex-1", spec.dense ? "mt-2" : "mt-2.5")}>
|
more={more}
|
||||||
{/* The small card drops the channel avatar: at its ~180px column the 36px avatar plus the
|
media
|
||||||
gap would leave the title barely half the width. The channel name is still below it. */}
|
/>
|
||||||
{!spec.dense && (
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
// The small card drops the channel avatar: at its ~180px column the 36px avatar plus the gap
|
||||||
|
// would leave the title barely half the width. The channel name is still in the meta line.
|
||||||
|
leading={
|
||||||
|
!spec.dense && (
|
||||||
<Avatar
|
<Avatar
|
||||||
src={video.channel_thumbnail}
|
src={video.channel_thumbnail}
|
||||||
fallback={video.channel_title ?? ""}
|
fallback={video.channel_title ?? ""}
|
||||||
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
|
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
|
||||||
/>
|
/>
|
||||||
)}
|
)
|
||||||
<div className="min-w-0 flex-1 flex flex-col">{textBlock}</div>
|
}
|
||||||
{more && (
|
title={title}
|
||||||
<MoreButton
|
meta={
|
||||||
className="touch-only -mr-1 shrink-0"
|
shelf ? (
|
||||||
label={t("card.menu.more")}
|
channelLink
|
||||||
expanded={more.open}
|
) : (
|
||||||
menuProps={more.props}
|
<>
|
||||||
/>
|
{channelLink}
|
||||||
)}
|
<div className="text-xs text-muted mt-0.5">
|
||||||
</div>
|
{views}
|
||||||
</div>
|
{views && " · "}
|
||||||
|
{/* The small card keeps the relative time and moves the date into its tooltip: at a
|
||||||
|
shelf's width the full pair wrapped to a third meta line, and the tallest card sets
|
||||||
|
the whole shelf's height (every card fills its slot). */}
|
||||||
|
{spec.dense ? (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
video.published_at ? formatDate(video.published_at, i18n.language) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{relativeTime(video.published_at)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
published
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
more={more || undefined}
|
||||||
|
moreLabel={t("card.menu.more")}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
|
// 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.
|
// key-passthrough, so the asserted names are the i18n keys.
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -13,6 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
|
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
|
||||||
|
import { iconButtonClass } from "./ui/buttonStyles";
|
||||||
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
||||||
|
|
||||||
const movie = (status?: string): PlexCard => ({
|
const movie = (status?: string): PlexCard => ({
|
||||||
@@ -65,21 +66,103 @@ describe("PlexPosterCard a11y", () => {
|
|||||||
expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reveals the synopsis + rating on the hover scrim when the card carries them", () => {
|
// E28 S3h: the one media card — the year and the rating are the meta line under the title (pinned
|
||||||
|
// to the frame's bottom), the length a badge on the picture. The synopsis is NOT the picture's
|
||||||
|
// tooltip any more: it lives in the card's preview (below).
|
||||||
|
it("carries the year and rating in its meta line, the length on the picture", () => {
|
||||||
render(
|
render(
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
card={{ ...movie(), year: 2023, rating: 7.5, summary: "A quiet synopsis." }}
|
card={{
|
||||||
|
...movie(),
|
||||||
|
year: 2023,
|
||||||
|
rating: 7.5,
|
||||||
|
duration_seconds: 6780,
|
||||||
|
summary: "A quiet synopsis.",
|
||||||
|
}}
|
||||||
onOpen={vi.fn()}
|
onOpen={vi.fn()}
|
||||||
onToggleWatched={vi.fn()}
|
onToggleWatched={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
|
expect(screen.getByText("2023")).toBeTruthy();
|
||||||
expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1)
|
expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1)
|
||||||
|
expect(screen.getByText("1h 53m")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: movie().title }).getAttribute("title")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits the scrim when the card has no synopsis or rating", () => {
|
it("draws no meta line when the card has neither a year nor a rating", () => {
|
||||||
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onToggleWatched={vi.fn()} />);
|
const { container } = render(
|
||||||
expect(screen.queryByText(/synopsis/i)).toBeNull();
|
<PlexPosterCard card={{ ...movie(), year: null, rating: null }} onOpen={vi.fn()} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".mt-auto")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The owner's pick C (2026-09-27): the synopsis shows when the MOUSE rests on the card — not on a
|
||||||
|
// sweep across the grid, and not on the keyboard walk (a panel at every stop).
|
||||||
|
describe("PlexPosterCard preview", () => {
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
const card = { ...movie(), title: "A long title", year: 2020, summary: "A quiet synopsis." };
|
||||||
|
const root = () =>
|
||||||
|
screen.getByRole("button", { name: "A long title" }).closest(".cv-poster") as HTMLElement;
|
||||||
|
|
||||||
|
it("opens once the mouse has rested on the card, with the whole title and the synopsis", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
|
||||||
|
fireEvent.mouseEnter(root());
|
||||||
|
act(() => vi.advanceTimersByTime(600));
|
||||||
|
expect(screen.queryByText("A quiet synopsis.")).toBeNull(); // a sweep opens nothing
|
||||||
|
act(() => vi.advanceTimersByTime(400));
|
||||||
|
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText("A long title")).toHaveLength(2); // the card's and the preview's
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open on keyboard focus", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
|
||||||
|
fireEvent.focus(screen.getByRole("button", { name: "A long title" }));
|
||||||
|
act(() => vi.advanceTimersByTime(2000));
|
||||||
|
expect(screen.queryByText("A quiet synopsis.")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Home's shelves: a show that plays on is a card for its NEXT episode (the owner, 2026-09-27).
|
||||||
|
describe("PlexPosterCard with a next episode", () => {
|
||||||
|
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "", season_count: 6 };
|
||||||
|
const next: PlexCard = { ...episode(), season_number: 6, episode_number: 16 };
|
||||||
|
const playGlyph = (c: HTMLElement) => c.querySelector(".reveal-hover.inset-0 svg");
|
||||||
|
|
||||||
|
it("draws the Play, names the episode, and acts on it; the ⓘ opens the show", () => {
|
||||||
|
const onToggleWatched = vi.fn();
|
||||||
|
const onAdd = vi.fn();
|
||||||
|
const onInfo = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<PlexPosterCard
|
||||||
|
card={show}
|
||||||
|
next={next}
|
||||||
|
onOpen={vi.fn()}
|
||||||
|
onToggleWatched={onToggleWatched}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onInfo={onInfo}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(playGlyph(container)).not.toBeNull();
|
||||||
|
expect(screen.getByText("plex.nextUp")).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched" }));
|
||||||
|
expect(onToggleWatched).toHaveBeenCalledWith(next);
|
||||||
|
// The episode's own add ("Add episode"), not the show's.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "plex.playlist.addEpisode" }));
|
||||||
|
expect(onAdd).toHaveBeenCalledWith(next);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "plex.openShow" }));
|
||||||
|
expect(onInfo).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays a plain show card without one: no Play, no toggles, no ⓘ", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PlexPosterCard card={show} onOpen={vi.fn()} onToggleWatched={vi.fn()} onInfo={vi.fn()} />,
|
||||||
|
);
|
||||||
|
expect(playGlyph(container)).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "plex.openShow" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "plex.markWatched" })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,3 +260,36 @@ describe("a Plex card's caption and a drag", () => {
|
|||||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// On a shelf (Home, the Plex rails, a collection strip) the title is one line, so every card on the
|
||||||
|
// strip is the same height; a landscape shelf picture is short, so it takes the smaller Play, clear
|
||||||
|
// of the hover row under it (E28 S3h: at the large size they touched on a 232px slot).
|
||||||
|
describe("PlexPosterCard on a shelf", () => {
|
||||||
|
const titleOf = () => screen.getByText("M");
|
||||||
|
const play = (c: HTMLElement) =>
|
||||||
|
c.querySelector("span[aria-hidden] > svg.lucide-play")!.parentElement!;
|
||||||
|
|
||||||
|
it("cuts the title to one line; a grid card keeps two", () => {
|
||||||
|
const { unmount } = render(<PlexPosterCard card={movie()} onOpen={vi.fn()} shelf />);
|
||||||
|
expect(titleOf().className).toContain("truncate");
|
||||||
|
unmount();
|
||||||
|
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} />);
|
||||||
|
expect(titleOf().className).toContain("line-clamp-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only a landscape SHELF card takes the smaller Play", () => {
|
||||||
|
const large = iconButtonClass({ variant: "media", size: "lg" });
|
||||||
|
const cases: [boolean, boolean, string][] = [
|
||||||
|
[true, true, iconButtonClass({ variant: "media", size: "md" })],
|
||||||
|
[true, false, large], // a poster on a shelf is tall enough
|
||||||
|
[false, true, large], // a landscape card in a grid is wide enough
|
||||||
|
];
|
||||||
|
for (const [shelf, wide, want] of cases) {
|
||||||
|
const { container, unmount } = render(
|
||||||
|
<PlexPosterCard card={movie()} onOpen={vi.fn()} shelf={shelf} wide={wide} />,
|
||||||
|
);
|
||||||
|
expect(play(container).className, `shelf=${shelf} wide=${wide}`).toBe(want);
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CheckCircle2, Circle, Film, ListPlus, Play, Star, Tv2 } from "lucide-react";
|
import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
|
||||||
import { type CSSProperties, type ReactNode, useRef } from "react";
|
import clsx from "clsx";
|
||||||
|
import { type ReactNode, useRef } from "react";
|
||||||
import { formatRuntime } from "../lib/format";
|
import { formatRuntime } from "../lib/format";
|
||||||
import { Carousel } from "./ui/Carousel";
|
import { Carousel } from "./ui/Carousel";
|
||||||
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
|
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
|
||||||
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
|
|
||||||
import { plexAddLabelKey } from "./usePlexCardActions";
|
import { plexAddLabelKey } from "./usePlexCardActions";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { MoreButton } from "./ui/MoreButton";
|
import { MoreButton } from "./ui/MoreButton";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
|
import { HOVER_YIELD } from "./ui/reveal";
|
||||||
|
import {
|
||||||
|
MEDIA_BADGE,
|
||||||
|
MEDIA_BAR,
|
||||||
|
MEDIA_HOVER_LAYER,
|
||||||
|
MEDIA_IMG,
|
||||||
|
MEDIA_PICTURE,
|
||||||
|
mediaPictureBox,
|
||||||
|
mediaTitleClass,
|
||||||
|
} from "./ui/mediaCardStyles";
|
||||||
|
import { MediaCard, MediaProgress, MediaPlayGlyph } from "./ui/MediaCard";
|
||||||
|
import { MediaPreview } from "./ui/MediaPreview";
|
||||||
|
import { useMediaPreview } from "./ui/useMediaPreview";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||||
@@ -42,7 +54,7 @@ function WatchedIcon({ watched }: { watched: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The hover row on a card's picture (top-right). A sibling of the picture's own button, never
|
/** The hover row on a card's picture (bottom-right, as on the YouTube card). A sibling of the picture's own button, never
|
||||||
* inside it, so no control nests in another. */
|
* inside it, so no control nests in another. */
|
||||||
function HoverBar({
|
function HoverBar({
|
||||||
watched,
|
watched,
|
||||||
@@ -50,6 +62,7 @@ function HoverBar({
|
|||||||
onToggleWatched,
|
onToggleWatched,
|
||||||
addLabel,
|
addLabel,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
onInfo,
|
||||||
more: m,
|
more: m,
|
||||||
}: {
|
}: {
|
||||||
watched: boolean;
|
watched: boolean;
|
||||||
@@ -57,12 +70,14 @@ function HoverBar({
|
|||||||
onToggleWatched?: () => void;
|
onToggleWatched?: () => void;
|
||||||
addLabel: string;
|
addLabel: string;
|
||||||
onAdd?: () => void;
|
onAdd?: () => void;
|
||||||
|
/** The item's own page, when the click does something else (a show on Home plays its next episode). */
|
||||||
|
onInfo?: () => void;
|
||||||
more: More;
|
more: More;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (!onToggleWatched && !onAdd && !m) return null;
|
if (!onToggleWatched && !onAdd && !onInfo && !m) return null;
|
||||||
return (
|
return (
|
||||||
<div className={`${HOVER_REVEAL} absolute right-1.5 top-1.5 z-base flex gap-1`}>
|
<div className={clsx(MEDIA_BAR, "flex gap-1")}>
|
||||||
{onToggleWatched && (
|
{onToggleWatched && (
|
||||||
<IconButton variant="media" onClick={onToggleWatched} label={watchedLabel}>
|
<IconButton variant="media" onClick={onToggleWatched} label={watchedLabel}>
|
||||||
<WatchedIcon watched={watched} />
|
<WatchedIcon watched={watched} />
|
||||||
@@ -73,6 +88,11 @@ function HoverBar({
|
|||||||
<ListPlus />
|
<ListPlus />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
{onInfo && (
|
||||||
|
<IconButton variant="media" onClick={onInfo} label={t("plex.openShow")}>
|
||||||
|
<Info />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
{m && (
|
{m && (
|
||||||
<MoreButton
|
<MoreButton
|
||||||
variant="media"
|
variant="media"
|
||||||
@@ -85,25 +105,52 @@ function HoverBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The caption under a card's picture, with the touch "…" at its end (the hover row's twin). */
|
/** The hover row on a picture that is not a card's (Home's hero): the card's own watched and add
|
||||||
function Caption({
|
* toggles and its "…", acting on a movie or an episode. */
|
||||||
|
export function PlexHoverBar({
|
||||||
|
card,
|
||||||
|
onToggleWatched,
|
||||||
|
onAdd,
|
||||||
|
moreProps,
|
||||||
|
moreOpen = false,
|
||||||
|
}: {
|
||||||
|
card: PlexCard;
|
||||||
|
onToggleWatched?: (c: PlexCard) => void;
|
||||||
|
onAdd?: (c: PlexCard) => void;
|
||||||
|
moreProps?: Record<string, unknown>;
|
||||||
|
moreOpen?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const plays = card.type !== "show";
|
||||||
|
const watched = card.status === "watched";
|
||||||
|
return (
|
||||||
|
<HoverBar
|
||||||
|
watched={watched}
|
||||||
|
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||||
|
onToggleWatched={plays && onToggleWatched ? () => onToggleWatched(card) : undefined}
|
||||||
|
addLabel={t(plexAddLabelKey(card))}
|
||||||
|
onAdd={plays && onAdd ? () => onAdd(card) : undefined}
|
||||||
|
more={moreProps ? { props: moreProps, open: moreOpen } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Plex card's title: it opens the card like its picture does, except for the click that ends
|
||||||
|
* selecting its words, or a press dragged past the slop (DESIGN "Text stays selectable"); a div's
|
||||||
|
* text selects on its own, so only the click needs the guard. */
|
||||||
|
function CardTitle({
|
||||||
onClick,
|
onClick,
|
||||||
more: m,
|
shelf = false,
|
||||||
className = "",
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
more: More;
|
/** One line, cut with an ellipsis (a shelf). The whole title is in the card's preview. */
|
||||||
className?: string;
|
shelf?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const pressedAt = useRef<{ x: number; y: number } | null>(null);
|
const pressedAt = useRef<{ x: number; y: number } | null>(null);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
// The click that ends selecting the title's words, or a press dragged past the slop, does not
|
|
||||||
// open it (DESIGN "Text stays selectable"); a div's text selects on its own, so only the click
|
|
||||||
// needs the guard.
|
|
||||||
onClick={
|
onClick={
|
||||||
onClick &&
|
onClick &&
|
||||||
((e) => {
|
((e) => {
|
||||||
@@ -114,22 +161,75 @@ function Caption({
|
|||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
pressedAt.current = { x: e.clientX, y: e.clientY };
|
pressedAt.current = { x: e.clientX, y: e.clientY };
|
||||||
}}
|
}}
|
||||||
className={`mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5 ${onClick ? "cursor-pointer" : ""} ${className}`}
|
className={clsx(
|
||||||
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
"min-h-6",
|
||||||
>
|
mediaTitleClass(true, shelf),
|
||||||
<div className="min-w-0 flex-1">{children}</div>
|
onClick && "cursor-pointer hover:text-accent",
|
||||||
{m && (
|
|
||||||
<MoreButton
|
|
||||||
className="touch-only -mr-1 -my-0.5 shrink-0"
|
|
||||||
label={t("card.menu.more")}
|
|
||||||
expanded={m.open}
|
|
||||||
menuProps={m.props}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A Plex card's picture: the whole box is the card's open/play button (Enter opens it from the
|
||||||
|
* keyboard walk), with the hover row docked beside it rather than inside it. */
|
||||||
|
function PlexPicture({
|
||||||
|
aspect,
|
||||||
|
thumb,
|
||||||
|
label,
|
||||||
|
onOpen,
|
||||||
|
plays,
|
||||||
|
small = false,
|
||||||
|
pct = 0,
|
||||||
|
bar,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
aspect: "poster" | "wide";
|
||||||
|
thumb: string;
|
||||||
|
/** The button's name: the picture has no text of its own. */
|
||||||
|
label: string;
|
||||||
|
onOpen: () => void;
|
||||||
|
/** The click plays (a movie, an episode) — only then does the hover draw a Play. */
|
||||||
|
plays: boolean;
|
||||||
|
/** A shelf's short landscape picture: the smaller Play, clear of the hover row under it. */
|
||||||
|
small?: boolean;
|
||||||
|
pct?: number;
|
||||||
|
bar: ReactNode;
|
||||||
|
/** The badges printed on the picture. */
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={mediaPictureBox(aspect)}>
|
||||||
|
<div
|
||||||
|
onClick={onOpen}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={label}
|
||||||
|
data-nav-open=""
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={clsx(MEDIA_PICTURE, "cursor-pointer")}
|
||||||
|
>
|
||||||
|
<img src={thumb} alt="" loading="lazy" decoding="async" className={MEDIA_IMG} />
|
||||||
|
{children}
|
||||||
|
<div className={MEDIA_HOVER_LAYER}>{plays && <MediaPlayGlyph small={small} />}</div>
|
||||||
|
<MediaProgress pct={pct} />
|
||||||
|
</div>
|
||||||
|
{bar}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPct = (c: { position_seconds?: number; duration_seconds?: number | null }) =>
|
||||||
|
(c.position_seconds ?? 0) > 0 && c.duration_seconds
|
||||||
|
? Math.round(((c.position_seconds ?? 0) / c.duration_seconds) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
export function PlexPosterCard({
|
export function PlexPosterCard({
|
||||||
card,
|
card,
|
||||||
onOpen,
|
onOpen,
|
||||||
@@ -139,6 +239,10 @@ export function PlexPosterCard({
|
|||||||
menuButtonProps,
|
menuButtonProps,
|
||||||
menuOpen = false,
|
menuOpen = false,
|
||||||
navIndex,
|
navIndex,
|
||||||
|
wide = false,
|
||||||
|
shelf = false,
|
||||||
|
next,
|
||||||
|
onInfo,
|
||||||
}: {
|
}: {
|
||||||
card: PlexCard;
|
card: PlexCard;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
@@ -155,163 +259,149 @@ export function PlexPosterCard({
|
|||||||
menuOpen?: boolean;
|
menuOpen?: boolean;
|
||||||
/** Position in a keyboard-walkable grid or rail (lib/listNav); the poster is what Enter opens. */
|
/** Position in a keyboard-walkable grid or rail (lib/listNav); the poster is what Enter opens. */
|
||||||
navIndex?: number;
|
navIndex?: number;
|
||||||
|
/** Landscape, beside YouTube cards (Home's shelves): the item's wide art instead of its poster
|
||||||
|
* (the poster, cropped, when it has no art). An episode's picture is landscape already. */
|
||||||
|
wide?: boolean;
|
||||||
|
/** On a shelf (a horizontal strip): a one-line title (ui/mediaCardStyles). */
|
||||||
|
shelf?: boolean;
|
||||||
|
/** A SHOW whose click plays this episode (Home's shelves: a pinned or started show plays on). The
|
||||||
|
* picture then draws its Play, the watched toggle and the add act on the episode, the meta line
|
||||||
|
* names it, and an ⓘ (`onInfo`) opens the show. */
|
||||||
|
next?: PlexCard | null;
|
||||||
|
/** The show's page, beside a `next` episode. */
|
||||||
|
onInfo?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
|
const preview = useMediaPreview();
|
||||||
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
|
const landscape = wide || card.type === "episode";
|
||||||
|
const ep = card.type === "show" ? (next ?? null) : null;
|
||||||
|
// movies/episodes play; a show opens its drill-down — unless it plays on (`next`)
|
||||||
|
const isPlayable = card.type !== "show" || !!ep;
|
||||||
|
// What the hover row acts on: the episode a show would play, else the card itself.
|
||||||
|
const acted = ep ?? card;
|
||||||
const watched = card.status === "watched";
|
const watched = card.status === "watched";
|
||||||
|
const actedWatched = acted.status === "watched";
|
||||||
const runtime = formatRuntime(card.duration_seconds);
|
const runtime = formatRuntime(card.duration_seconds);
|
||||||
const pct =
|
const pct = ep ? progressPct(ep) : watched ? 0 : progressPct(card);
|
||||||
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 m = more(card, menuButtonProps, menuOpen);
|
||||||
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(card) : undefined;
|
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
|
||||||
const add = isPlayable && onAdd ? () => onAdd(card) : undefined;
|
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
|
||||||
// A corner pill steps aside while the hover row covers its corner; at rest, and on touch, it stays.
|
const epCode = ep && { season: ep.season_number ?? "?", episode: ep.episode_number ?? "?" };
|
||||||
const yieldTop = toggle || add || m ? HOVER_YIELD : "";
|
// A show has no length: its corner says how many seasons it has instead, so the corner alone tells
|
||||||
|
// a movie from a show (the film/TV glyph it replaces said the same thing a second time).
|
||||||
|
const corner =
|
||||||
|
card.type === "show"
|
||||||
|
? card.season_count != null && t("plex.seasons", { count: card.season_count })
|
||||||
|
: runtime;
|
||||||
|
const rating = card.rating != null && (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
<Star className="w-3 h-3 fill-current text-rating" aria-hidden />
|
||||||
|
{card.rating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className="group text-left"
|
<MediaCard
|
||||||
{...menuProps?.(card)}
|
dense
|
||||||
{...(navIndex !== undefined && navItemAttrs(navIndex, card.title))}
|
rootProps={preview.rootProps({
|
||||||
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
|
...menuProps?.(card),
|
||||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
...(navIndex !== undefined && navItemAttrs(navIndex, card.title)),
|
||||||
>
|
})}
|
||||||
<div className="relative">
|
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll);
|
||||||
<div
|
// a carousel turns it off (index.css `.media-rail`).
|
||||||
onClick={onOpen}
|
className="cv-poster"
|
||||||
role="button"
|
picture={
|
||||||
tabIndex={0}
|
<PlexPicture
|
||||||
data-nav-open=""
|
aspect={landscape ? "wide" : "poster"}
|
||||||
onKeyDown={(e) => {
|
thumb={(wide && card.type !== "episode" && card.art) || card.thumb}
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
label={card.title}
|
||||||
e.preventDefault();
|
onOpen={onOpen}
|
||||||
onOpen();
|
plays={isPlayable}
|
||||||
|
small={shelf && landscape}
|
||||||
|
pct={pct}
|
||||||
|
bar={
|
||||||
|
<HoverBar
|
||||||
|
watched={actedWatched}
|
||||||
|
watchedLabel={
|
||||||
|
epCode
|
||||||
|
? t(actedWatched ? "plex.markNextUnwatched" : "plex.markNextWatched", epCode)
|
||||||
|
: actedWatched
|
||||||
|
? t("plex.markUnwatched")
|
||||||
|
: t("plex.markWatched")
|
||||||
|
}
|
||||||
|
onToggleWatched={toggle}
|
||||||
|
addLabel={t(plexAddLabelKey(acted))}
|
||||||
|
onAdd={add}
|
||||||
|
onInfo={ep ? onInfo : undefined}
|
||||||
|
more={m}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
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. */}
|
|
||||||
{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}`}
|
|
||||||
>
|
|
||||||
{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" />}
|
{watched && <WatchedBadge />}
|
||||||
|
{/* Aggregate in-progress badge for a partially-watched show. */}
|
||||||
|
{card.type === "show" && card.status === "in_progress" && <InProgressBadge />}
|
||||||
|
{corner && <CornerBadge yields={!!(toggle || add || m)}>{corner}</CornerBadge>}
|
||||||
|
</PlexPicture>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<CardTitle onClick={onOpen} shelf={shelf}>
|
||||||
|
{card.title}
|
||||||
|
</CardTitle>
|
||||||
|
}
|
||||||
|
meta={
|
||||||
|
epCode ? (
|
||||||
|
<div className="truncate text-xs leading-6 text-muted">{t("plex.nextUp", epCode)}</div>
|
||||||
|
) : (
|
||||||
|
(!!card.year || card.rating != null) && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs leading-6 text-muted">
|
||||||
|
{!!card.year && <span>{card.year}</span>}
|
||||||
|
{!!card.year && rating && <span aria-hidden>·</span>}
|
||||||
|
{rating}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
more={m}
|
||||||
|
moreLabel={t("card.menu.more")}
|
||||||
|
/>
|
||||||
|
<MediaPreview
|
||||||
|
preview={preview}
|
||||||
|
title={card.title}
|
||||||
|
facts={
|
||||||
|
<span className="flex flex-wrap items-center gap-x-1.5">
|
||||||
|
{[card.year, corner].filter(Boolean).join(" · ")}
|
||||||
|
{rating && (!!card.year || !!corner) && <span aria-hidden>·</span>}
|
||||||
|
{rating}
|
||||||
</span>
|
</span>
|
||||||
|
}
|
||||||
|
summary={card.summary}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Hover reveal: a soft dim, a less-flat play ring, and a bottom scrim that slides up with
|
/** The picture's rest-state marks, in the corners the hover row leaves free (it docks bottom-right). */
|
||||||
the synopsis + rating (the info you'd otherwise click through for). */}
|
function WatchedBadge() {
|
||||||
<div className="absolute inset-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
const { t } = useTranslation();
|
||||||
<div className="absolute inset-0 bg-black/25" />
|
return <span className={clsx(MEDIA_BADGE, "top-1.5 right-1.5")}>{t("plex.watched")}</span>;
|
||||||
<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>
|
|
||||||
|
|
||||||
{pct > 0 && (
|
function InProgressBadge() {
|
||||||
<div className="absolute inset-x-0 bottom-0 z-base h-1 bg-black/40">
|
const { t } = useTranslation();
|
||||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
return (
|
||||||
</div>
|
<span className={clsx(MEDIA_BADGE, "top-1.5 right-1.5 text-accent")}>
|
||||||
)}
|
{t("plex.inProgress")}
|
||||||
</div>
|
</span>
|
||||||
<HoverBar
|
);
|
||||||
watched={watched}
|
}
|
||||||
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
|
|
||||||
onToggleWatched={toggle}
|
/** The bottom-right fact (a length, a count): it steps aside while the hover row covers its corner;
|
||||||
addLabel={t(plexAddLabelKey(card))}
|
* at rest, and on touch, it stays. */
|
||||||
onAdd={add}
|
function CornerBadge({ yields, children }: { yields: boolean; children: ReactNode }) {
|
||||||
more={m}
|
return (
|
||||||
/>
|
<span className={clsx(MEDIA_BADGE, "bottom-1.5 right-1.5", yields && HOVER_YIELD)}>
|
||||||
</div>
|
{children}
|
||||||
<Caption onClick={onOpen} more={m}>
|
</span>
|
||||||
<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>
|
|
||||||
</Caption>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,72 +429,45 @@ export function SeasonCard({
|
|||||||
const watched = se.status === "watched";
|
const watched = se.status === "watched";
|
||||||
const m = more(se, menuButtonProps, menuOpen);
|
const m = more(se, menuButtonProps, menuOpen);
|
||||||
return (
|
return (
|
||||||
<div
|
<MediaCard
|
||||||
className="group text-left"
|
dense
|
||||||
{...menuProps?.(se)}
|
rootProps={{
|
||||||
{...(navIndex !== undefined && navItemAttrs(navIndex, se.title))}
|
...menuProps?.(se),
|
||||||
>
|
...(navIndex !== undefined && navItemAttrs(navIndex, se.title)),
|
||||||
<div className="relative">
|
}}
|
||||||
<div
|
picture={
|
||||||
onClick={onOpen}
|
// A season opens its episode list: no Play on hover, since the click does not play.
|
||||||
role="button"
|
<PlexPicture
|
||||||
tabIndex={0}
|
aspect="poster"
|
||||||
data-nav-open=""
|
thumb={se.thumb}
|
||||||
onKeyDown={(e) => {
|
label={se.title}
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
onOpen={onOpen}
|
||||||
e.preventDefault();
|
plays={false}
|
||||||
onOpen();
|
bar={
|
||||||
}
|
<HoverBar
|
||||||
}}
|
watched={watched}
|
||||||
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`}
|
watchedLabel={
|
||||||
>
|
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
|
||||||
<img
|
}
|
||||||
src={se.thumb}
|
onToggleWatched={onToggleWatched}
|
||||||
alt=""
|
addLabel={t("plex.playlist.addSeason")}
|
||||||
loading="lazy"
|
onAdd={onAdd}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
more={m}
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
<HoverBar
|
|
||||||
watched={watched}
|
|
||||||
watchedLabel={
|
|
||||||
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
|
|
||||||
}
|
}
|
||||||
onToggleWatched={onToggleWatched}
|
>
|
||||||
addLabel={t("plex.playlist.addSeason")}
|
{watched && <WatchedBadge />}
|
||||||
onAdd={onAdd}
|
{/* A season has no progress bar, so this badge is its sole in-progress cue. */}
|
||||||
more={m}
|
{se.status === "in_progress" && <InProgressBadge />}
|
||||||
/>
|
<CornerBadge yields={!!(onToggleWatched || onAdd || m)}>
|
||||||
</div>
|
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||||
<Caption onClick={onOpen} more={m} className="text-sm font-medium leading-tight">
|
</CornerBadge>
|
||||||
<div className="line-clamp-2 hover:text-accent">{se.title}</div>
|
</PlexPicture>
|
||||||
</Caption>
|
}
|
||||||
</div>
|
title={<CardTitle onClick={onOpen}>{se.title}</CardTitle>}
|
||||||
|
more={m}
|
||||||
|
moreLabel={t("card.menu.more")}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,9 +571,10 @@ export function RelatedStrip({
|
|||||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
|
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
|
||||||
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
|
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
|
||||||
{related.map((r, i) => (
|
{related.map((r, i) => (
|
||||||
<div key={r.id} className="w-37.5 shrink-0">
|
<div key={r.id} className="grid w-37.5 shrink-0 grid-cols-1">
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
card={r}
|
card={r}
|
||||||
|
shelf
|
||||||
navIndex={i}
|
navIndex={i}
|
||||||
onOpen={() => onOpen(r)}
|
onOpen={() => onOpen(r)}
|
||||||
menuProps={menuProps}
|
menuProps={menuProps}
|
||||||
@@ -548,80 +612,62 @@ export function EpisodeCard({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const watched = ep.status === "watched";
|
const watched = ep.status === "watched";
|
||||||
const inProgress = (ep.position_seconds ?? 0) > 0 && !watched;
|
const pct = watched ? 0 : progressPct(ep);
|
||||||
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);
|
const m = more(ep, menuButtonProps, menuOpen);
|
||||||
|
const runtime = formatRuntime(ep.duration_seconds);
|
||||||
return (
|
return (
|
||||||
<div
|
<MediaCard
|
||||||
className="group"
|
dense
|
||||||
{...menuProps?.(ep)}
|
rootProps={{
|
||||||
{...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))}
|
...menuProps?.(ep),
|
||||||
>
|
...(navIndex !== undefined && navItemAttrs(navIndex, ep.title)),
|
||||||
<div className="relative">
|
}}
|
||||||
<div
|
picture={
|
||||||
onClick={onPlay}
|
<PlexPicture
|
||||||
role="button"
|
aspect="wide"
|
||||||
tabIndex={0}
|
thumb={ep.thumb}
|
||||||
data-nav-open=""
|
label={ep.title}
|
||||||
onKeyDown={(e) => {
|
onOpen={onPlay}
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
plays
|
||||||
e.preventDefault();
|
pct={pct}
|
||||||
onPlay();
|
bar={
|
||||||
}
|
<HoverBar
|
||||||
}}
|
watched={watched}
|
||||||
className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer"
|
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||||
|
onToggleWatched={onToggleWatched}
|
||||||
|
addLabel={t("plex.playlist.addEpisode")}
|
||||||
|
onAdd={onAdd}
|
||||||
|
more={m}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<img
|
{watched && <WatchedBadge />}
|
||||||
src={ep.thumb}
|
{runtime && (
|
||||||
alt=""
|
<CornerBadge yields={!!(onToggleWatched || onAdd || m)}>{runtime}</CornerBadge>
|
||||||
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>
|
|
||||||
{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 && (
|
</PlexPicture>
|
||||||
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
}
|
||||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
title={
|
||||||
</div>
|
<CardTitle onClick={onPlay}>
|
||||||
|
{!withShowTitle && ep.episode_number != null && (
|
||||||
|
<span className="text-muted mr-1">{ep.episode_number}.</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
{ep.title}
|
||||||
<HoverBar
|
</CardTitle>
|
||||||
watched={watched}
|
}
|
||||||
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
|
meta={
|
||||||
onToggleWatched={onToggleWatched}
|
withShowTitle &&
|
||||||
addLabel={t("plex.playlist.addEpisode")}
|
ep.show_title && (
|
||||||
onAdd={onAdd}
|
<div className="truncate text-xs text-muted">
|
||||||
more={m}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Caption more={m}>
|
|
||||||
{withShowTitle && ep.show_title && (
|
|
||||||
<div className="text-2xs text-muted truncate">
|
|
||||||
{ep.show_title}
|
{ep.show_title}
|
||||||
{ep.season_number != null && ep.episode_number != null
|
{ep.season_number != null && ep.episode_number != null
|
||||||
? ` · S${ep.season_number}·E${ep.episode_number}`
|
? ` · S${ep.season_number}·E${ep.episode_number}`
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
<div className="text-sm font-medium leading-tight">
|
}
|
||||||
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
|
more={m}
|
||||||
{ep.title}
|
moreLabel={t("card.menu.more")}
|
||||||
</div>
|
/>
|
||||||
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
|
|
||||||
</Caption>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ export function PlexPersonView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{d.biography && (
|
{d.biography && (
|
||||||
<p className="mt-4 whitespace-pre-line text-sm leading-relaxed text-muted">
|
<p className="measure mt-4 whitespace-pre-line text-sm leading-relaxed text-muted">
|
||||||
{d.biography}
|
{d.biography}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -277,8 +277,9 @@ export function PlexPersonView({
|
|||||||
keyboardNav
|
keyboardNav
|
||||||
>
|
>
|
||||||
{d.known_for.map((c, i) => (
|
{d.known_for.map((c, i) => (
|
||||||
<div key={c.id} className="w-37.5 shrink-0">
|
<div key={c.id} className="grid w-37.5 shrink-0 grid-cols-1">
|
||||||
<PlexPosterCard
|
<PlexPosterCard
|
||||||
|
shelf
|
||||||
card={c}
|
card={c}
|
||||||
navIndex={i}
|
navIndex={i}
|
||||||
onOpen={() => onOpenCard(c)}
|
onOpen={() => onOpenCard(c)}
|
||||||
@@ -470,9 +471,12 @@ export function PlexShowView({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PageHeading className="text-2xl font-semibold leading-tight">{show.title}</PageHeading>
|
<PageHeading className="text-2xl font-semibold leading-tight">{show.title}</PageHeading>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
|
{/* The same meta row as the media-info page: filtering values are chips, plain facts flat
|
||||||
|
text, spaced by the gap alone (a '·' between chips reads as noise). */}
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
|
||||||
{show.year != null && (
|
{show.year != null && (
|
||||||
<Filterable
|
<Filterable
|
||||||
|
tag
|
||||||
title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined}
|
title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined}
|
||||||
onClick={
|
onClick={
|
||||||
onFilter
|
onFilter
|
||||||
@@ -484,21 +488,19 @@ export function PlexShowView({
|
|||||||
</Filterable>
|
</Filterable>
|
||||||
)}
|
)}
|
||||||
{show.content_rating && (
|
{show.content_rating && (
|
||||||
<span>
|
<Filterable
|
||||||
·{" "}
|
tag
|
||||||
<Filterable
|
onClick={
|
||||||
onClick={
|
onFilter
|
||||||
onFilter
|
? () => onFilter({ contentRatings: [show.content_rating!] })
|
||||||
? () => onFilter({ contentRatings: [show.content_rating!] })
|
: undefined
|
||||||
: undefined
|
}
|
||||||
}
|
>
|
||||||
>
|
{show.content_rating}
|
||||||
{show.content_rating}
|
</Filterable>
|
||||||
</Filterable>
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{show.season_count != null && (
|
{show.season_count != null && (
|
||||||
<span>· {t("plex.seasons", { count: show.season_count })}</span>
|
<span>{t("plex.seasons", { count: show.season_count })}</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
// IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show
|
// IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show
|
||||||
@@ -518,7 +520,7 @@ export function PlexShowView({
|
|||||||
className={`inline-flex items-center gap-1 ${onFilter ? "min-h-6 cursor-pointer hover:text-accent touch-target" : "cursor-default"}`}
|
className={`inline-flex items-center gap-1 ${onFilter ? "min-h-6 cursor-pointer hover:text-accent touch-target" : "cursor-default"}`}
|
||||||
>
|
>
|
||||||
{/* the rating gold — its own token, not --warn: a score is not a warning */}
|
{/* the rating gold — its own token, not --warn: a score is not a warning */}
|
||||||
· <Star className="w-3.5 h-3.5 text-rating" fill="currentColor" />
|
<Star className="w-3.5 h-3.5 text-rating" fill="currentColor" />
|
||||||
{score.toFixed(1)}
|
{score.toFixed(1)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -538,7 +540,9 @@ export function PlexShowView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{show.summary && (
|
{show.summary && (
|
||||||
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">{show.summary}</p>
|
<p className="measure mt-3 line-clamp-5 text-sm leading-relaxed text-muted">
|
||||||
|
{show.summary}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
{show.resume && (
|
{show.resume && (
|
||||||
|
|||||||
@@ -132,7 +132,10 @@ export function Carousel({
|
|||||||
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
|
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
|
||||||
<div
|
<div
|
||||||
ref={setScroller}
|
ref={setScroller}
|
||||||
className={clsx("no-scrollbar -mx-1 -mt-1 flex overflow-x-auto px-1 pt-1 pb-1", gap)}
|
className={clsx(
|
||||||
|
"media-rail no-scrollbar -mx-1 -mt-1 flex overflow-x-auto px-1 pt-1 pb-1",
|
||||||
|
gap,
|
||||||
|
)}
|
||||||
style={{ maskImage: mask, WebkitMaskImage: mask }}
|
style={{ maskImage: mask, WebkitMaskImage: mask }}
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Play } from "lucide-react";
|
||||||
|
import { iconButtonClass } from "./buttonStyles";
|
||||||
|
import { MoreButton } from "./MoreButton";
|
||||||
|
import { SURFACE } from "./surfaces";
|
||||||
|
|
||||||
|
// The ONE media card (docs/DESIGN.md "Media card", E28 S3h): a YouTube video, a Plex movie, show,
|
||||||
|
// season or episode — in the feed, the Plex library, Home's shelves and the media pages' strips — is
|
||||||
|
// the same object: one glass frame holding the picture, then the title, then a meta line PINNED to the
|
||||||
|
// frame's bottom edge. Cards in a grid row stretch to the row's height, so pinning the meta puts every
|
||||||
|
// card's meta on one line whatever its title's length; a one-line title leaves room between the title
|
||||||
|
// and the meta, never an empty line under it (the owner's pick, option C, 2026-09-26). The length is
|
||||||
|
// a badge ON the picture, as YouTube draws it.
|
||||||
|
//
|
||||||
|
// The callers keep what differs (a YouTube picture is a link, a Plex picture is the card's play
|
||||||
|
// button) and build it from the pieces here and in ./mediaCardStyles, so the look cannot drift
|
||||||
|
// apart again.
|
||||||
|
|
||||||
|
/** The Play drawn on a picture whose whole box is the play button: the `media` IconButton's look
|
||||||
|
* (so it matches the YouTube card's real one), but only a picture of it — the box around it is the
|
||||||
|
* control, and a button inside a button is not allowed. Only where a click PLAYS: a show or a season
|
||||||
|
* opens a page, and a Play there would be a promise the click does not keep. */
|
||||||
|
export function MediaPlayGlyph({ small = false }: { small?: boolean }) {
|
||||||
|
return (
|
||||||
|
<span aria-hidden className={iconButtonClass({ variant: "media", size: small ? "md" : "lg" })}>
|
||||||
|
<Play className="translate-x-px fill-current" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far into it the viewer is: a bar along the picture's bottom edge. */
|
||||||
|
export function MediaProgress({ pct }: { pct: number }) {
|
||||||
|
if (pct <= 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-x-0 bottom-0 z-base h-1 bg-black/40">
|
||||||
|
<div className="h-full bg-accent" style={{ width: `${Math.min(100, pct)}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaCard({
|
||||||
|
rootProps,
|
||||||
|
dense = false,
|
||||||
|
dim = false,
|
||||||
|
picture,
|
||||||
|
leading,
|
||||||
|
title,
|
||||||
|
meta,
|
||||||
|
more,
|
||||||
|
moreLabel = "",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
/** The card's own attributes: its right-click menu, its keyboard-walk position, a test id. */
|
||||||
|
rootProps?: HTMLAttributes<HTMLDivElement> & Record<string, unknown>;
|
||||||
|
/** A narrow column (a small card, a rail, a poster grid): tighter padding and a smaller title. */
|
||||||
|
dense?: boolean;
|
||||||
|
/** Fades the whole card (a watched video in the feed). */
|
||||||
|
dim?: boolean;
|
||||||
|
picture: ReactNode;
|
||||||
|
/** Beside the text, before the title (the YouTube channel's avatar). */
|
||||||
|
leading?: ReactNode;
|
||||||
|
title: ReactNode;
|
||||||
|
/** The line pinned to the frame's bottom edge; omit for a card with none. */
|
||||||
|
meta?: ReactNode;
|
||||||
|
/** The card's "…": on touch it sits beside the title as a 44px target (the hover row's twin). */
|
||||||
|
more?: { props: Record<string, unknown>; open: boolean };
|
||||||
|
/** The "…"'s name (the caller's words: this platform file speaks no feature namespace). */
|
||||||
|
moreLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...rootProps}
|
||||||
|
className={clsx(
|
||||||
|
`group ${SURFACE.card} glass-hover flex flex-col text-left transition-all duration-150`,
|
||||||
|
dense ? "p-2" : "p-2.5",
|
||||||
|
dim && "opacity-55",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{picture}
|
||||||
|
<div className={clsx("flex flex-1 gap-3 px-0.5 pb-0.5", dense ? "mt-2" : "mt-2.5")}>
|
||||||
|
{leading}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{title}
|
||||||
|
{meta && <div className="mt-auto pt-1">{meta}</div>}
|
||||||
|
</div>
|
||||||
|
{more && (
|
||||||
|
<MoreButton
|
||||||
|
className="touch-only -mr-1 shrink-0"
|
||||||
|
label={moreLabel}
|
||||||
|
expanded={more.open}
|
||||||
|
menuProps={more.props}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { PopoverSurface } from "./AnchoredPopover";
|
||||||
|
import type { useMediaPreview } from "./useMediaPreview";
|
||||||
|
import { SURFACE } from "./surfaces";
|
||||||
|
|
||||||
|
// A media card's preview (docs/DESIGN.md "Media card", E28 S3h — the owner's pick C, 2026-09-26):
|
||||||
|
// rest the mouse on a card for a moment and a panel beside it shows what the card has no room for —
|
||||||
|
// the whole title, the facts, the synopsis in lines short enough to read. It replaced the synopsis as
|
||||||
|
// the picture's native tooltip, which fired anywhere on the art, ran one long line, and shared its
|
||||||
|
// mechanism with the cut title's own tooltip.
|
||||||
|
//
|
||||||
|
// Mouse only (`pointerOnly`): a keyboard walk down a grid would pop a panel at every stop, and on
|
||||||
|
// touch a tap is the card's play. The same facts are one row away there ("…" → Media info). Passive
|
||||||
|
// (`passthrough`), so the panel lying over the next card never eats the click aimed at it; any press
|
||||||
|
// on the card closes it, so it never sits over a menu the card just opened.
|
||||||
|
|
||||||
|
export function MediaPreview({
|
||||||
|
preview,
|
||||||
|
title,
|
||||||
|
facts,
|
||||||
|
summary,
|
||||||
|
}: {
|
||||||
|
preview: ReturnType<typeof useMediaPreview>;
|
||||||
|
title: string;
|
||||||
|
/** One line under the title: year, length, score. */
|
||||||
|
facts?: ReactNode;
|
||||||
|
summary?: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PopoverSurface
|
||||||
|
popover={preview.popover}
|
||||||
|
passthrough
|
||||||
|
// 18rem at text-xs: ~50 characters a line, inside the 45–75 a reader follows without losing
|
||||||
|
// the next line's start.
|
||||||
|
className={`${SURFACE.popover} w-72 px-3.5 py-3 text-left normal-case tracking-normal animate-[fadeIn_0.12s_ease]`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold leading-snug text-fg">{title}</p>
|
||||||
|
{facts && <div className="mt-0.5 text-xs text-muted">{facts}</div>}
|
||||||
|
{summary && <p className="mt-2 line-clamp-8 text-xs leading-relaxed text-fg">{summary}</p>}
|
||||||
|
</PopoverSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ domain-free.
|
|||||||
|
|
||||||
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
|
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
|
||||||
link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
||||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ export interface AnchoredPopoverOptions {
|
|||||||
* must tap. `mouseOnly` is forced on so a tap's synthetic mouseenter can't open the panel a
|
* must tap. `mouseOnly` is forced on so a tap's synthetic mouseenter can't open the panel a
|
||||||
* hair before the click handler toggles it shut again. */
|
* hair before the click handler toggles it shut again. */
|
||||||
hoverAlso?: boolean;
|
hoverAlso?: boolean;
|
||||||
|
/** Hover mode only: a MOUSE affordance and nothing else — no focus-open, no touch. A media card's
|
||||||
|
* preview (ui/MediaPreview): a keyboard walk down a grid would otherwise pop a panel at every stop,
|
||||||
|
* and a tap is the card's play. */
|
||||||
|
pointerOnly?: boolean;
|
||||||
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
|
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
|
||||||
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
|
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
|
||||||
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
|
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
|
||||||
@@ -157,13 +161,13 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
|||||||
move: false,
|
move: false,
|
||||||
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
|
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
|
||||||
// synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut).
|
// synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut).
|
||||||
mouseOnly: !isHover,
|
mouseOnly: !isHover || !!opts.pointerOnly,
|
||||||
// A submenu wants a shorter intent delay than a hover card: the pointer is already inside a
|
// A submenu wants a shorter intent delay than a hover card: the pointer is already inside a
|
||||||
// menu, so the "did they mean it" question is mostly answered.
|
// menu, so the "did they mean it" question is mostly answered.
|
||||||
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
|
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
|
||||||
handleClose: safePolygon(),
|
handleClose: safePolygon(),
|
||||||
});
|
});
|
||||||
const focus = useFocus(context, { enabled: isHover });
|
const focus = useFocus(context, { enabled: isHover && !opts.pointerOnly });
|
||||||
// Escape for EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it
|
// Escape for EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it
|
||||||
// is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape
|
// is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape
|
||||||
// off entirely. (Leaving it on for hover made an open tooltip's document-level handler either
|
// off entirely. (Leaving it on for hover made an open tooltip's document-level handler either
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { HOVER_REVEAL } from "./reveal";
|
||||||
|
|
||||||
|
// The one media card's classes (ui/MediaCard): the pieces a caller builds its picture from, kept in
|
||||||
|
// a module of their own so the component file exports only components (fast refresh).
|
||||||
|
|
||||||
|
/** The picture's own box: rounded inside the frame, edged, lifting its shadow on hover. */
|
||||||
|
export const MEDIA_PICTURE =
|
||||||
|
"relative block h-full w-full overflow-hidden rounded-xl border border-border bg-surface shadow-md transition-shadow group-hover:shadow-2xl";
|
||||||
|
|
||||||
|
/** The picture's image: covers the box and zooms a little on hover. */
|
||||||
|
export const MEDIA_IMG =
|
||||||
|
"h-full w-full object-cover transition-transform duration-200 group-hover:scale-105";
|
||||||
|
|
||||||
|
/** A fact printed on the picture (a length, a season count, "Watched"): dark, legible on any art. */
|
||||||
|
export const MEDIA_BADGE =
|
||||||
|
"pointer-events-none absolute rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums";
|
||||||
|
|
||||||
|
/** The hover layer over the picture: a dim that carries the Play (mouse only, like the hover row). */
|
||||||
|
export const MEDIA_HOVER_LAYER =
|
||||||
|
"reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100";
|
||||||
|
|
||||||
|
/** Where the card's hover row (watched, save, add, "…") docks: the picture's bottom-right. */
|
||||||
|
export const MEDIA_BAR = `${HOVER_REVEAL} absolute bottom-2.5 right-1.5 z-base`;
|
||||||
|
|
||||||
|
/** The title's type, one size per density. On a SHELF (any horizontal strip: Home, the Plex rails,
|
||||||
|
* a collection strip) it is one line, cut with an ellipsis, over one meta line — every card on the
|
||||||
|
* strip is then exactly two lines of text under its picture (the owner, 2026-09-26: the pinned meta
|
||||||
|
* still left a hole in the cards with less to say). A grid keeps two lines and its full meta. */
|
||||||
|
export const mediaTitleClass = (dense: boolean, shelf = false) =>
|
||||||
|
clsx(
|
||||||
|
"font-medium leading-snug",
|
||||||
|
shelf ? "block truncate" : "line-clamp-2",
|
||||||
|
(dense || shelf) && "text-sm",
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The box that holds the picture at its ratio. `min-h-0`: an aspect-ratio box otherwise grows to
|
||||||
|
* its content's height, so a square channel avatar made a 16:9 slot square (measured 182×182). */
|
||||||
|
export const mediaPictureBox = (aspect: "poster" | "wide") =>
|
||||||
|
clsx("relative min-h-0", aspect === "poster" ? "aspect-2/3" : "aspect-video");
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// A card skips its off-screen rendering (`cv-card` / `cv-poster`: content-visibility auto with a
|
||||||
|
// reserved height), but not inside a carousel. A rail stretches every card to its tallest, and an
|
||||||
|
// off-screen card's reserve WAS the tallest — Home's shelves measured 338px around 170–216px cards
|
||||||
|
// (E28 S3h). The pair lives in two files and neither can be seen in jsdom, so it is held here.
|
||||||
|
const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8");
|
||||||
|
|
||||||
|
describe("a carousel turns the cards' content-visibility off", () => {
|
||||||
|
it("the carousel's track is the .media-rail", () => {
|
||||||
|
expect(read("./Carousel.tsx")).toMatch(/["`]media-rail /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("index.css makes both card kinds visible inside it", () => {
|
||||||
|
expect(read("../../index.css")).toMatch(
|
||||||
|
/\.media-rail \.cv-card,\s*\.media-rail \.cv-poster\s*\{\s*content-visibility: visible;/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,10 @@ const files = import.meta.glob(["../**/*.tsx", "!../**/*.test.*", "!../design-la
|
|||||||
|
|
||||||
/** file → [how many plain Pressables, why they are not titles]. */
|
/** file → [how many plain Pressables, why they are not titles]. */
|
||||||
const PLAIN: Record<string, [number, string]> = {
|
const PLAIN: Record<string, [number, string]> = {
|
||||||
"Home.tsx": [1, "the hero's picture (named by aria-label; its title sits outside)"],
|
"Home.tsx": [
|
||||||
|
2,
|
||||||
|
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
|
||||||
|
],
|
||||||
"LanguageSwitcher.tsx": [1, "an option row"],
|
"LanguageSwitcher.tsx": [1, "an option row"],
|
||||||
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
|
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
|
||||||
"PaletteLauncher.tsx": [2, "launcher controls"],
|
"PaletteLauncher.tsx": [2, "launcher controls"],
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState, type HTMLAttributes } from "react";
|
||||||
|
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||||
|
|
||||||
|
// The hover wiring of a media card's preview (./MediaPreview has the why).
|
||||||
|
|
||||||
|
/** How long the pointer must rest on a card: long enough that a sweep across a grid opens nothing. */
|
||||||
|
const MEDIA_PREVIEW_DELAY = 900;
|
||||||
|
|
||||||
|
export function useMediaPreview(enabled = true) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const pop = useAnchoredPopover({
|
||||||
|
open: open && enabled,
|
||||||
|
onOpenChange: setOpen,
|
||||||
|
mode: "hover",
|
||||||
|
placement: "right-start",
|
||||||
|
offset: 8,
|
||||||
|
hoverDelay: { open: MEDIA_PREVIEW_DELAY, close: 0 },
|
||||||
|
pointerOnly: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
popover: pop,
|
||||||
|
/** The card root's props: the anchor, the hover wiring, and a close on any press — merged with
|
||||||
|
* the root's own (its menu's right-click and long-press). */
|
||||||
|
rootProps: (own: HTMLAttributes<HTMLDivElement> & Record<string, unknown> = {}) => ({
|
||||||
|
...(pop.getReferenceProps({
|
||||||
|
...own,
|
||||||
|
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
setOpen(false);
|
||||||
|
(own.onPointerDown as ((e: React.PointerEvent<HTMLDivElement>) => void) | undefined)?.(e);
|
||||||
|
},
|
||||||
|
onContextMenu: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
setOpen(false);
|
||||||
|
(own.onContextMenu as ((e: React.MouseEvent<HTMLDivElement>) => void) | undefined)?.(e);
|
||||||
|
},
|
||||||
|
}) as HTMLAttributes<HTMLDivElement> & Record<string, unknown>),
|
||||||
|
ref: pop.refs.setReference,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { SCHEMES, type Scheme } from "../lib/theme";
|
|||||||
import { BatchA } from "./batchA";
|
import { BatchA } from "./batchA";
|
||||||
import { BatchB } from "./batchB";
|
import { BatchB } from "./batchB";
|
||||||
import { BatchC } from "./batchC";
|
import { BatchC } from "./batchC";
|
||||||
|
import { BatchD } from "./batchD";
|
||||||
import { Chip } from "../components/ui/Chip";
|
import { Chip } from "../components/ui/Chip";
|
||||||
import { Slider } from "../components/ui/NativeControls";
|
import { Slider } from "../components/ui/NativeControls";
|
||||||
import { SURFACE } from "../components/ui/surfaces";
|
import { SURFACE } from "../components/ui/surfaces";
|
||||||
@@ -82,6 +83,9 @@ export default function DesignLab() {
|
|||||||
<a href="#decided-c" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
<a href="#decided-c" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
||||||
C ✓
|
C ✓
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#decided-s3h" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
||||||
|
S3h ✓
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,6 +118,14 @@ export default function DesignLab() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<BatchC />
|
<BatchC />
|
||||||
|
<header id="decided-s3h" className="mt-6 scroll-mt-20">
|
||||||
|
<h2 className="text-xl font-bold">Decided · S3h, the media card</h2>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Hover a card: its Play and its hover row. The preview, the hero and the info page live
|
||||||
|
in the app.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<BatchD />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { Bookmark, Check, ListPlus, MoreHorizontal, Star } from "lucide-react";
|
||||||
|
import { Reference, Sample } from "./labKit";
|
||||||
|
import { MediaCard, MediaPlayGlyph } from "../components/ui/MediaCard";
|
||||||
|
import {
|
||||||
|
MEDIA_BADGE,
|
||||||
|
MEDIA_BAR,
|
||||||
|
MEDIA_HOVER_LAYER,
|
||||||
|
MEDIA_PICTURE,
|
||||||
|
mediaPictureBox,
|
||||||
|
mediaTitleClass,
|
||||||
|
} from "../components/ui/mediaCardStyles";
|
||||||
|
import { iconButtonClass } from "../components/ui/buttonStyles";
|
||||||
|
import { HOVER_YIELD } from "../components/ui/reveal";
|
||||||
|
|
||||||
|
// E28 S3h — the one media card, DECIDED (owner, 2026-09-26/27: anatomy C, shelf density, the preview
|
||||||
|
// C, the Home hero A, list row B, info layout B). Drawn with the real ui/MediaCard and its classes;
|
||||||
|
// hover a card to see its Play and its hover row. The preview panel, the hero and the info page are
|
||||||
|
// live in the app (Plex, Home), not copied here.
|
||||||
|
|
||||||
|
const BAR_BUTTON = iconButtonClass({ variant: "media", size: "sm" });
|
||||||
|
|
||||||
|
function HoverRow({ save = false }: { save?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={MEDIA_BAR}>
|
||||||
|
<span className="flex gap-1">
|
||||||
|
{[Check, ...(save ? [Bookmark] : []), ListPlus, MoreHorizontal].map((Icon, i) => (
|
||||||
|
<span key={i} className={BAR_BUTTON}>
|
||||||
|
<Icon />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Picture({
|
||||||
|
aspect,
|
||||||
|
length,
|
||||||
|
small = false,
|
||||||
|
save = false,
|
||||||
|
}: {
|
||||||
|
aspect: "poster" | "wide";
|
||||||
|
length: string;
|
||||||
|
small?: boolean;
|
||||||
|
save?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={mediaPictureBox(aspect)}>
|
||||||
|
<div className={clsx(MEDIA_PICTURE, "bg-linear-to-br from-accent/45 to-card")}>
|
||||||
|
<span className={clsx(MEDIA_BADGE, "bottom-1.5 right-1.5", HOVER_YIELD)}>{length}</span>
|
||||||
|
<div className={MEDIA_HOVER_LAYER}>
|
||||||
|
<MediaPlayGlyph small={small} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HoverRow save={save} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
<Star className="h-3 w-3 fill-current text-rating" aria-hidden />
|
||||||
|
7.8
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const plexMeta = (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs leading-6 text-muted">
|
||||||
|
<span>2003</span>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
{score}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function BatchD() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Reference
|
||||||
|
id="s3h-card"
|
||||||
|
code="S3h"
|
||||||
|
title="The media card"
|
||||||
|
rule="One frame: the picture, the title, the meta pinned to the bottom edge. The length is a badge on the picture; the Play shows only where the click plays; the hover row docks bottom-right and the length steps aside. A grid card: two title lines and the full meta. On a shelf: one title line over one meta line, a 232px slot, and a landscape picture takes the smaller Play."
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-stretch gap-4">
|
||||||
|
<Sample label="Grid · a film (poster)">
|
||||||
|
<div className="grid w-44 grid-cols-1">
|
||||||
|
<MediaCard
|
||||||
|
dense
|
||||||
|
picture={<Picture aspect="poster" length="2h 34m" />}
|
||||||
|
title={
|
||||||
|
<div className={clsx("min-h-6", mediaTitleClass(true))}>
|
||||||
|
The Last Samurai, a title long enough to wrap
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
meta={plexMeta}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Sample>
|
||||||
|
<Sample label="Grid · a video">
|
||||||
|
<div className="grid w-72 grid-cols-1">
|
||||||
|
<MediaCard
|
||||||
|
picture={<Picture aspect="wide" length="18:04" save />}
|
||||||
|
title={<div className={mediaTitleClass(false)}>A video title</div>}
|
||||||
|
meta={
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-muted">A channel</div>
|
||||||
|
<div className="mt-0.5 text-xs text-muted">12K views · 3 days ago</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Sample>
|
||||||
|
<Sample label="Shelf · 232px, landscape, the smaller Play">
|
||||||
|
<div className="grid w-58 grid-cols-1">
|
||||||
|
<MediaCard
|
||||||
|
dense
|
||||||
|
picture={<Picture aspect="wide" length="2h 34m" small />}
|
||||||
|
title={
|
||||||
|
<div className={clsx("min-h-6", mediaTitleClass(true, true))}>
|
||||||
|
The Last Samurai, cut to one line on a shelf
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
meta={plexMeta}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Sample>
|
||||||
|
</div>
|
||||||
|
</Reference>
|
||||||
|
<Reference
|
||||||
|
id="s3h-measure"
|
||||||
|
code="S3h"
|
||||||
|
title="The reading measure"
|
||||||
|
rule="Prose (a synopsis, a biography, a channel's description) runs at most ~65 characters a line: the `measure` utility, wherever its container can be wider."
|
||||||
|
>
|
||||||
|
<p className="measure text-sm leading-relaxed text-muted">
|
||||||
|
Bourne is brought out of hiding once again by a reporter who is trying to unveil an
|
||||||
|
operation, an upgrade to a programme, in a series of newspaper columns. Information from
|
||||||
|
the reporter stirs a new set of memories, and Bourne must finally uncover his dark past.
|
||||||
|
</p>
|
||||||
|
</Reference>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,7 +39,8 @@
|
|||||||
"moveRight": "Right",
|
"moveRight": "Right",
|
||||||
"moveLast": "To the end",
|
"moveLast": "To the end",
|
||||||
"moveFailed": "Could not reorder the shelf.",
|
"moveFailed": "Could not reorder the shelf.",
|
||||||
"moveSaving": "Saving the new order…"
|
"moveSaving": "Saving the new order…",
|
||||||
|
"openPlaylist": "Open the playlist"
|
||||||
},
|
},
|
||||||
"pin": {
|
"pin": {
|
||||||
"menu": "Comfort shelf",
|
"menu": "Comfort shelf",
|
||||||
|
|||||||
@@ -125,6 +125,9 @@
|
|||||||
"menu": "Media",
|
"menu": "Media",
|
||||||
"markWatched": "Mark watched",
|
"markWatched": "Mark watched",
|
||||||
"markUnwatched": "Mark unwatched",
|
"markUnwatched": "Mark unwatched",
|
||||||
|
"markNextWatched": "Mark S{{season}}·E{{episode}} watched",
|
||||||
|
"markNextUnwatched": "Mark S{{season}}·E{{episode}} unwatched",
|
||||||
|
"nextUp": "Next: S{{season}}·E{{episode}}",
|
||||||
"watchedFailed": "Couldn't update the watched state — please try again.",
|
"watchedFailed": "Couldn't update the watched state — please try again.",
|
||||||
"seasons": "{{count}} seasons",
|
"seasons": "{{count}} seasons",
|
||||||
"unified": {
|
"unified": {
|
||||||
@@ -243,7 +246,7 @@
|
|||||||
"title": "Media info",
|
"title": "Media info",
|
||||||
"customize": "Customize view",
|
"customize": "Customize view",
|
||||||
"prefArtBg": "Faint backdrop",
|
"prefArtBg": "Faint backdrop",
|
||||||
"prefCast": "Cast & crew",
|
"prefCast": "Cast",
|
||||||
"prefRelated": "Related shows",
|
"prefRelated": "Related shows",
|
||||||
"prefFacts": "Details",
|
"prefFacts": "Details",
|
||||||
"prefKeywords": "Keywords",
|
"prefKeywords": "Keywords",
|
||||||
@@ -258,7 +261,7 @@
|
|||||||
},
|
},
|
||||||
"openImdb": "Open on IMDb",
|
"openImdb": "Open on IMDb",
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast & crew",
|
"cast": "Cast",
|
||||||
"markWatched": "Mark watched",
|
"markWatched": "Mark watched",
|
||||||
"markUnwatched": "Mark unwatched",
|
"markUnwatched": "Mark unwatched",
|
||||||
"clearResume": "Clear resume position",
|
"clearResume": "Clear resume position",
|
||||||
|
|||||||
@@ -39,7 +39,8 @@
|
|||||||
"moveRight": "Jobbra",
|
"moveRight": "Jobbra",
|
||||||
"moveLast": "A polc végére",
|
"moveLast": "A polc végére",
|
||||||
"moveFailed": "Nem sikerült átrendezni a polcot.",
|
"moveFailed": "Nem sikerült átrendezni a polcot.",
|
||||||
"moveSaving": "Az új sorrend mentése…"
|
"moveSaving": "Az új sorrend mentése…",
|
||||||
|
"openPlaylist": "Lejátszási lista megnyitása"
|
||||||
},
|
},
|
||||||
"pin": {
|
"pin": {
|
||||||
"menu": "Komfort polc",
|
"menu": "Komfort polc",
|
||||||
|
|||||||
@@ -125,6 +125,9 @@
|
|||||||
"menu": "Média",
|
"menu": "Média",
|
||||||
"markWatched": "Megnézettnek jelöl",
|
"markWatched": "Megnézettnek jelöl",
|
||||||
"markUnwatched": "Nem-nézettnek jelöl",
|
"markUnwatched": "Nem-nézettnek jelöl",
|
||||||
|
"markNextWatched": "S{{season}}·E{{episode}} megnézettnek jelöl",
|
||||||
|
"markNextUnwatched": "S{{season}}·E{{episode}} nem-nézettnek jelöl",
|
||||||
|
"nextUp": "Következik: S{{season}}·E{{episode}}",
|
||||||
"watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra.",
|
"watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra.",
|
||||||
"seasons": "{{count}} évad",
|
"seasons": "{{count}} évad",
|
||||||
"unified": {
|
"unified": {
|
||||||
@@ -258,7 +261,7 @@
|
|||||||
},
|
},
|
||||||
"openImdb": "Megnyitás az IMDb-n",
|
"openImdb": "Megnyitás az IMDb-n",
|
||||||
"director": "Rendező",
|
"director": "Rendező",
|
||||||
"cast": "Szereplők & stáb",
|
"cast": "Szereplők",
|
||||||
"markWatched": "Megjelölés megnézettként",
|
"markWatched": "Megjelölés megnézettként",
|
||||||
"markUnwatched": "Megnézett visszavonása",
|
"markUnwatched": "Megnézett visszavonása",
|
||||||
"clearResume": "Folytatási pozíció törlése",
|
"clearResume": "Folytatási pozíció törlése",
|
||||||
|
|||||||
@@ -142,6 +142,14 @@
|
|||||||
border-color: var(--edge-strong);
|
border-color: var(--edge-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The reading measure for prose (a synopsis, a biography, a channel's description): ~65 characters
|
||||||
|
a line, inside the 45–75 a reader follows back to the next line's start. A full-width page ran
|
||||||
|
one to 110 (owner, E28 S3h: "shorter lines are easier to follow"). Only where the container can
|
||||||
|
be wider than that; a narrow panel or popover is its own measure. */
|
||||||
|
@utility measure {
|
||||||
|
max-width: 65ch;
|
||||||
|
}
|
||||||
|
|
||||||
/* B9: the header + fade edge — a crisp band with a 1rem gradient of the colour it sits on, so content
|
/* B9: the header + fade edge — a crisp band with a 1rem gradient of the colour it sits on, so content
|
||||||
dissolves as it slides under (the reference is DataTable's sticky header). The host is positioned. */
|
dissolves as it slides under (the reference is DataTable's sticky header). The host is positioned. */
|
||||||
/* A STICKY header's fade (a table head, the server log's toolbar), inside the scroller it sticks in.
|
/* A STICKY header's fade (a table head, the server log's toolbar), inside the scroller it sticks in.
|
||||||
@@ -865,6 +873,18 @@ html[data-perf="1"] .overlay-backdrop {
|
|||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
contain-intrinsic-size: auto 320px;
|
contain-intrinsic-size: auto 320px;
|
||||||
}
|
}
|
||||||
|
/* A Plex poster card in a grid: the same skip, at a poster's height. */
|
||||||
|
.cv-poster {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 300px;
|
||||||
|
}
|
||||||
|
/* ...but not in a carousel. A rail stretches every card to its tallest (the pinned meta line,
|
||||||
|
ui/MediaCard), and an off-screen card's reserved box WAS the tallest: Home's shelves measured 338px
|
||||||
|
where the real cards were 170–216. A rail holds a handful of cards; there is nothing to skip. */
|
||||||
|
.media-rail .cv-card,
|
||||||
|
.media-rail .cv-poster {
|
||||||
|
content-visibility: visible;
|
||||||
|
}
|
||||||
.cv-row {
|
.cv-row {
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
contain-intrinsic-size: auto 96px;
|
contain-intrinsic-size: auto 96px;
|
||||||
|
|||||||
Reference in New Issue
Block a user