diff --git a/backend/app/plex/cards.py b/backend/app/plex/cards.py index e8abf9a4..7e13a595 100644 --- a/backend/app/plex/cards.py +++ b/backend/app/plex/cards.py @@ -6,6 +6,14 @@ router. Pure functions — no DB, no request state.""" from app.models import PlexItem, PlexShow, PlexState +# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item +# actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an +# `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that +# tried to use it, so gate it on the stored key and let the client fall back to the poster. +def _art_url(rating_key: str, art_key: str | None) -> str | None: + return f"/api/plex/image/{rating_key}?variant=art" if art_key else None + + def movie_card(it: PlexItem, st: PlexState | None) -> dict: return { "id": it.rating_key, @@ -14,6 +22,7 @@ def movie_card(it: PlexItem, st: PlexState | None) -> dict: "year": it.year, "duration_seconds": it.duration_s, "thumb": f"/api/plex/image/{it.rating_key}", + "art": _art_url(it.rating_key, it.art_key), "playable": it.playable, "status": st.status if st else "new", "position_seconds": st.position_seconds if st else 0, @@ -27,6 +36,7 @@ def show_card(sh: PlexShow, status: str = "new") -> dict: "title": sh.title, "year": sh.year, "thumb": f"/api/plex/image/{sh.rating_key}", + "art": _art_url(sh.rating_key, sh.art_key), "season_count": sh.child_count, # Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge. "status": status, diff --git a/backend/tests/test_plex_cards.py b/backend/tests/test_plex_cards.py new file mode 100644 index 00000000..8efa25de --- /dev/null +++ b/backend/tests/test_plex_cards.py @@ -0,0 +1,31 @@ +"""Plex card serializers (app.plex.cards) — pure model->dict projections, no DB needed. + +E5 S1: movie/show cards now also emit `art` (the wide backdrop, for a Discover rail's landscape tile +and the crossfading hero), but ONLY when the item actually has an `art_key` — otherwise the URL would +resolve to a blank image on every card that tried to use it, so it must be null and let the client fall +back to the poster.""" + +from app.models import PlexItem, PlexShow +from app.plex.cards import movie_card, show_card + + +def test_movie_card_emits_art_when_art_key_present(): + it = PlexItem(rating_key="m1", kind="movie", title="M", year=2020, art_key="/library/art/1") + card = movie_card(it, None) + assert card["thumb"] == "/api/plex/image/m1" + assert card["art"] == "/api/plex/image/m1?variant=art" + + +def test_movie_card_art_is_none_without_art_key(): + it = PlexItem(rating_key="m2", kind="movie", title="M2") + assert movie_card(it, None)["art"] is None + + +def test_show_card_emits_art_when_art_key_present(): + sh = PlexShow(rating_key="s1", title="S", art_key="/library/art/2") + assert show_card(sh)["art"] == "/api/plex/image/s1?variant=art" + + +def test_show_card_art_is_none_without_art_key(): + sh = PlexShow(rating_key="s2", title="S2") + assert show_card(sh)["art"] is None diff --git a/frontend/src/components/Carousel.dom.test.tsx b/frontend/src/components/Carousel.dom.test.tsx new file mode 100644 index 00000000..c3627b72 --- /dev/null +++ b/frontend/src/components/Carousel.dom.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +// +// Contract of the shared horizontal rail. jsdom lays out nothing (scrollWidth/clientWidth are 0), so +// the overflow-driven scroll buttons only appear once we feed the scroller fake metrics and fire a +// scroll — which is exactly the affordance under test: a button shows ONLY on the clipped edge, and +// clicking it pages the scroller. i18n is key-passthrough, so asserted names are the i18n keys. +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; +}); + +import { Carousel } from "./Carousel"; + +beforeAll(() => { + // useHScroll wires a ResizeObserver (watch-the-children); jsdom ships none. + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); + +afterEach(cleanup); + +function setMetrics( + el: HTMLElement, + m: { scrollWidth: number; clientWidth: number; scrollLeft: number }, +) { + Object.defineProperty(el, "scrollWidth", { configurable: true, value: m.scrollWidth }); + Object.defineProperty(el, "clientWidth", { configurable: true, value: m.clientWidth }); + Object.defineProperty(el, "scrollLeft", { + configurable: true, + writable: true, + value: m.scrollLeft, + }); +} + +describe("Carousel", () => { + it("renders children and an optional header with a see-all action", () => { + const onSeeAll = vi.fn(); + render( + +
card-a
+
card-b
+
, + ); + expect(screen.getByText("card-a")).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Recently added" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "common.seeAll" })); + expect(onSeeAll).toHaveBeenCalledOnce(); + }); + + it("omits the header when no title is given and names the scroll region", () => { + render( + +
x
+
, + ); + expect(screen.queryByRole("heading")).toBeNull(); + expect(screen.getByRole("group", { name: "Cast" })).toBeTruthy(); + }); + + it("shows only the clipped edge's button and pages the scroller on click", () => { + render( + +
x
+
, + ); + const scroller = screen.getByRole("group", { name: "rail" }); + const scrollBy = vi.fn(); + scroller.scrollBy = scrollBy as unknown as typeof scroller.scrollBy; + + // At the left boundary with more content to the right → only the right button. + setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 0 }); + fireEvent.scroll(scroller); + expect(screen.queryByRole("button", { name: "common.scrollLeft" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "common.scrollRight" })); + expect(scrollBy).toHaveBeenCalled(); + + // Scrolled into the middle → both edges clipped → both buttons. + setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 350 }); + fireEvent.scroll(scroller); + expect(screen.getByRole("button", { name: "common.scrollLeft" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "common.scrollRight" })).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/Carousel.tsx b/frontend/src/components/Carousel.tsx new file mode 100644 index 00000000..b30b48e6 --- /dev/null +++ b/frontend/src/components/Carousel.tsx @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import clsx from "clsx"; + +// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing +// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future +// Plex-Discover rows. Gives every consumer the same affordances for free: edge fade, hover/focus scroll +// buttons that appear only on the side that actually has more content, a native scrollbar hidden behind +// `.no-scrollbar`, and reduced-motion-aware paging. Purely presentational — the caller owns the cards. + +// Prefer an instant jump when the viewer asked for reduced motion; smooth otherwise. Guarded for jsdom, +// where matchMedia is often absent (the button paging is still exercised — it just uses "auto" there). +function scrollBehavior(): ScrollBehavior { + const reduce = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + return reduce ? "auto" : "smooth"; +} + +// Horizontal scroll-affordance: tracks whether the scroller is clipped on the left / right so the rail +// can show a button + fade only where there's more to see. The X-axis twin of useScrollFade — same +// node-state callback ref (re-runs on attach) and same watch-the-children discipline (a rail whose +// cards load in later grows scrollWidth without ever firing a scroll event), and it also hands back the +// element so the buttons can scroll it. +function useHScroll(): { + ref: (node: HTMLElement | null) => void; + el: HTMLElement | null; + left: boolean; + right: boolean; +} { + const [el, setEl] = useState(null); + const [edge, setEdge] = useState({ left: false, right: false }); + + useEffect(() => { + if (!el) return; + const update = () => { + const left = el.scrollLeft > 1; + const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1; + setEdge((prev) => (prev.left === left && prev.right === right ? prev : { left, right })); + }; + update(); + el.addEventListener("scroll", update, { passive: true }); + const ro = new ResizeObserver(update); + ro.observe(el); + for (const child of Array.from(el.children)) ro.observe(child); + const mo = new MutationObserver((records) => { + for (const rec of records) { + for (const node of Array.from(rec.addedNodes)) + if (node instanceof Element) ro.observe(node); + for (const node of Array.from(rec.removedNodes)) + if (node instanceof Element) ro.unobserve(node); + } + update(); + }); + mo.observe(el, { childList: true }); + return () => { + el.removeEventListener("scroll", update); + ro.disconnect(); + mo.disconnect(); + }; + }, [el]); + + return { ref: setEl, el, left: edge.left, right: edge.right }; +} + +const FADE_PX = 28; + +export function Carousel({ + title, + onSeeAll, + seeAllLabel, + ariaLabel, + gap = "gap-3", + className, + children, +}: { + /** Optional built-in header (rails). Omit for a bare scroller inside a card that owns its own heading. */ + title?: ReactNode; + /** When set, a "see all" affordance appears in the header (needs `title`). */ + onSeeAll?: () => void; + seeAllLabel?: string; + /** Names the scroll region for AT; falls back to the string `title` when that's a plain string. */ + ariaLabel?: string; + /** Tailwind gap class for the scroller (e.g. "gap-4"). */ + gap?: string; + className?: string; + children: ReactNode; +}) { + const { t } = useTranslation(); + const { ref, el, left, right } = useHScroll(); + + const page = useCallback( + (dir: -1 | 1) => { + if (!el) return; + // ~one viewport-worth, but never a hair scroll on a narrow rail (or 0 in a zero-width jsdom box). + const amount = Math.max(Math.round(el.clientWidth * 0.85), 200); + el.scrollBy({ left: dir * amount, behavior: scrollBehavior() }); + }, + [el], + ); + + // Fade only the clipped edge — no fade at a boundary, none when it all fits (matches useScrollFade). + const mask = `linear-gradient(to right, transparent, #000 ${left ? FADE_PX : 0}px, #000 calc(100% - ${ + right ? FADE_PX : 0 + }px), transparent)`; + + const label = ariaLabel ?? (typeof title === "string" ? title : undefined); + + const arrowBtn = + "absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " + + "bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " + + "opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75"; + + return ( +
+ {title != null && ( +
+

{title}

+ {onSeeAll && ( + + )} +
+ )} + +
+ {left && ( + + )} +
+ {children} +
+ {right && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/PlexPersonView.dom.test.tsx b/frontend/src/components/PlexPersonView.dom.test.tsx index f91c2b0e..c6fb03b4 100644 --- a/frontend/src/components/PlexPersonView.dom.test.tsx +++ b/frontend/src/components/PlexPersonView.dom.test.tsx @@ -5,10 +5,23 @@ // filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero, // the Movies/Shows sections, and the "filter library by this person" action). i18n is key-passthrough // and the api is mocked so the query resolves synchronously in the test. -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"; +beforeAll(() => { + // CastStrip now scrolls inside a Carousel, whose useHScroll wires a ResizeObserver; jsdom ships none. + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); + vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 18595777..f3a02dcb 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Tv2 } from "lucide-react"; import { type CSSProperties } from "react"; import { formatRuntime } from "../lib/format"; +import { Carousel } from "./Carousel"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; // Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared @@ -301,7 +302,7 @@ export function CastStrip({ style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }} >

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

-
+ {cast.map((c, i) => { const inner = ( <> @@ -345,7 +346,7 @@ export function CastStrip({
); })} - + ); } @@ -364,7 +365,7 @@ export function RelatedStrip({ style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }} >

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

-
+ {related.map((r) => ( ))} -
+ ); } diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index deed2f72..0ccc7fdb 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -19,5 +19,8 @@ "demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.", "backToTop": "Back to top", "retry": "Retry", - "loadError": "Couldn't load this — check your connection and try again." + "loadError": "Couldn't load this — check your connection and try again.", + "seeAll": "See all", + "scrollLeft": "Scroll left", + "scrollRight": "Scroll right" } diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index a3386834..350e7d27 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -19,5 +19,8 @@ "demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.", "backToTop": "Vissza a tetejére", "retry": "Újra", - "loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra." + "loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra.", + "seeAll": "Összes", + "scrollLeft": "Görgetés balra", + "scrollRight": "Görgetés jobbra" } diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 9257b02f..5a601578 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -42,6 +42,7 @@ export interface PlexCard { year?: number | null; duration_seconds?: number | null; thumb: string; + art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key playable?: string; // direct | remux | transcode status?: string; // new | watched | hidden position_seconds?: number;