A bare <img> sent a Referer that Google's image CDNs refuse and had no retry and no fallback, so a Home shelf card showed the browser's broken-image mark. ui/RemoteImage gathers what Avatar, the channel banner and the download rows had each hand-rolled: no Referer, up to three retries past the browser's negative cache, the caller's fallback (by default the card's empty frame) instead of the broken mark, the failure remembered by URL. Every picture now goes through it; a lint rule refuses a bare <img>.
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
import { useRef, useState, type ReactNode, type Ref } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
ArrowLeft,
|
||
Check,
|
||
CheckCheck,
|
||
Play,
|
||
SlidersHorizontal,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
import { useDismiss } from "../lib/useDismiss";
|
||
import { DETAIL_POSTER } from "../lib/plexDetail";
|
||
import { Button } from "./ui/Button";
|
||
import { IconButton } from "./ui/IconButton";
|
||
import { Pressable } from "./ui/Pressable";
|
||
import { SURFACE } from "./ui/surfaces";
|
||
import { Badge } from "./ui/Badge";
|
||
import { Chip } from "./ui/Chip";
|
||
import RemoteImage from "./ui/RemoteImage";
|
||
|
||
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
||
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
||
// per-user display prefs (art background / cast row — same keys as the movie info page), and the
|
||
// customize menu that toggles them. The prefs hook, backdrop helpers and epLabel live in plexDetail.ts.
|
||
|
||
// A metadata value that filters the grid when clicked (if onClick is wired), else plain text. Shared
|
||
// by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). A filtering value
|
||
// always says so on hover: `title` is a caller's specific hint ("Show titles from 1998"), else the
|
||
// generic "Filter by …" from the value's own text.
|
||
export function Filterable({
|
||
onClick,
|
||
title,
|
||
className,
|
||
tag = false,
|
||
onDark = false,
|
||
children,
|
||
}: {
|
||
onClick?: () => void;
|
||
title?: string;
|
||
className?: string;
|
||
/** A genre / keyword tag rather than a run of text: an edged chip when it filters, a flat badge
|
||
* when it doesn't (edged = pressable). */
|
||
tag?: boolean;
|
||
onDark?: boolean;
|
||
children: ReactNode;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const hint =
|
||
title ??
|
||
(onClick && (typeof children === "string" || typeof children === "number")
|
||
? t("plex.info.filterValue", { name: children })
|
||
: undefined);
|
||
if (tag)
|
||
return onClick ? (
|
||
<Chip variant="token" onClick={onClick} title={hint}>
|
||
{children}
|
||
</Chip>
|
||
) : (
|
||
<Badge onDark={onDark}>{children}</Badge>
|
||
);
|
||
if (!onClick) return <span className={className}>{children}</span>;
|
||
return (
|
||
<Pressable
|
||
selectable
|
||
onClick={onClick}
|
||
title={hint}
|
||
// min-h-6 + touch-target: a value in the meta line is still a 24 / 44 target (B1, A3).
|
||
className={`${className ?? ""} min-h-6 min-w-6 cursor-pointer hover:text-accent transition touch-target`}
|
||
>
|
||
{children}
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
export function DetailCustomizeMenu({
|
||
artBg,
|
||
onToggleArtBg,
|
||
showCast,
|
||
onToggleCast,
|
||
hasCast,
|
||
showRelated,
|
||
onToggleRelated,
|
||
hasRelated,
|
||
crossfade,
|
||
onToggleCrossfade,
|
||
overlay,
|
||
extra,
|
||
}: {
|
||
artBg: boolean;
|
||
onToggleArtBg: () => void;
|
||
showCast: boolean;
|
||
onToggleCast: () => void;
|
||
hasCast: boolean;
|
||
showRelated?: boolean;
|
||
onToggleRelated?: () => void;
|
||
hasRelated?: boolean;
|
||
// Crossfade toggle — offered only when the art backdrop itself is on; omitted where a page
|
||
// doesn't wire it (e.g. the season page reuses the show's backdrop prefs without its own control).
|
||
crossfade?: boolean;
|
||
onToggleCrossfade?: () => void;
|
||
overlay?: boolean; // white-on-dark button styling when floated over the player
|
||
extra?: ReactNode; // additional toggles rendered after the built-in ones (e.g. strip-source buckets)
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const [open, setOpen] = useState(false);
|
||
const btnRef = useRef<HTMLButtonElement>(null);
|
||
const menuRef = useRef<HTMLDivElement>(null);
|
||
useDismiss(open, () => setOpen(false), [menuRef, btnRef]);
|
||
// Deliberately NOT on the popover platform. In the player's OVERLAY variant this menu renders
|
||
// inside PlexPlayer's `transform: scale(1.25)` windowed subtree, and the platform portals its panel
|
||
// out to <body> — which would paint it at 1× while the surrounding player UI is at 1.25×. A plain
|
||
// `absolute` child stays in the scaled subtree and matches, in both this variant and the unscaled
|
||
// detail-page variant. Same reason the player's own gear/tracks menus stay hand-rolled; migrating
|
||
// any of them needs the platform to portal INTO a caller-named (scaled) container.
|
||
return (
|
||
<div className="relative shrink-0">
|
||
<IconButton
|
||
ref={btnRef}
|
||
variant={overlay ? "overlay" : "ghost"}
|
||
size="md"
|
||
onClick={() => setOpen((v) => !v)}
|
||
aria-expanded={open}
|
||
label={t("plex.info.customize")}
|
||
>
|
||
<SlidersHorizontal />
|
||
</IconButton>
|
||
{open && (
|
||
<div
|
||
ref={menuRef}
|
||
className={`${SURFACE.menu} absolute right-0 top-full z-menu mt-1 w-52 text-sm`}
|
||
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
|
||
>
|
||
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||
{artBg && onToggleCrossfade && (
|
||
<PrefToggle
|
||
label={t("plex.info.prefCrossfade")}
|
||
on={crossfade ?? true}
|
||
onClick={onToggleCrossfade}
|
||
/>
|
||
)}
|
||
{hasCast && (
|
||
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
|
||
)}
|
||
{hasRelated && onToggleRelated && (
|
||
<PrefToggle
|
||
label={t("plex.info.prefRelated")}
|
||
on={showRelated ?? true}
|
||
onClick={onToggleRelated}
|
||
/>
|
||
)}
|
||
{extra}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** One switch row inside `DetailCustomizeMenu`. Independent on/off toggles (all can be on at once),
|
||
* so a plain button, not a menuitemradio. */
|
||
export function PrefToggle({
|
||
label,
|
||
on,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
on: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<Pressable
|
||
role="switch"
|
||
aria-checked={on}
|
||
layer
|
||
onClick={onClick}
|
||
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5"
|
||
>
|
||
<span>{label}</span>
|
||
<span
|
||
className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 h-3 w-3 rounded-full transition-all ${on ? "left-3.5 bg-accent-fg" : "left-0.5 bg-white"}`}
|
||
/>
|
||
</span>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
// --- Series detail-page shared UI (the show page + the season subpage share these) -------------------
|
||
// Moved out of PlexBrowse — small building blocks the show/season detail views render verbatim.
|
||
|
||
export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
|
||
return (
|
||
<Button variant="glass" size="sm" icon={<ArrowLeft />} onClick={onBack} className="mb-4">
|
||
{label}
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
export function ActionBtn({
|
||
ref,
|
||
onClick,
|
||
icon: Icon,
|
||
label,
|
||
sub,
|
||
primary,
|
||
disabled,
|
||
}: {
|
||
onClick: () => void;
|
||
icon: LucideIcon;
|
||
label: string;
|
||
sub?: string;
|
||
primary?: boolean;
|
||
disabled?: boolean;
|
||
ref?: Ref<HTMLButtonElement>;
|
||
}) {
|
||
return (
|
||
<Button
|
||
ref={ref}
|
||
variant={primary ? "primary" : "glass"}
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
icon={<Icon />}
|
||
>
|
||
{label}
|
||
{/* The resume point rides on the same line: the md rung is one line tall (B1). */}
|
||
{sub && <span className="opacity-75">· {sub}</span>}
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were
|
||
* copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot,
|
||
* and the flex info column; the caller passes the customize menu + the title/metadata/actions as
|
||
* `children`. Poster is slightly larger on the show page (default) than the season page. `aside` is the
|
||
* show page's second column (the facts and crew, plexDetailAside): beside the text in a wide hero,
|
||
* under it in a narrow one. */
|
||
export function DetailHero({
|
||
thumb,
|
||
posterTitle,
|
||
posterDisabled,
|
||
showPlayOverlay,
|
||
onPosterClick,
|
||
posterSizeClass = DETAIL_POSTER,
|
||
customizeMenu,
|
||
aside,
|
||
children,
|
||
}: {
|
||
thumb: string;
|
||
posterTitle: string;
|
||
posterDisabled: boolean;
|
||
showPlayOverlay: boolean;
|
||
onPosterClick: () => void;
|
||
posterSizeClass?: string;
|
||
customizeMenu: ReactNode;
|
||
aside?: ReactNode;
|
||
children: ReactNode;
|
||
}) {
|
||
const main = (
|
||
<div className="flex gap-4 sm:gap-5">
|
||
<Pressable
|
||
onClick={onPosterClick}
|
||
disabled={posterDisabled}
|
||
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
|
||
title={posterTitle}
|
||
>
|
||
<RemoteImage
|
||
src={thumb}
|
||
alt=""
|
||
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
|
||
fallback={
|
||
<div className="w-full aspect-2/3 rounded-xl border border-border bg-surface" />
|
||
}
|
||
/>
|
||
{showPlayOverlay && (
|
||
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
||
<Play className="w-10 h-10 text-white" fill="currentColor" />
|
||
</div>
|
||
)}
|
||
</Pressable>
|
||
<div className="min-w-0 flex-1">{children}</div>
|
||
</div>
|
||
);
|
||
return (
|
||
<div className={`${SURFACE.panel} @container relative p-4 sm:p-5 mb-8`}>
|
||
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
|
||
{aside ? (
|
||
<div className="grid gap-6 @5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]">
|
||
{main}
|
||
{aside}
|
||
</div>
|
||
) : (
|
||
main
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both
|
||
* the show and season heroes; the labels differ, so the caller passes both. */
|
||
export function WatchedToggle({
|
||
watched,
|
||
busy,
|
||
onToggle,
|
||
watchedLabel,
|
||
unwatchedLabel,
|
||
}: {
|
||
watched: boolean;
|
||
busy?: boolean;
|
||
onToggle: () => void;
|
||
watchedLabel: string;
|
||
unwatchedLabel: string;
|
||
}) {
|
||
return (
|
||
<ActionBtn
|
||
onClick={onToggle}
|
||
disabled={busy}
|
||
icon={watched ? CheckCheck : Check}
|
||
label={watched ? watchedLabel : unwatchedLabel}
|
||
/>
|
||
);
|
||
}
|