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 (
|
||||
<>
|
||||
{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)}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
|
||||
import { StrictMode } from "react";
|
||||
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 { qk } from "../lib/queryKeys";
|
||||
|
||||
@@ -785,3 +785,58 @@ describe("Home · keyboard", () => {
|
||||
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}`,
|
||||
}));
|
||||
|
||||
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() }) }));
|
||||
|
||||
import Home from "./Home";
|
||||
@@ -150,6 +156,7 @@ const item = (label: string) => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
openChannel.mockReset();
|
||||
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
|
||||
window.history.replaceState(null, "");
|
||||
discover.mockReset().mockResolvedValue(payload());
|
||||
@@ -166,6 +173,8 @@ describe("Home — the comfort shelf's context menu", () => {
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort B"));
|
||||
expect(labels()).toEqual([
|
||||
"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.moveLeft",
|
||||
"home.shelf.moveRight",
|
||||
@@ -341,3 +350,28 @@ describe("Home — the comfort shelf's context menu", () => {
|
||||
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,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ArrowUpRight,
|
||||
PinOff,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import {
|
||||
@@ -26,10 +28,9 @@ import {
|
||||
} from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
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 { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
|
||||
import { ComfortPinMenu } from "./ComfortPinMenu";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
@@ -37,15 +38,29 @@ import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import VideoCard from "./VideoCard";
|
||||
import VideoCard, { VideoHoverActions } from "./VideoCard";
|
||||
import { useVideoActions } from "./useVideoActions";
|
||||
import { usePlexCardActions } from "./usePlexCardActions";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import { PlexHoverBar, PlexPosterCard } from "./plexCards";
|
||||
import { usePlaylistsActions } from "./playlistsContext";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
import { usePlexActions } from "./plexContext";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
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?".
|
||||
//
|
||||
@@ -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
|
||||
// silently kept offering four.
|
||||
|
||||
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
|
||||
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
|
||||
// drift from it. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
|
||||
// A rail tile's slot width. `cardsSmall` is the feed's own dense card, so the slot holds the same
|
||||
// object the feed shows — not a Home-only variant that would drift from it. 232, not the 200 it was:
|
||||
// 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.
|
||||
const YT_SLOT = 200;
|
||||
const YT_SLOT = 232;
|
||||
|
||||
// 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
|
||||
@@ -89,6 +105,151 @@ const SHELF_MOVES: readonly [ShelfMove, string, typeof ChevronLeft][] = [
|
||||
["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({
|
||||
title,
|
||||
ariaLabel,
|
||||
@@ -118,83 +279,16 @@ function Rail({
|
||||
/** A reorder is being written: the move rows wait for it (one write at a time). */
|
||||
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 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)),
|
||||
];
|
||||
const { menu, items, panels, plexAdd } = useHomeCardMenu({
|
||||
cards,
|
||||
onPlay,
|
||||
onInfo,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onToggleWatched,
|
||||
onMove,
|
||||
moveSaving,
|
||||
});
|
||||
|
||||
// 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.
|
||||
@@ -221,13 +315,19 @@ function Rail({
|
||||
{cards.map((c, i) => (
|
||||
<div
|
||||
key={`${c.source}:${c.kind}:${c.id}`}
|
||||
className={c.aspect === "poster" ? "w-37.5 shrink-0" : "shrink-0"}
|
||||
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
|
||||
// Every shelf card is landscape, a Plex movie or show too (its wide art): a shelf mixes
|
||||
// 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 ? (
|
||||
<VideoCard
|
||||
video={c.video}
|
||||
view="cardsSmall"
|
||||
shelf
|
||||
width={YT_SLOT}
|
||||
navIndex={i}
|
||||
onState={onState}
|
||||
@@ -240,23 +340,33 @@ function Rail({
|
||||
) : c.plex ? (
|
||||
<PlexPosterCard
|
||||
card={c.plex}
|
||||
wide
|
||||
shelf
|
||||
navIndex={i}
|
||||
onOpen={() => onPlay(c)}
|
||||
onToggleWatched={onToggleWatched}
|
||||
onAdd={plexActions.add}
|
||||
onAdd={plexAdd}
|
||||
next={c.next?.plex}
|
||||
onInfo={() => onInfo(c)}
|
||||
menuProps={menuFor[i]}
|
||||
menuButtonProps={buttonFor[i]}
|
||||
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>
|
||||
))}
|
||||
</Carousel>
|
||||
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
|
||||
{videoActions.panel}
|
||||
{plexActions.panel}
|
||||
{panels}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -268,45 +378,90 @@ function ShelfTile({
|
||||
card,
|
||||
onPlay,
|
||||
menuProps,
|
||||
menuButtonProps,
|
||||
menuOpen = false,
|
||||
navIndex,
|
||||
}: {
|
||||
card: DiscoverCard;
|
||||
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;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const openPage = useOpenShelfPage();
|
||||
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
|
||||
const countLabel =
|
||||
card.kind === "channel"
|
||||
? t("home.shelf.unwatchedCount", { 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 (
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={() => card.next && onPlay(card)}
|
||||
{...menuProps?.(card)}
|
||||
{...(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
|
||||
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
|
||||
// than it looks: a real `disabled` button DOES still receive `contextmenu` and `pointerdown`,
|
||||
// so mouse and long-press would both survive it — what it loses is FOCUS, and with it
|
||||
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
|
||||
// alive to all three input paths.
|
||||
aria-disabled={!card.next}
|
||||
className={`${SURFACE.card} glass-hover block w-full overflow-hidden transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent`}
|
||||
>
|
||||
<span className="block aspect-video bg-card overflow-hidden">
|
||||
{card.thumb ? (
|
||||
<img src={card.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="block p-2">
|
||||
<span className="block text-xs font-medium truncate">{card.title}</span>
|
||||
<span className="block text-2xs text-muted">{countLabel}</span>
|
||||
</span>
|
||||
</Pressable>
|
||||
<MediaCard
|
||||
dense
|
||||
rootProps={{
|
||||
...menuProps,
|
||||
...(navIndex !== undefined && navItemAttrs(navIndex)),
|
||||
}}
|
||||
className={!card.next ? "opacity-60" : undefined}
|
||||
picture={
|
||||
// The ratio lives on a wrapper, as in the other cards: MEDIA_PICTURE fills its parent, and
|
||||
// on the stretched frame itself that would be the whole card's height.
|
||||
<div className={mediaPictureBox("wide")}>
|
||||
<Pressable
|
||||
onClick={() => card.next && onPlay(card)}
|
||||
aria-label={card.title}
|
||||
// `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 that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is
|
||||
// narrower than it looks: a real `disabled` button DOES still receive `contextmenu` and
|
||||
// `pointerdown`, so mouse and long-press would both survive it — what it loses is FOCUS,
|
||||
// and with it Shift+F10, the only keyboard route to the menu. Inert to the click (the
|
||||
// handler guards), alive to all three input paths.
|
||||
aria-disabled={!card.next}
|
||||
data-nav-open=""
|
||||
className={MEDIA_PICTURE}
|
||||
>
|
||||
{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,
|
||||
card,
|
||||
onPlay,
|
||||
onInfo,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onToggleWatched,
|
||||
onReroll,
|
||||
rerolling,
|
||||
}: {
|
||||
tier: HeroTier;
|
||||
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;
|
||||
rerolling: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The hero is the likeliest thing on the page to be comfort media — it is what the surface just
|
||||
// answered with — so it answers the same right-click every card in the app does.
|
||||
const pinMenu = useContextMenu<DiscoverCard>();
|
||||
// The hero is a card like any other (the owner, 2026-09-27): the same hover row on its picture and
|
||||
// the same whole action list on its "…", right-click and long-press — it was a pin row alone.
|
||||
const { menu, items, panels, plexAdd } = useHomeCardMenu({
|
||||
cards: [card],
|
||||
onPlay,
|
||||
onInfo,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onToggleWatched,
|
||||
});
|
||||
const started = card.position_seconds > 0;
|
||||
const left = card.remaining_seconds;
|
||||
// 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.surprise");
|
||||
|
||||
return (
|
||||
<section
|
||||
{...pinMenu.triggerProps(card)}
|
||||
className={`${SURFACE.panel} mb-6 p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]`}
|
||||
>
|
||||
<Pressable
|
||||
onClick={() => onPlay(card)}
|
||||
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${
|
||||
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
|
||||
}`}
|
||||
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 && (
|
||||
<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)}
|
||||
</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}
|
||||
</Pressable>
|
||||
// One line of facts under the title: a film's year, length and score; a video's views and age.
|
||||
// The picture's column is exactly the picture's width (it was 18rem around a 12rem poster: a 6rem
|
||||
// hole), and the text stands centred beside it, so a video — shorter picture, fewer facts — fills
|
||||
// its hero as well as a film does (the owner's pick, 2026-09-26).
|
||||
const plex = card.plex;
|
||||
const video = card.video;
|
||||
const facts = plex
|
||||
? [plex.year, formatRuntime(card.duration_seconds)]
|
||||
: video
|
||||
? // The channel is the subtitle already.
|
||||
[
|
||||
video.view_count != null && `${formatViews(video.view_count)} ${t("card.views")}`,
|
||||
relativeTime(video.published_at),
|
||||
]
|
||||
: [];
|
||||
const factLine = facts.filter(Boolean).join(" · ");
|
||||
// A feed video carries no description, so only a film or an episode has a summary line.
|
||||
const summary = plex?.summary;
|
||||
// A film's subtitle is its year, which the fact line already says.
|
||||
const subtitle = card.subtitle !== String(plex?.year ?? "") ? card.subtitle : null;
|
||||
|
||||
<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>
|
||||
<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>}
|
||||
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3">
|
||||
{subtitle && <p className="mt-0.5 text-xs text-muted truncate">{subtitle}</p>}
|
||||
{(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)}>
|
||||
{started ? t("home.hero.continue") : t("home.hero.play")}
|
||||
</Button>
|
||||
@@ -386,16 +626,18 @@ function Hero({
|
||||
>
|
||||
{t("home.hero.reroll")}
|
||||
</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>
|
||||
{/* A comfort hero acts on the pin 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): the row unpins the show. */}
|
||||
<ComfortPinMenu
|
||||
menu={pinMenu}
|
||||
target={(c) => pinTargetForCard(c.pinned_from ?? c)}
|
||||
title={(c) => (c.pinned_from ?? c).title}
|
||||
/>
|
||||
<ContextMenu menu={menu} ariaLabel={card.title} items={items} />
|
||||
{panels}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -776,6 +1018,10 @@ export default function Home() {
|
||||
tier={hero.tier}
|
||||
card={hero.card}
|
||||
onPlay={onPlay}
|
||||
onInfo={onInfo}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onToggleWatched={onToggleWatched}
|
||||
onReroll={() => reroll()}
|
||||
rerolling={rerolling}
|
||||
/>
|
||||
|
||||
@@ -326,7 +326,7 @@ export default function PlexCollectionView({
|
||||
{d.summary && (
|
||||
<div className="mt-3">
|
||||
<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}
|
||||
</p>
|
||||
|
||||
@@ -70,8 +70,9 @@ function CardRail({
|
||||
keyboardNav
|
||||
>
|
||||
{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
|
||||
shelf
|
||||
card={c}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 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
|
||||
// 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -156,3 +156,60 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
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 { IconLink } from "./ui/LinkButton";
|
||||
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,
|
||||
// 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,
|
||||
// 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({
|
||||
detail,
|
||||
onFilter,
|
||||
@@ -175,9 +178,9 @@ function FactsPanel({
|
||||
label: t("plex.info.facts.release"),
|
||||
node: (
|
||||
<Filterable
|
||||
tag
|
||||
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
|
||||
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
|
||||
className="hover:text-accent"
|
||||
>
|
||||
{year}
|
||||
</Filterable>
|
||||
@@ -195,10 +198,7 @@ function FactsPanel({
|
||||
key: "cert",
|
||||
label: t("plex.info.facts.cert"),
|
||||
node: (
|
||||
<Filterable
|
||||
onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}
|
||||
className="hover:text-accent"
|
||||
>
|
||||
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
|
||||
{cert}
|
||||
</Filterable>
|
||||
),
|
||||
@@ -212,17 +212,15 @@ function FactsPanel({
|
||||
key: "studios",
|
||||
label: t("plex.info.facts.studios"),
|
||||
node: (
|
||||
<span className="flex flex-wrap gap-x-1.5 gap-y-0.5">
|
||||
{studios.map((s, i) => (
|
||||
<span key={s}>
|
||||
<Filterable
|
||||
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
|
||||
className="hover:text-accent"
|
||||
>
|
||||
{s}
|
||||
</Filterable>
|
||||
{i < studios.length - 1 ? "," : ""}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{studios.map((s) => (
|
||||
<Filterable
|
||||
key={s}
|
||||
tag
|
||||
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
|
||||
>
|
||||
{s}
|
||||
</Filterable>
|
||||
))}
|
||||
</span>
|
||||
),
|
||||
@@ -237,7 +235,7 @@ function FactsPanel({
|
||||
return (
|
||||
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
||||
<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) => (
|
||||
<div key={r.key} className="contents">
|
||||
<dt className="text-muted">{r.label}</dt>
|
||||
@@ -291,9 +289,8 @@ function CrewPanel({
|
||||
return (
|
||||
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
||||
<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,
|
||||
so a fixed small column count keeps names from over-spreading. */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
|
||||
{/* In the main column (layout B): as many name columns as its width holds. */}
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-x-3 gap-y-1.5">
|
||||
{crew.map((c, i) => (
|
||||
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
|
||||
{onOpenPerson ? (
|
||||
@@ -536,7 +533,8 @@ export default function PlexInfo({
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
{detail.year != null && (
|
||||
<Filterable
|
||||
className={mutedCls}
|
||||
tag
|
||||
onDark={overlay}
|
||||
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
|
||||
onClick={
|
||||
onFilter
|
||||
@@ -549,7 +547,8 @@ export default function PlexInfo({
|
||||
)}
|
||||
{detail.content_rating && (
|
||||
<Filterable
|
||||
className={mutedCls}
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ contentRatings: [detail.content_rating!] })
|
||||
@@ -564,7 +563,8 @@ export default function PlexInfo({
|
||||
) : null}
|
||||
{detail.studio && (
|
||||
<Filterable
|
||||
className={mutedCls}
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
|
||||
>
|
||||
{detail.studio}
|
||||
@@ -608,25 +608,24 @@ export default function PlexInfo({
|
||||
)}
|
||||
|
||||
{detail.directors.length > 0 && (
|
||||
<p className={`mt-2 text-sm ${mutedCls}`}>
|
||||
{t("plex.info.director")}:{" "}
|
||||
{detail.directors.map((d, i) => (
|
||||
<span key={d}>
|
||||
<Filterable
|
||||
className="font-medium"
|
||||
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
|
||||
>
|
||||
{d}
|
||||
</Filterable>
|
||||
{i < detail.directors.length - 1 ? ", " : ""}
|
||||
</span>
|
||||
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
|
||||
<span>{t("plex.info.director")}:</span>
|
||||
{detail.directors.map((d) => (
|
||||
<Filterable
|
||||
key={d}
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
|
||||
>
|
||||
{d}
|
||||
</Filterable>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{detail.summary && (
|
||||
<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}
|
||||
</p>
|
||||
@@ -679,100 +678,18 @@ export default function PlexInfo({
|
||||
</div>
|
||||
{/* End hero panel. */}
|
||||
|
||||
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. Details / Keywords /
|
||||
Crew sit side by side (each its natural height, items-start) so no single box hogs the full
|
||||
width with dead space; each is independently hideable from the customize menu. */}
|
||||
{!overlay && (showFacts || showKeywords || showCrew) && (
|
||||
<div className="grid items-start gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{showFacts && <FactsPanel detail={detail} onFilter={onFilter} />}
|
||||
{showKeywords && (
|
||||
<KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} />
|
||||
)}
|
||||
{showCrew && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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) => (
|
||||
{/* Below the hero (E28 S3h, the owner's layout B, 2026-09-26): a main column — the collection
|
||||
strips first (the owner's most used), then the crew, then the cast — beside a narrow
|
||||
sidebar of facts (details, keywords), the TMDB/IMDb shape. Every section is per-user
|
||||
toggleable, so the layout is built from what is ON: a column with nothing in it is not
|
||||
rendered and the other takes the full width; inside a column the sections stack, so a
|
||||
hidden one leaves no hole. Narrow windows stack the sidebar under the main column. The
|
||||
overlay (over the running player) stays lean: its strips and its cast, stacked. */}
|
||||
{(() => {
|
||||
const collections = (detail.collections ?? []).filter(
|
||||
(col) => !hiddenSources.includes(col.source),
|
||||
);
|
||||
const strips = collections.map((col) => (
|
||||
<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">
|
||||
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||
@@ -795,62 +712,134 @@ export default function PlexInfo({
|
||||
</div>
|
||||
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
|
||||
{col.items.map((m, i) => (
|
||||
<Pressable
|
||||
selectable
|
||||
key={m.id}
|
||||
{...navItemAttrs(i)}
|
||||
onClick={() => onPlayItem?.(m.id)}
|
||||
disabled={!onPlayItem}
|
||||
className="group/col w-28 shrink-0"
|
||||
>
|
||||
<div
|
||||
className={`relative aspect-2/3 overflow-hidden rounded-lg border ${
|
||||
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>
|
||||
// The one media card, as on every other strip (E28 S3h). A grid cell, so the
|
||||
// card fills the slot the carousel stretches to its tallest card.
|
||||
<div key={m.id} className="grid w-37.5 shrink-0 grid-cols-1">
|
||||
<PlexPosterCard
|
||||
card={{ ...m, type: "movie" }}
|
||||
shelf
|
||||
navIndex={i}
|
||||
onOpen={() => onPlayItem?.(m.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</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>
|
||||
{editorOpen && library && (
|
||||
<PlexCollectionEditor
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { MEDIA_IMG } from "./ui/mediaCardStyles";
|
||||
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}),
|
||||
// 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
|
||||
// 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({
|
||||
title,
|
||||
subtitle,
|
||||
@@ -32,11 +37,11 @@ export default function PosterTile({
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
{...(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. */}
|
||||
<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
|
||||
style={posters.length === 0 ? { background: band } : undefined}
|
||||
>
|
||||
@@ -48,7 +53,7 @@ export default function PosterTile({
|
||||
src={`/api/plex/image/${posters[i]}`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
className={MEDIA_IMG}
|
||||
/>
|
||||
) : (
|
||||
<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 { MoreButton } from "./ui/MoreButton";
|
||||
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. */
|
||||
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
|
||||
// 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.
|
||||
function Actions({
|
||||
/** The card's hover row (watched, save, add, "…"); Home's hero docks the same row on its picture. */
|
||||
export function VideoHoverActions({
|
||||
video,
|
||||
onState,
|
||||
onToggleSave,
|
||||
@@ -64,8 +74,8 @@ function Actions({
|
||||
onToggleSave(video.id, !video.saved);
|
||||
};
|
||||
const variant = media ? "media" : "ghost";
|
||||
// Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail and the
|
||||
// one-line row's w-32 column (ROW_COL.actions).
|
||||
// Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail; the
|
||||
// one-line row floats three of them (no "…": its own stays put) over its date on hover.
|
||||
return (
|
||||
<div className={clsx("flex", dense ? "gap-0.5" : "gap-1")}>
|
||||
<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
|
||||
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
|
||||
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>
|
||||
{StatusIcon && (
|
||||
<span role="img" aria-label={status.label} title={status.label}>
|
||||
@@ -220,7 +230,10 @@ function Thumb({
|
||||
};
|
||||
|
||||
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
|
||||
Tab stop of every card as "44:10" (E28 S3d4). */}
|
||||
<a
|
||||
@@ -229,7 +242,7 @@ function Thumb({
|
||||
rel="noreferrer"
|
||||
aria-label={video.title ?? undefined}
|
||||
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 ? (
|
||||
<img
|
||||
@@ -239,7 +252,7 @@ function Thumb({
|
||||
decoding="async"
|
||||
width={1280}
|
||||
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" />
|
||||
@@ -247,7 +260,8 @@ function Thumb({
|
||||
{video.duration_seconds != null ? (
|
||||
<span
|
||||
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.
|
||||
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
|
||||
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
|
||||
{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 ? (
|
||||
<>
|
||||
{small ? (
|
||||
@@ -338,15 +352,9 @@ function Thumb({
|
||||
</div>
|
||||
)}
|
||||
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<MediaProgress pct={pct} />
|
||||
</a>
|
||||
{bar && (
|
||||
<div className={clsx(HOVER_REVEAL, "absolute bottom-2.5 right-1.5 z-base")}>{bar}</div>
|
||||
)}
|
||||
{bar && <div className={MEDIA_BAR}>{bar}</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
|
||||
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
|
||||
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
|
||||
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
|
||||
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
|
||||
// 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.
|
||||
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 = {
|
||||
channel: ROW_BASE + ROW_GAP + ROW_COL.channel,
|
||||
meta: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta,
|
||||
@@ -401,6 +409,7 @@ function VideoCard({
|
||||
menuButtonProps,
|
||||
menuOpen = false,
|
||||
navIndex,
|
||||
shelf = false,
|
||||
}: {
|
||||
video: Video;
|
||||
view: FeedView;
|
||||
@@ -422,6 +431,8 @@ function VideoCard({
|
||||
menuOpen?: boolean;
|
||||
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
|
||||
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 { openChannel } = useNavigationActions();
|
||||
@@ -493,8 +504,8 @@ function VideoCard({
|
||||
title={titleClamped ? (video.title ?? undefined) : undefined}
|
||||
className={clsx(
|
||||
// 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",
|
||||
noThumb ? "block truncate" : "line-clamp-2",
|
||||
"min-h-6 select-text hover:text-accent",
|
||||
noThumb ? "block truncate font-medium leading-snug" : mediaTitleClass(!!spec.dense, shelf),
|
||||
)}
|
||||
>
|
||||
{video.title}
|
||||
@@ -523,7 +534,7 @@ function VideoCard({
|
||||
// the toggles inside it are gone and only the "…" is left.
|
||||
const actions = (
|
||||
<div className="reveal">
|
||||
<Actions
|
||||
<VideoHoverActions
|
||||
video={video}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
@@ -569,29 +580,53 @@ function VideoCard({
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
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
|
||||
they're the shortest pointer trip from it. ROW_COL.actions counts their width. */}
|
||||
<div className="shrink-0 w-32">{actions}</div>
|
||||
{/* Everything else is shrink-0, so the title is the only thing that can give — which means
|
||||
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
|
||||
from the right as the row narrows, least important first: a title with no 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. */}
|
||||
{/* The title leads (E28 S3h, the owner's pick B, 2026-09-26): the actions used to lead in a
|
||||
128px column that sat EMPTY at rest, a hole at the start of every row. Now the row's
|
||||
end holds a faint "…" (always there: the menu's door on every device), and on hover or
|
||||
keyboard focus the quick toggles float in beside it OVER the date, which steps aside —
|
||||
the Gmail row. On touch the toggles are gone (reveal-hover) and the "…" stays.
|
||||
Everything but the title is shrink-0, so the title is the only thing that can give —
|
||||
which means once the fixed columns outgrow the row, it gives ALL of it and vanishes.
|
||||
Columns drop from the right as the row narrows, least important first: a title with no
|
||||
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>
|
||||
{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
|
||||
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. */}
|
||||
{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)}
|
||||
</div>
|
||||
)}
|
||||
{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. */}
|
||||
{pct > 0 && (
|
||||
<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
|
||||
// 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
|
||||
// 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 (
|
||||
<div
|
||||
data-testid="video-card"
|
||||
data-video-id={video.id}
|
||||
{...menuProps?.(video)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
className={clsx(
|
||||
`cv-card group ${SURFACE.card} glass-hover transition-all duration-150`,
|
||||
"flex flex-col",
|
||||
spec.dense ? "p-2" : "p-2.5",
|
||||
watched && "opacity-55",
|
||||
)}
|
||||
>
|
||||
{/* 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
|
||||
and would spill straight out of it. */}
|
||||
<Thumb
|
||||
video={video}
|
||||
className="aspect-video"
|
||||
onOpen={onOpen}
|
||||
small={spec.dense}
|
||||
bar={
|
||||
<Actions video={video} onState={onState} onToggleSave={onToggleSave} more={more} media />
|
||||
}
|
||||
/>
|
||||
<div className={clsx("flex gap-3 px-0.5 pb-0.5 flex-1", spec.dense ? "mt-2" : "mt-2.5")}>
|
||||
{/* 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 below it. */}
|
||||
{!spec.dense && (
|
||||
<MediaCard
|
||||
rootProps={{
|
||||
"data-testid": "video-card",
|
||||
"data-video-id": video.id,
|
||||
...menuProps?.(video),
|
||||
...(navIndex !== undefined && navItemAttrs(navIndex)),
|
||||
}}
|
||||
className="cv-card"
|
||||
dense={spec.dense}
|
||||
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
|
||||
// and would spill straight out of it.
|
||||
picture={
|
||||
<Thumb
|
||||
video={video}
|
||||
className="aspect-video"
|
||||
onOpen={onOpen}
|
||||
small={spec.dense}
|
||||
bar={
|
||||
<VideoHoverActions
|
||||
video={video}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
more={more}
|
||||
media
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
// 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
|
||||
src={video.channel_thumbnail}
|
||||
fallback={video.channel_title ?? ""}
|
||||
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 && (
|
||||
<MoreButton
|
||||
className="touch-only -mr-1 shrink-0"
|
||||
label={t("card.menu.more")}
|
||||
expanded={more.open}
|
||||
menuProps={more.props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
title={title}
|
||||
meta={
|
||||
shelf ? (
|
||||
channelLink
|
||||
) : (
|
||||
<>
|
||||
{channelLink}
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{views}
|
||||
{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
|
||||
// key-passthrough, so the asserted names are the i18n keys.
|
||||
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) => {
|
||||
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 { iconButtonClass } from "./ui/buttonStyles";
|
||||
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
||||
|
||||
const movie = (status?: string): PlexCard => ({
|
||||
@@ -65,21 +66,103 @@ describe("PlexPosterCard a11y", () => {
|
||||
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(
|
||||
<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()}
|
||||
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("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", () => {
|
||||
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onToggleWatched={vi.fn()} />);
|
||||
expect(screen.queryByText(/synopsis/i)).toBeNull();
|
||||
it("draws no meta line when the card has neither a year nor a rating", () => {
|
||||
const { container } = render(
|
||||
<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);
|
||||
});
|
||||
});
|
||||
|
||||
// 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 { CheckCircle2, Circle, Film, ListPlus, Play, Star, Tv2 } from "lucide-react";
|
||||
import { type CSSProperties, type ReactNode, useRef } from "react";
|
||||
import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
|
||||
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
|
||||
import { plexAddLabelKey } from "./usePlexCardActions";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
|
||||
import { 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";
|
||||
|
||||
// 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. */
|
||||
function HoverBar({
|
||||
watched,
|
||||
@@ -50,6 +62,7 @@ function HoverBar({
|
||||
onToggleWatched,
|
||||
addLabel,
|
||||
onAdd,
|
||||
onInfo,
|
||||
more: m,
|
||||
}: {
|
||||
watched: boolean;
|
||||
@@ -57,12 +70,14 @@ function HoverBar({
|
||||
onToggleWatched?: () => void;
|
||||
addLabel: string;
|
||||
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;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!onToggleWatched && !onAdd && !m) return null;
|
||||
if (!onToggleWatched && !onAdd && !onInfo && !m) return null;
|
||||
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 && (
|
||||
<IconButton variant="media" onClick={onToggleWatched} label={watchedLabel}>
|
||||
<WatchedIcon watched={watched} />
|
||||
@@ -73,6 +88,11 @@ function HoverBar({
|
||||
<ListPlus />
|
||||
</IconButton>
|
||||
)}
|
||||
{onInfo && (
|
||||
<IconButton variant="media" onClick={onInfo} label={t("plex.openShow")}>
|
||||
<Info />
|
||||
</IconButton>
|
||||
)}
|
||||
{m && (
|
||||
<MoreButton
|
||||
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). */
|
||||
function Caption({
|
||||
/** The hover row on a picture that is not a card's (Home's hero): the card's own watched and add
|
||||
* 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,
|
||||
more: m,
|
||||
className = "",
|
||||
shelf = false,
|
||||
children,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
more: More;
|
||||
className?: string;
|
||||
/** One line, cut with an ellipsis (a shelf). The whole title is in the card's preview. */
|
||||
shelf?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pressedAt = useRef<{ x: number; y: number } | null>(null);
|
||||
return (
|
||||
<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 &&
|
||||
((e) => {
|
||||
@@ -114,22 +161,75 @@ function Caption({
|
||||
onMouseDown={(e) => {
|
||||
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}`}
|
||||
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
{m && (
|
||||
<MoreButton
|
||||
className="touch-only -mr-1 -my-0.5 shrink-0"
|
||||
label={t("card.menu.more")}
|
||||
expanded={m.open}
|
||||
menuProps={m.props}
|
||||
/>
|
||||
className={clsx(
|
||||
"min-h-6",
|
||||
mediaTitleClass(true, shelf),
|
||||
onClick && "cursor-pointer hover:text-accent",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</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({
|
||||
card,
|
||||
onOpen,
|
||||
@@ -139,6 +239,10 @@ export function PlexPosterCard({
|
||||
menuButtonProps,
|
||||
menuOpen = false,
|
||||
navIndex,
|
||||
wide = false,
|
||||
shelf = false,
|
||||
next,
|
||||
onInfo,
|
||||
}: {
|
||||
card: PlexCard;
|
||||
onOpen: () => void;
|
||||
@@ -155,163 +259,149 @@ export function PlexPosterCard({
|
||||
menuOpen?: boolean;
|
||||
/** Position in a keyboard-walkable grid or rail (lib/listNav); the poster is what Enter opens. */
|
||||
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 inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
|
||||
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
|
||||
const preview = useMediaPreview();
|
||||
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 actedWatched = acted.status === "watched";
|
||||
const runtime = formatRuntime(card.duration_seconds);
|
||||
const pct =
|
||||
inProgress && card.duration_seconds
|
||||
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
|
||||
: 0;
|
||||
const pct = ep ? progressPct(ep) : watched ? 0 : progressPct(card);
|
||||
const m = more(card, menuButtonProps, menuOpen);
|
||||
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(card) : undefined;
|
||||
const add = isPlayable && onAdd ? () => onAdd(card) : undefined;
|
||||
// A corner pill steps aside while the hover row covers its corner; at rest, and on touch, it stays.
|
||||
const yieldTop = toggle || add || m ? HOVER_YIELD : "";
|
||||
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
|
||||
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
|
||||
const epCode = ep && { season: ep.season_number ?? "?", episode: ep.episode_number ?? "?" };
|
||||
// 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 (
|
||||
<div
|
||||
className="group text-left"
|
||||
{...menuProps?.(card)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex, card.title))}
|
||||
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-nav-open=""
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
<>
|
||||
<MediaCard
|
||||
dense
|
||||
rootProps={preview.rootProps({
|
||||
...menuProps?.(card),
|
||||
...(navIndex !== undefined && navItemAttrs(navIndex, card.title)),
|
||||
})}
|
||||
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll);
|
||||
// a carousel turns it off (index.css `.media-rail`).
|
||||
className="cv-poster"
|
||||
picture={
|
||||
<PlexPicture
|
||||
aspect={landscape ? "wide" : "poster"}
|
||||
thumb={(wide && card.type !== "episode" && card.art) || card.thumb}
|
||||
label={card.title}
|
||||
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>
|
||||
}
|
||||
summary={card.summary}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Hover reveal: a soft dim, a less-flat play ring, and a bottom scrim that slides up with
|
||||
the synopsis + rating (the info you'd otherwise click through for). */}
|
||||
<div className="absolute inset-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<div className="absolute inset-0 bg-black/25" />
|
||||
<div className="absolute inset-x-0 top-[30%] flex justify-center">
|
||||
<div
|
||||
className="grid h-12 w-12 scale-90 place-items-center rounded-full text-accent-fg shadow-xl transition-transform duration-200 group-hover:scale-100"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at 38% 32%, color-mix(in srgb, var(--accent) 72%, white), var(--accent))",
|
||||
boxShadow:
|
||||
"0 10px 22px -6px color-mix(in srgb, var(--accent) 60%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Play className="w-5 h-5 translate-x-px fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
{(card.summary || card.rating != null) && (
|
||||
<div className="absolute inset-x-0 bottom-0 translate-y-1.5 bg-gradient-to-t from-black/95 via-black/85 to-transparent p-2.5 transition-transform duration-200 group-hover:translate-y-0">
|
||||
<div className="line-clamp-1 text-xs font-semibold leading-snug text-white">
|
||||
{card.title}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-2xs text-white/75">
|
||||
{(card.year || runtime) && (
|
||||
<span>{[card.year, runtime].filter(Boolean).join(" · ")}</span>
|
||||
)}
|
||||
{card.rating != null && (
|
||||
<span className="inline-flex items-center gap-0.5 text-rating-media">
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{card.rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{card.summary && (
|
||||
<p className="mt-1 line-clamp-3 text-2xs leading-snug text-white/85">
|
||||
{card.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/** The picture's rest-state marks, in the corners the hover row leaves free (it docks bottom-right). */
|
||||
function WatchedBadge() {
|
||||
const { t } = useTranslation();
|
||||
return <span className={clsx(MEDIA_BADGE, "top-1.5 right-1.5")}>{t("plex.watched")}</span>;
|
||||
}
|
||||
|
||||
{pct > 0 && (
|
||||
<div className="absolute inset-x-0 bottom-0 z-base h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<HoverBar
|
||||
watched={watched}
|
||||
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||
onToggleWatched={toggle}
|
||||
addLabel={t(plexAddLabelKey(card))}
|
||||
onAdd={add}
|
||||
more={m}
|
||||
/>
|
||||
</div>
|
||||
<Caption onClick={onOpen} more={m}>
|
||||
<div className="text-sm font-medium line-clamp-2 leading-tight min-h-[2lh] hover:text-accent">
|
||||
{card.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted">
|
||||
{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</Caption>
|
||||
</div>
|
||||
function InProgressBadge() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className={clsx(MEDIA_BADGE, "top-1.5 right-1.5 text-accent")}>
|
||||
{t("plex.inProgress")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The bottom-right fact (a length, a count): it steps aside while the hover row covers its corner;
|
||||
* at rest, and on touch, it stays. */
|
||||
function CornerBadge({ yields, children }: { yields: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<span className={clsx(MEDIA_BADGE, "bottom-1.5 right-1.5", yields && HOVER_YIELD)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -339,72 +429,45 @@ export function SeasonCard({
|
||||
const watched = se.status === "watched";
|
||||
const m = more(se, menuButtonProps, menuOpen);
|
||||
return (
|
||||
<div
|
||||
className="group text-left"
|
||||
{...menuProps?.(se)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex, se.title))}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-nav-open=""
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`}
|
||||
>
|
||||
<img
|
||||
src={se.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||
<div className="flex flex-col items-center gap-1 text-white">
|
||||
<Play className="w-9 h-9" fill="currentColor" />
|
||||
<span className="text-xs font-medium">{t("plex.series.openSeason")}</span>
|
||||
</div>
|
||||
</div>
|
||||
{watched && (
|
||||
<span
|
||||
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${
|
||||
onToggleWatched || onAdd || m ? HOVER_YIELD : ""
|
||||
}`}
|
||||
>
|
||||
{t("plex.watched")}
|
||||
</span>
|
||||
)}
|
||||
{/* In-progress lives at the top-LEFT (the hover row owns the top-right) and stays visible:
|
||||
a season has no progress bar, so this badge is its sole in-progress cue. */}
|
||||
{se.status === "in_progress" && (
|
||||
<span className="pointer-events-none absolute top-1.5 left-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm">
|
||||
{t("plex.inProgress")}
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||
</span>
|
||||
</div>
|
||||
<HoverBar
|
||||
watched={watched}
|
||||
watchedLabel={
|
||||
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
|
||||
<MediaCard
|
||||
dense
|
||||
rootProps={{
|
||||
...menuProps?.(se),
|
||||
...(navIndex !== undefined && navItemAttrs(navIndex, se.title)),
|
||||
}}
|
||||
picture={
|
||||
// A season opens its episode list: no Play on hover, since the click does not play.
|
||||
<PlexPicture
|
||||
aspect="poster"
|
||||
thumb={se.thumb}
|
||||
label={se.title}
|
||||
onOpen={onOpen}
|
||||
plays={false}
|
||||
bar={
|
||||
<HoverBar
|
||||
watched={watched}
|
||||
watchedLabel={
|
||||
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
|
||||
}
|
||||
onToggleWatched={onToggleWatched}
|
||||
addLabel={t("plex.playlist.addSeason")}
|
||||
onAdd={onAdd}
|
||||
more={m}
|
||||
/>
|
||||
}
|
||||
onToggleWatched={onToggleWatched}
|
||||
addLabel={t("plex.playlist.addSeason")}
|
||||
onAdd={onAdd}
|
||||
more={m}
|
||||
/>
|
||||
</div>
|
||||
<Caption onClick={onOpen} more={m} className="text-sm font-medium leading-tight">
|
||||
<div className="line-clamp-2 hover:text-accent">{se.title}</div>
|
||||
</Caption>
|
||||
</div>
|
||||
>
|
||||
{watched && <WatchedBadge />}
|
||||
{/* A season has no progress bar, so this badge is its sole in-progress cue. */}
|
||||
{se.status === "in_progress" && <InProgressBadge />}
|
||||
<CornerBadge yields={!!(onToggleWatched || onAdd || m)}>
|
||||
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||
</CornerBadge>
|
||||
</PlexPicture>
|
||||
}
|
||||
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>
|
||||
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
|
||||
{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
|
||||
card={r}
|
||||
shelf
|
||||
navIndex={i}
|
||||
onOpen={() => onOpen(r)}
|
||||
menuProps={menuProps}
|
||||
@@ -548,80 +612,62 @@ export function EpisodeCard({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const watched = ep.status === "watched";
|
||||
const inProgress = (ep.position_seconds ?? 0) > 0 && !watched;
|
||||
const pct =
|
||||
inProgress && ep.duration_seconds
|
||||
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
|
||||
: 0;
|
||||
const pct = watched ? 0 : progressPct(ep);
|
||||
const m = more(ep, menuButtonProps, menuOpen);
|
||||
const runtime = formatRuntime(ep.duration_seconds);
|
||||
return (
|
||||
<div
|
||||
className="group"
|
||||
{...menuProps?.(ep)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
onClick={onPlay}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-nav-open=""
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onPlay();
|
||||
}
|
||||
}}
|
||||
className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer"
|
||||
<MediaCard
|
||||
dense
|
||||
rootProps={{
|
||||
...menuProps?.(ep),
|
||||
...(navIndex !== undefined && navItemAttrs(navIndex, ep.title)),
|
||||
}}
|
||||
picture={
|
||||
<PlexPicture
|
||||
aspect="wide"
|
||||
thumb={ep.thumb}
|
||||
label={ep.title}
|
||||
onOpen={onPlay}
|
||||
plays
|
||||
pct={pct}
|
||||
bar={
|
||||
<HoverBar
|
||||
watched={watched}
|
||||
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||
onToggleWatched={onToggleWatched}
|
||||
addLabel={t("plex.playlist.addEpisode")}
|
||||
onAdd={onAdd}
|
||||
more={m}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={ep.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||
<Play className="w-9 h-9 text-white" fill="currentColor" />
|
||||
</div>
|
||||
{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>
|
||||
{watched && <WatchedBadge />}
|
||||
{runtime && (
|
||||
<CornerBadge yields={!!(onToggleWatched || onAdd || m)}>{runtime}</CornerBadge>
|
||||
)}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</PlexPicture>
|
||||
}
|
||||
title={
|
||||
<CardTitle onClick={onPlay}>
|
||||
{!withShowTitle && ep.episode_number != null && (
|
||||
<span className="text-muted mr-1">{ep.episode_number}.</span>
|
||||
)}
|
||||
</div>
|
||||
<HoverBar
|
||||
watched={watched}
|
||||
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||
onToggleWatched={onToggleWatched}
|
||||
addLabel={t("plex.playlist.addEpisode")}
|
||||
onAdd={onAdd}
|
||||
more={m}
|
||||
/>
|
||||
</div>
|
||||
<Caption more={m}>
|
||||
{withShowTitle && ep.show_title && (
|
||||
<div className="text-2xs text-muted truncate">
|
||||
{ep.title}
|
||||
</CardTitle>
|
||||
}
|
||||
meta={
|
||||
withShowTitle &&
|
||||
ep.show_title && (
|
||||
<div className="truncate text-xs text-muted">
|
||||
{ep.show_title}
|
||||
{ep.season_number != null && ep.episode_number != null
|
||||
? ` · S${ep.season_number}·E${ep.episode_number}`
|
||||
: ""}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium leading-tight">
|
||||
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
|
||||
{ep.title}
|
||||
</div>
|
||||
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
|
||||
</Caption>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
more={m}
|
||||
moreLabel={t("card.menu.more")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ export function PlexPersonView({
|
||||
</div>
|
||||
</div>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -277,8 +277,9 @@ export function PlexPersonView({
|
||||
keyboardNav
|
||||
>
|
||||
{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
|
||||
shelf
|
||||
card={c}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
@@ -470,9 +471,12 @@ export function PlexShowView({
|
||||
}
|
||||
>
|
||||
<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 && (
|
||||
<Filterable
|
||||
tag
|
||||
title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined}
|
||||
onClick={
|
||||
onFilter
|
||||
@@ -484,21 +488,19 @@ export function PlexShowView({
|
||||
</Filterable>
|
||||
)}
|
||||
{show.content_rating && (
|
||||
<span>
|
||||
·{" "}
|
||||
<Filterable
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ contentRatings: [show.content_rating!] })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{show.content_rating}
|
||||
</Filterable>
|
||||
</span>
|
||||
<Filterable
|
||||
tag
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ contentRatings: [show.content_rating!] })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{show.content_rating}
|
||||
</Filterable>
|
||||
)}
|
||||
{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
|
||||
@@ -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"}`}
|
||||
>
|
||||
{/* 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)}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -538,7 +540,9 @@ export function PlexShowView({
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
{show.resume && (
|
||||
|
||||
@@ -132,7 +132,10 @@ export function Carousel({
|
||||
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
|
||||
<div
|
||||
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 }}
|
||||
role="group"
|
||||
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
|
||||
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` |
|
||||
| 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
|
||||
* hair before the click handler toggles it shut again. */
|
||||
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
|
||||
* 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. */
|
||||
@@ -157,13 +161,13 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
move: false,
|
||||
// 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).
|
||||
mouseOnly: !isHover,
|
||||
mouseOnly: !isHover || !!opts.pointerOnly,
|
||||
// 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.
|
||||
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -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]. */
|
||||
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"],
|
||||
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
|
||||
"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 { BatchB } from "./batchB";
|
||||
import { BatchC } from "./batchC";
|
||||
import { BatchD } from "./batchD";
|
||||
import { Chip } from "../components/ui/Chip";
|
||||
import { Slider } from "../components/ui/NativeControls";
|
||||
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">
|
||||
C ✓
|
||||
</a>
|
||||
<a href="#decided-s3h" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
||||
S3h ✓
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -114,6 +118,14 @@ export default function DesignLab() {
|
||||
</p>
|
||||
</header>
|
||||
<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>
|
||||
</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",
|
||||
"moveLast": "To the end",
|
||||
"moveFailed": "Could not reorder the shelf.",
|
||||
"moveSaving": "Saving the new order…"
|
||||
"moveSaving": "Saving the new order…",
|
||||
"openPlaylist": "Open the playlist"
|
||||
},
|
||||
"pin": {
|
||||
"menu": "Comfort shelf",
|
||||
|
||||
@@ -125,6 +125,9 @@
|
||||
"menu": "Media",
|
||||
"markWatched": "Mark watched",
|
||||
"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.",
|
||||
"seasons": "{{count}} seasons",
|
||||
"unified": {
|
||||
@@ -243,7 +246,7 @@
|
||||
"title": "Media info",
|
||||
"customize": "Customize view",
|
||||
"prefArtBg": "Faint backdrop",
|
||||
"prefCast": "Cast & crew",
|
||||
"prefCast": "Cast",
|
||||
"prefRelated": "Related shows",
|
||||
"prefFacts": "Details",
|
||||
"prefKeywords": "Keywords",
|
||||
@@ -258,7 +261,7 @@
|
||||
},
|
||||
"openImdb": "Open on IMDb",
|
||||
"director": "Director",
|
||||
"cast": "Cast & crew",
|
||||
"cast": "Cast",
|
||||
"markWatched": "Mark watched",
|
||||
"markUnwatched": "Mark unwatched",
|
||||
"clearResume": "Clear resume position",
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
"moveRight": "Jobbra",
|
||||
"moveLast": "A polc végére",
|
||||
"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": {
|
||||
"menu": "Komfort polc",
|
||||
|
||||
@@ -125,6 +125,9 @@
|
||||
"menu": "Média",
|
||||
"markWatched": "Megné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.",
|
||||
"seasons": "{{count}} évad",
|
||||
"unified": {
|
||||
@@ -258,7 +261,7 @@
|
||||
},
|
||||
"openImdb": "Megnyitás az IMDb-n",
|
||||
"director": "Rendező",
|
||||
"cast": "Szereplők & stáb",
|
||||
"cast": "Szereplők",
|
||||
"markWatched": "Megjelölés megnézettként",
|
||||
"markUnwatched": "Megnézett visszavonása",
|
||||
"clearResume": "Folytatási pozíció törlése",
|
||||
|
||||
@@ -142,6 +142,14 @@
|
||||
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
|
||||
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.
|
||||
@@ -865,6 +873,18 @@ html[data-perf="1"] .overlay-backdrop {
|
||||
content-visibility: auto;
|
||||
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 {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 96px;
|
||||
|
||||
Reference in New Issue
Block a user