From a453b77f04f00b2f6e81946aa805be0aa8305107 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 00:54:19 +0200 Subject: [PATCH] feat(design): one media card, and Home's hero and shelves as cards (E28 S3h) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- frontend/src/components/ChannelAbout.tsx | 2 +- frontend/src/components/Home.dom.test.tsx | 57 +- frontend/src/components/Home.pin.dom.test.tsx | 36 +- frontend/src/components/Home.tsx | 568 ++++++++++----- .../src/components/PlexCollectionView.tsx | 2 +- frontend/src/components/PlexDiscover.tsx | 3 +- frontend/src/components/PlexInfo.dom.test.tsx | 59 +- frontend/src/components/PlexInfo.tsx | 355 +++++----- frontend/src/components/PosterTile.tsx | 11 +- .../components/VideoCard.actions.dom.test.tsx | 62 ++ frontend/src/components/VideoCard.tsx | 216 ++++-- .../src/components/plexCards.dom.test.tsx | 130 +++- frontend/src/components/plexCards.tsx | 656 ++++++++++-------- frontend/src/components/plexViews.tsx | 40 +- frontend/src/components/ui/Carousel.tsx | 5 +- frontend/src/components/ui/MediaCard.tsx | 100 +++ frontend/src/components/ui/MediaPreview.tsx | 42 ++ frontend/src/components/ui/README.md | 2 +- .../src/components/ui/anchoredPopoverHooks.ts | 8 +- frontend/src/components/ui/mediaCardStyles.ts | 40 ++ .../components/ui/mediaRail.contract.test.ts | 20 + .../ui/pressableSelectable.contract.test.ts | 5 +- frontend/src/components/ui/useMediaPreview.ts | 39 ++ frontend/src/design-lab/DesignLab.tsx | 12 + frontend/src/design-lab/batchD.tsx | 143 ++++ frontend/src/i18n/locales/en/home.json | 3 +- frontend/src/i18n/locales/en/plex.json | 7 +- frontend/src/i18n/locales/hu/home.json | 3 +- frontend/src/i18n/locales/hu/plex.json | 5 +- frontend/src/index.css | 20 + 30 files changed, 1882 insertions(+), 769 deletions(-) create mode 100644 frontend/src/components/ui/MediaCard.tsx create mode 100644 frontend/src/components/ui/MediaPreview.tsx create mode 100644 frontend/src/components/ui/mediaCardStyles.ts create mode 100644 frontend/src/components/ui/mediaRail.contract.test.ts create mode 100644 frontend/src/components/ui/useMediaPreview.ts create mode 100644 frontend/src/design-lab/batchD.tsx diff --git a/frontend/src/components/ChannelAbout.tsx b/frontend/src/components/ChannelAbout.tsx index 57fe8282..bd0331f6 100644 --- a/frontend/src/components/ChannelAbout.tsx +++ b/frontend/src/components/ChannelAbout.tsx @@ -51,7 +51,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) { return ( <> {ch.description ? ( -

+

{linkify(ch.description)}

) : ( diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx index a0400a4f..346260b8 100644 --- a/frontend/src/components/Home.dom.test.tsx +++ b/frontend/src/components/Home.dom.test.tsx @@ -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(); + }); +}); diff --git a/frontend/src/components/Home.pin.dom.test.tsx b/frontend/src/components/Home.pin.dom.test.tsx index 857e86b4..b0db7e68 100644 --- a/frontend/src/components/Home.pin.dom.test.tsx +++ b/frontend/src/components/Home.pin.dom.test.tsx @@ -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"); + }); +}); diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index cbf41baa..927ab45c 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -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(); + 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: , + // 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: , + 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: , + 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: , + 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(); - 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: , - // 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: , - 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: , - 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) => (
{c.video ? ( 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} /> ) : ( - + )}
))} - {videoActions.panel} - {plexActions.panel} + {panels} ); } @@ -268,45 +378,90 @@ function ShelfTile({ card, onPlay, menuProps, + menuButtonProps, + menuOpen = false, navIndex, }: { card: DiscoverCard; onPlay: (card: DiscoverCard) => void; - menuProps?: (c: DiscoverCard) => Record; + menuProps?: Record; + /** The rail menu's "…" for this tile. */ + menuButtonProps?: Record; + 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 ( - 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`} - > - - {card.thumb ? ( - - ) : null} - - - {card.title} - {countLabel} - - + + 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 ? ( + + ) : null} + {/* The Play only when there is something to play: the click plays the channel's newest + unwatched video, the playlist's first. */} +
{card.next && }
+
+ {/* 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). */} +
+ openPage.open(card)} + label={openPage.label(card)} + > + + + {menuButtonProps && ( + + )} +
+ + } + title={ + + {card.title} + + } + meta={{countLabel}} + 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(); + // 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 ( -
- 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 ? : null} - {card.duration_seconds != null && ( - - {formatDuration(card.duration_seconds)} - - )} - {started && card.duration_seconds ? ( - - - - ) : null} - + // 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; -
+ 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. +
+
+ 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 ? ( + + ) : null} + {card.duration_seconds != null && ( + // It steps aside while the hover row covers its corner, as a card's length does. + + {formatDuration(card.duration_seconds)} + + )} + {started && card.duration_seconds ? ( + + + + ) : null} +
+ +
+
+ {/* The card's own hover row, as on every card: a sibling of the picture's button. */} + {card.video ? ( +
+ +
+ ) : card.plex ? ( + + ) : null} +
+ +

{kicker}

{card.title}

- {card.subtitle &&

{card.subtitle}

} -
+ {subtitle &&

{subtitle}

} + {(factLine || plex?.rating != null) && ( +

+ {factLine && {factLine}} + {plex?.rating != null && ( + + {factLine && ·} + + {plex.rating.toFixed(1)} + + )} +

+ )} + {/* 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 &&

{summary}

} +
@@ -386,16 +626,18 @@ function Hero({ > {t("home.hero.reroll")} + {/* On touch the picture's hover row is gone; the "…" stands here instead, as it sits + beside a card's title. */} +
- {/* 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. */} - pinTargetForCard(c.pinned_from ?? c)} - title={(c) => (c.pinned_from ?? c).title} - /> + + {panels}
); } @@ -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} /> diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 819c515b..19e82ee1 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -326,7 +326,7 @@ export default function PlexCollectionView({ {d.summary && (

{d.summary}

diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index 8f46b235..61fed3bf 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -70,8 +70,9 @@ function CardRail({ keyboardNav > {cards.map((c, i) => ( -
+
onOpenCard(c)} diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx index d7425e6e..2cbe06a0 100644 --- a/frontend/src/components/PlexInfo.dom.test.tsx +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -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(); + 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(); + 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]); + }); +}); diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 1092ace0..ee80d517 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -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: ( onFilter({ yearMin: year, yearMax: year }) : undefined} - className="hover:text-accent" > {year} @@ -195,10 +198,7 @@ function FactsPanel({ key: "cert", label: t("plex.info.facts.cert"), node: ( - onFilter({ contentRatings: [cert] }) : undefined} - className="hover:text-accent" - > + onFilter({ contentRatings: [cert] }) : undefined}> {cert} ), @@ -212,17 +212,15 @@ function FactsPanel({ key: "studios", label: t("plex.info.facts.studios"), node: ( - - {studios.map((s, i) => ( - - onFilter({ studios: [s] }) : undefined} - className="hover:text-accent" - > - {s} - - {i < studios.length - 1 ? "," : ""} - + + {studios.map((s) => ( + onFilter({ studios: [s] }) : undefined} + > + {s} + ))} ), @@ -237,7 +235,7 @@ function FactsPanel({ return (

{t("plex.info.facts.title")}

-
+
{rows.map((r) => (
{r.label}
@@ -291,9 +289,8 @@ function CrewPanel({ return (

{t("plex.info.crew")}

- {/* 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. */} -
+ {/* In the main column (layout B): as many name columns as its width holds. */} +
{crew.map((c, i) => (
{onOpenPerson ? ( @@ -536,7 +533,8 @@ export default function PlexInfo({
{detail.year != null && ( onFilter({ contentRatings: [detail.content_rating!] }) @@ -564,7 +563,8 @@ export default function PlexInfo({ ) : null} {detail.studio && ( onFilter({ studios: [detail.studio!] }) : undefined} > {detail.studio} @@ -608,25 +608,24 @@ export default function PlexInfo({ )} {detail.directors.length > 0 && ( -

- {t("plex.info.director")}:{" "} - {detail.directors.map((d, i) => ( - - onFilter({ directors: [d] }) : undefined} - > - {d} - - {i < detail.directors.length - 1 ? ", " : ""} - +

+ {t("plex.info.director")}: + {detail.directors.map((d) => ( + onFilter({ directors: [d] }) : undefined} + > + {d} + ))}

)} {detail.summary && (

{detail.summary}

@@ -679,100 +678,18 @@ export default function PlexInfo({
{/* 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) && ( -
- {showFacts && } - {showKeywords && ( - - )} - {showCrew && } -
- )} - - {/* Cast row (toggleable), circular photos. */} - {cast.length > 0 && ( -
-

- {t("plex.info.cast")} -

- - {cast.map((c, i) => { - const clickable = !!(onOpenPerson || onFilter); - const inner = ( - <> -
- {c.thumb ? ( - - ) : ( -
- {c.name.charAt(0)} -
- )} -
-
- {c.name} -
- {c.role && ( -
- {c.role} -
- )} - - ); - return clickable ? ( - - 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} - - ) : ( -
- {inner} -
- ); - })} -
-
- )} - - {/* (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) => (

@@ -795,62 +712,134 @@ export default function PlexInfo({

{col.items.map((m, i) => ( - onPlayItem?.(m.id)} - disabled={!onPlayItem} - className="group/col w-28 shrink-0" - > -
- - {m.status === "watched" && ( - - ✓ - - )} - {onPlayItem && ( -
- -
- )} - {(m.position_seconds ?? 0) > 0 && - m.status !== "watched" && - m.duration_seconds ? ( -
-
-
- ) : null} -
-
- {m.title} -
- {m.year ? ( -
- {m.year} -
- ) : null} - + // 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. +
+ onPlayItem?.(m.id)} + /> +
))}
- ))} + )); + const castRow = cast.length > 0 && ( +
+

+ {t("plex.info.cast")} +

+ + {cast.map((c, i) => { + const clickable = !!(onOpenPerson || onFilter); + const inner = ( + <> +
+ {c.thumb ? ( + + ) : ( +
+ {c.name.charAt(0)} +
+ )} +
+
+ {c.name} +
+ {c.role && ( +
+ {c.role} +
+ )} + + ); + return clickable ? ( + + 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} + + ) : ( +
+ {inner} +
+ ); + })} +
+
+ ); + if (overlay) + return ( + <> + {strips} + {castRow} + + ); + const crewBox = showCrew && hasCrew && ( + + ); + const factsBox = showFacts && hasFacts && ( + + ); + const keywordsBox = showKeywords && hasKeywords && ( + + ); + const hasMain = strips.length > 0 || !!crewBox || !!castRow; + const hasSide = !!factsBox || !!keywordsBox; + if (!hasMain && !hasSide) return null; + return ( +
+ {hasMain && ( +
+ {strips} + {crewBox} + {castRow} +
+ )} + {hasSide && ( +
+ {factsBox} + {keywordsBox} +
+ )} +
+ ); + })()}
{editorOpen && library && ( {/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
@@ -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} /> ) : (
diff --git a/frontend/src/components/VideoCard.actions.dom.test.tsx b/frontend/src/components/VideoCard.actions.dom.test.tsx index 5c9de89a..3b6b5a9e 100644 --- a/frontend/src/components/VideoCard.actions.dom.test.tsx +++ b/frontend/src/components/VideoCard.actions.dom.test.tsx @@ -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(".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( + + {}} + onToggleSave={() => {}} + /> + , + ); + 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(); + }); +}); diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 7d2eebd5..f7edd00c 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -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; open: boolean }; @@ -35,7 +44,8 @@ type More = { props: Record; 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 (
{StatusIcon && ( @@ -220,7 +230,10 @@ function Thumb({ }; return ( -
+ // 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`). +
{/* 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). */} 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 ? ( ) : (
@@ -247,7 +260,8 @@ function Thumb({ {video.duration_seconds != null ? ( +
{inProgress ? ( <> {small ? ( @@ -338,15 +352,9 @@ function Thumb({
)} {/* Resume progress bar (sits at the very bottom, under the duration badge). */} - {pct > 0 && ( -
-
-
- )} +
- {bar && ( -
{bar}
- )} + {bar &&
{bar}
}
); } @@ -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 = (
- - {/* 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. */} -
{actions}
- {/* 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. */}
{title}
{fits(ROW_FITS.channel) &&
{channelLink}
} - {fits(ROW_FITS.meta) && } + {fits(ROW_FITS.meta) && ( +
+ +
+ )} {/* 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) && ( -
+
{viewsCell(false)}
)} {fits(ROW_FITS.published) && ( -
{published}
+
+ {published} +
)} +
+
+ +
+ {more && ( + + )} +
{/* Thumb draws this over the image; with no image it goes on the row itself. */} {pct > 0 && (
@@ -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 ( -
- {/* 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. */} - - } - /> -
- {/* 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 && ( + + } + /> + } + // 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 && ( - )} -
{textBlock}
- {more && ( - - )} -
-
+ ) + } + title={title} + meta={ + shelf ? ( + channelLink + ) : ( + <> + {channelLink} +
+ {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 ? ( + + {relativeTime(video.published_at)} + + ) : ( + published + )} +
+ + ) + } + more={more || undefined} + moreLabel={t("card.menu.more")} + /> ); } diff --git a/frontend/src/components/plexCards.dom.test.tsx b/frontend/src/components/plexCards.dom.test.tsx index 88b48e48..c9963daa 100644 --- a/frontend/src/components/plexCards.dom.test.tsx +++ b/frontend/src/components/plexCards.dom.test.tsx @@ -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(); @@ -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( , ); - 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(); - expect(screen.queryByText(/synopsis/i)).toBeNull(); + it("draws no meta line when the card has neither a year nor a rating", () => { + const { container } = render( + , + ); + 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(); + 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(); + 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( + , + ); + 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( + , + ); + 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(); + expect(titleOf().className).toContain("truncate"); + unmount(); + render(); + 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( + , + ); + expect(play(container).className, `shelf=${shelf} wide=${wide}`).toBe(want); + unmount(); + } + }); +}); diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 147b05f6..0293b107 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -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 ( -
+
{onToggleWatched && ( @@ -73,6 +88,11 @@ function HoverBar({ )} + {onInfo && ( + + + + )} {m && ( void; + onAdd?: (c: PlexCard) => void; + moreProps?: Record; + moreOpen?: boolean; +}) { + const { t } = useTranslation(); + const plays = card.type !== "show"; + const watched = card.status === "watched"; + return ( + 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 (
{ @@ -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)" }} - > -
{children}
- {m && ( - + className={clsx( + "min-h-6", + mediaTitleClass(true, shelf), + onClick && "cursor-pointer hover:text-accent", )} + > + {children}
); } +/** 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 ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(); + } + }} + className={clsx(MEDIA_PICTURE, "cursor-pointer")} + > + + {children} +
{plays && }
+ +
+ {bar} +
+ ); +} + +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 && ( + + + {card.rating.toFixed(1)} + + ); return ( -
-
-
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onOpen(); + <> + } - }} - className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`} - > - - {/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-menu keeps the - dot ABOVE the full-card hover-reveal overlay below (absolute inset-0, no - pointer-events-none): without it the overlay sits on top and swallows the cursor, so the - dot's native `title` tooltip never fired on hover. */} - {card.playable && - (() => { - const cls = playableClass(card.playable, card.transcode_hw); - return ( - - ); - })()} - {/* Rest-state watched badge (top-right). */} - {watched && ( - - {t("plex.watched")} - - )} - {/* Aggregate in-progress badge for a partially-watched show. */} - {card.type === "show" && card.status === "in_progress" && ( - - {t("plex.inProgress")} - - )} - {/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */} - {card.type === "show" && card.season_count != null && ( - - {t("plex.seasons", { count: card.season_count })} - - )} - - {card.type === "show" ? : } + {watched && } + {/* Aggregate in-progress badge for a partially-watched show. */} + {card.type === "show" && card.status === "in_progress" && } + {corner && {corner}} + + } + title={ + + {card.title} + + } + meta={ + epCode ? ( +
{t("plex.nextUp", epCode)}
+ ) : ( + (!!card.year || card.rating != null) && ( +
+ {!!card.year && {card.year}} + {!!card.year && rating && ·} + {rating} +
+ ) + ) + } + more={m} + moreLabel={t("card.menu.more")} + /> + + {[card.year, corner].filter(Boolean).join(" · ")} + {rating && (!!card.year || !!corner) && ·} + {rating}
+ } + 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). */} -
-
-
-
- -
-
- {(card.summary || card.rating != null) && ( -
-
- {card.title} -
-
- {(card.year || runtime) && ( - {[card.year, runtime].filter(Boolean).join(" · ")} - )} - {card.rating != null && ( - - - {card.rating.toFixed(1)} - - )} -
- {card.summary && ( -

- {card.summary} -

- )} -
- )} -
+/** The picture's rest-state marks, in the corners the hover row leaves free (it docks bottom-right). */ +function WatchedBadge() { + const { t } = useTranslation(); + return {t("plex.watched")}; +} - {pct > 0 && ( -
-
-
- )} -
- -
- -
- {card.title} -
-
- {[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")} -
- -
+function InProgressBadge() { + const { t } = useTranslation(); + return ( + + {t("plex.inProgress")} + + ); +} + +/** 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 ( + + {children} + ); } @@ -339,72 +429,45 @@ export function SeasonCard({ const watched = se.status === "watched"; const m = more(se, menuButtonProps, menuOpen); return ( -
-
-
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onOpen(); - } - }} - className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`} - > - -
-
- - {t("plex.series.openSeason")} -
-
- {watched && ( - - {t("plex.watched")} - - )} - {/* In-progress lives at the top-LEFT (the hover row owns the top-right) and stays visible: - a season has no progress bar, so this badge is its sole in-progress cue. */} - {se.status === "in_progress" && ( - - {t("plex.inProgress")} - - )} - - {t("plex.series.episodeCount", { count: se.episode_count })} - -
- } - onToggleWatched={onToggleWatched} - addLabel={t("plex.playlist.addSeason")} - onAdd={onAdd} - more={m} - /> -
- -
{se.title}
- -
+ > + {watched && } + {/* A season has no progress bar, so this badge is its sole in-progress cue. */} + {se.status === "in_progress" && } + + {t("plex.series.episodeCount", { count: se.episode_count })} + + + } + title={{se.title}} + more={m} + moreLabel={t("card.menu.more")} + /> ); } @@ -508,9 +571,10 @@ export function RelatedStrip({

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

{related.map((r, i) => ( -
+
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 ( -
-
-
{ - 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" + + } > - -
- -
- {watched && ( - - - + {watched && } + {runtime && ( + {runtime} )} - {pct > 0 && ( -
-
-
+ + } + title={ + + {!withShowTitle && ep.episode_number != null && ( + {ep.episode_number}. )} -
- -
- - {withShowTitle && ep.show_title && ( -
+ {ep.title} + + } + meta={ + withShowTitle && + ep.show_title && ( +
{ep.show_title} {ep.season_number != null && ep.episode_number != null ? ` · S${ep.season_number}·E${ep.episode_number}` : ""}
- )} -
- {!withShowTitle && {ep.episode_number}.} - {ep.title} -
-
{formatRuntime(ep.duration_seconds)}
- -
+ ) + } + more={m} + moreLabel={t("card.menu.more")} + /> ); } diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 9d872e31..a4cb4afc 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -262,7 +262,7 @@ export function PlexPersonView({
{d.biography && ( -

+

{d.biography}

)} @@ -277,8 +277,9 @@ export function PlexPersonView({ keyboardNav > {d.known_for.map((c, i) => ( -
+
onOpenCard(c)} @@ -470,9 +471,12 @@ export function PlexShowView({ } > {show.title} -
+ {/* 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). */} +
{show.year != null && ( )} {show.content_rating && ( - - ·{" "} - onFilter({ contentRatings: [show.content_rating!] }) - : undefined - } - > - {show.content_rating} - - + onFilter({ contentRatings: [show.content_rating!] }) + : undefined + } + > + {show.content_rating} + )} {show.season_count != null && ( - · {t("plex.seasons", { count: show.season_count })} + {t("plex.seasons", { count: show.season_count })} )} {(() => { // 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 */} - · + {score.toFixed(1)} ); @@ -538,7 +540,9 @@ export function PlexShowView({
)} {show.summary && ( -

{show.summary}

+

+ {show.summary} +

)}
{show.resume && ( diff --git a/frontend/src/components/ui/Carousel.tsx b/frontend/src/components/ui/Carousel.tsx index 02ada021..c8bcd30c 100644 --- a/frontend/src/components/ui/Carousel.tsx +++ b/frontend/src/components/ui/Carousel.tsx @@ -132,7 +132,10 @@ export function Carousel({ scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
+ + + ); +} + +/** 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 ( +
+
+
+ ); +} + +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 & Record; + /** 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; open: boolean }; + /** The "…"'s name (the caller's words: this platform file speaks no feature namespace). */ + moreLabel?: string; + className?: string; +}) { + return ( +
+ {picture} +
+ {leading} +
+ {title} + {meta &&
{meta}
} +
+ {more && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/ui/MediaPreview.tsx b/frontend/src/components/ui/MediaPreview.tsx new file mode 100644 index 00000000..75c70efd --- /dev/null +++ b/frontend/src/components/ui/MediaPreview.tsx @@ -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; + title: string; + /** One line under the title: year, length, score. */ + facts?: ReactNode; + summary?: string | null; +}) { + return ( + +

{title}

+ {facts &&
{facts}
} + {summary &&

{summary}

} +
+ ); +} diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 23df279f..31abe573 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -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` | diff --git a/frontend/src/components/ui/anchoredPopoverHooks.ts b/frontend/src/components/ui/anchoredPopoverHooks.ts index 0b81da66..9a729da8 100644 --- a/frontend/src/components/ui/anchoredPopoverHooks.ts +++ b/frontend/src/components/ui/anchoredPopoverHooks.ts @@ -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 diff --git a/frontend/src/components/ui/mediaCardStyles.ts b/frontend/src/components/ui/mediaCardStyles.ts new file mode 100644 index 00000000..4efdbac2 --- /dev/null +++ b/frontend/src/components/ui/mediaCardStyles.ts @@ -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"); diff --git a/frontend/src/components/ui/mediaRail.contract.test.ts b/frontend/src/components/ui/mediaRail.contract.test.ts new file mode 100644 index 00000000..e2b2497f --- /dev/null +++ b/frontend/src/components/ui/mediaRail.contract.test.ts @@ -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;/, + ); + }); +}); diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts index 4c199525..061ff552 100644 --- a/frontend/src/components/ui/pressableSelectable.contract.test.ts +++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts @@ -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 = { - "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"], diff --git a/frontend/src/components/ui/useMediaPreview.ts b/frontend/src/components/ui/useMediaPreview.ts new file mode 100644 index 00000000..09e5341d --- /dev/null +++ b/frontend/src/components/ui/useMediaPreview.ts @@ -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 & Record = {}) => ({ + ...(pop.getReferenceProps({ + ...own, + onPointerDown: (e: React.PointerEvent) => { + setOpen(false); + (own.onPointerDown as ((e: React.PointerEvent) => void) | undefined)?.(e); + }, + onContextMenu: (e: React.MouseEvent) => { + setOpen(false); + (own.onContextMenu as ((e: React.MouseEvent) => void) | undefined)?.(e); + }, + }) as HTMLAttributes & Record), + ref: pop.refs.setReference, + }), + }; +} diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx index 795eab09..42a71cdd 100644 --- a/frontend/src/design-lab/DesignLab.tsx +++ b/frontend/src/design-lab/DesignLab.tsx @@ -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() { C ✓ + + S3h ✓ +
@@ -114,6 +118,14 @@ export default function DesignLab() {

+
+

Decided · S3h, the media card

+

+ Hover a card: its Play and its hover row. The preview, the hero and the info page live + in the app. +

+
+
); diff --git a/frontend/src/design-lab/batchD.tsx b/frontend/src/design-lab/batchD.tsx new file mode 100644 index 00000000..5d7e6c42 --- /dev/null +++ b/frontend/src/design-lab/batchD.tsx @@ -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 ( +
+ + {[Check, ...(save ? [Bookmark] : []), ListPlus, MoreHorizontal].map((Icon, i) => ( + + + + ))} + +
+ ); +} + +function Picture({ + aspect, + length, + small = false, + save = false, +}: { + aspect: "poster" | "wide"; + length: string; + small?: boolean; + save?: boolean; +}) { + return ( +
+
+ {length} +
+ +
+
+ +
+ ); +} + +const score = ( + + + 7.8 + +); +const plexMeta = ( +
+ 2003 + · + {score} +
+); + +export function BatchD() { + return ( +
+ +
+ +
+ } + title={ +
+ The Last Samurai, a title long enough to wrap +
+ } + meta={plexMeta} + /> +
+
+ +
+ } + title={
A video title
} + meta={ + <> +
A channel
+
12K views · 3 days ago
+ + } + /> +
+
+ +
+ } + title={ +
+ The Last Samurai, cut to one line on a shelf +
+ } + meta={plexMeta} + /> +
+
+
+
+ +

+ 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. +

+
+
+ ); +} diff --git a/frontend/src/i18n/locales/en/home.json b/frontend/src/i18n/locales/en/home.json index 1b696629..81685c8f 100644 --- a/frontend/src/i18n/locales/en/home.json +++ b/frontend/src/i18n/locales/en/home.json @@ -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", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 14eac6a7..15b8b750 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/home.json b/frontend/src/i18n/locales/hu/home.json index c7655f6c..41585b24 100644 --- a/frontend/src/i18n/locales/hu/home.json +++ b/frontend/src/i18n/locales/hu/home.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 84c97630..9dd8560b 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -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", diff --git a/frontend/src/index.css b/frontend/src/index.css index f393c9d6..9792f297 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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;