E5 S1: shared Carousel primitive + Plex card-art serializer
Wave 5a building blocks under the Seerr-inspired Plex enrichment (E5).
Carousel (frontend/src/components/Carousel.tsx): the one horizontal-rail
component, replacing the hand-rolled `flex overflow-x-auto` strips and the
home of the future Plex-Discover rows. Edge-fade mask, hover/focus scroll
buttons that appear only on the clipped side (useHScroll — the X-axis twin
of useScrollFade, same watch-the-children discipline), native scrollbar
hidden behind .no-scrollbar, reduced-motion-aware paging. First consumers:
CastStrip + RelatedStrip now scroll inside it (dedup + free affordances).
New i18n: common.seeAll / scrollLeft / scrollRight (en+hu).
Card-art serializer (backend/app/plex/cards.py): movie_card + show_card now
emit `art` (the wide backdrop, /api/plex/image/{rk}?variant=art) so rails and
the crossfading hero get a landscape image with no per-item fetch — gated on
art_key so an art-less item stays null and falls back to the poster. FE type
PlexCard.art?.
Tests: Carousel.dom.test (children/header/see-all, clipped-edge buttons +
paging), test_plex_cards (art present/absent by art_key). Added a
ResizeObserver stub to PlexPersonView.dom.test (it renders CastStrip, now a
Carousel consumer).
Gate: tsc x3 / eslint / prettier / knip / vitest 324 green; ruff green;
backend test lane (docker-compose.test.yml) green on the plex card tests.
This commit is contained in:
@@ -6,6 +6,14 @@ router. Pure functions — no DB, no request state."""
|
|||||||
from app.models import PlexItem, PlexShow, PlexState
|
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:
|
def movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": it.rating_key,
|
"id": it.rating_key,
|
||||||
@@ -14,6 +22,7 @@ def movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
|||||||
"year": it.year,
|
"year": it.year,
|
||||||
"duration_seconds": it.duration_s,
|
"duration_seconds": it.duration_s,
|
||||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
|
"art": _art_url(it.rating_key, it.art_key),
|
||||||
"playable": it.playable,
|
"playable": it.playable,
|
||||||
"status": st.status if st else "new",
|
"status": st.status if st else "new",
|
||||||
"position_seconds": st.position_seconds if st else 0,
|
"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,
|
"title": sh.title,
|
||||||
"year": sh.year,
|
"year": sh.year,
|
||||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||||
|
"art": _art_url(sh.rating_key, sh.art_key),
|
||||||
"season_count": sh.child_count,
|
"season_count": sh.child_count,
|
||||||
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<typeof import("react-i18next")>();
|
||||||
|
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(
|
||||||
|
<Carousel title="Recently added" onSeeAll={onSeeAll}>
|
||||||
|
<div>card-a</div>
|
||||||
|
<div>card-b</div>
|
||||||
|
</Carousel>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<Carousel ariaLabel="Cast">
|
||||||
|
<div>x</div>
|
||||||
|
</Carousel>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<Carousel ariaLabel="rail">
|
||||||
|
<div>x</div>
|
||||||
|
</Carousel>,
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<HTMLElement | null>(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 (
|
||||||
|
<div className={clsx("group/car relative", className)}>
|
||||||
|
{title != null && (
|
||||||
|
<div className="mb-3 flex items-baseline gap-2">
|
||||||
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
|
{onSeeAll && (
|
||||||
|
<button
|
||||||
|
onClick={onSeeAll}
|
||||||
|
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
|
||||||
|
>
|
||||||
|
{seeAllLabel ?? t("common.seeAll")}
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{left && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => page(-1)}
|
||||||
|
aria-label={t("common.scrollLeft")}
|
||||||
|
className={clsx(arrowBtn, "left-1")}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={clsx("no-scrollbar flex overflow-x-auto pb-1", gap)}
|
||||||
|
style={{ maskImage: mask, WebkitMaskImage: mask }}
|
||||||
|
role="group"
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{right && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => page(1)}
|
||||||
|
aria-label={t("common.scrollRight")}
|
||||||
|
className={clsx(arrowBtn, "right-1")}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,23 @@
|
|||||||
// filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero,
|
// 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
|
// 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.
|
// 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 { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Tv2 } from "lucide-react";
|
import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Tv2 } from "lucide-react";
|
||||||
import { type CSSProperties } from "react";
|
import { type CSSProperties } from "react";
|
||||||
import { formatRuntime } from "../lib/format";
|
import { formatRuntime } from "../lib/format";
|
||||||
|
import { Carousel } from "./Carousel";
|
||||||
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||||
|
|
||||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||||
@@ -301,7 +302,7 @@ export function CastStrip({
|
|||||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||||
>
|
>
|
||||||
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
|
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
|
||||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")}>
|
||||||
{cast.map((c, i) => {
|
{cast.map((c, i) => {
|
||||||
const inner = (
|
const inner = (
|
||||||
<>
|
<>
|
||||||
@@ -345,7 +346,7 @@ export function CastStrip({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Carousel>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -364,7 +365,7 @@ export function RelatedStrip({
|
|||||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||||
>
|
>
|
||||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
|
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
|
||||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")}>
|
||||||
{related.map((r) => (
|
{related.map((r) => (
|
||||||
<button
|
<button
|
||||||
key={r.id}
|
key={r.id}
|
||||||
@@ -389,7 +390,7 @@ export function RelatedStrip({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Carousel>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
"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",
|
"backToTop": "Back to top",
|
||||||
"retry": "Retry",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
"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",
|
"backToTop": "Vissza a tetejére",
|
||||||
"retry": "Újra",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface PlexCard {
|
|||||||
year?: number | null;
|
year?: number | null;
|
||||||
duration_seconds?: number | null;
|
duration_seconds?: number | null;
|
||||||
thumb: string;
|
thumb: string;
|
||||||
|
art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key
|
||||||
playable?: string; // direct | remux | transcode
|
playable?: string; // direct | remux | transcode
|
||||||
status?: string; // new | watched | hidden
|
status?: string; // new | watched | hidden
|
||||||
position_seconds?: number;
|
position_seconds?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user