Files
siftlode/frontend/src/components/UpNextCard.tsx
T
peter ff80cc96fd fix(design): rating and live colour tokens, the player's menus over its notices, the info overlay on a light theme (E28)
- F16: --rating (theme-scoped gold, 4.5:1 on the page and its chip), --rating-media, --live/--live-fg,
  --media-chrome replace 14 palette literals; a lint rule refuses palette classes; the theme contract
  measures them.
- The Plex player's notices drew over its open settings/tracks popovers: notices z-base, popovers z-menu.
- The player's info overlay rendered theme tokens on its always-dark panel: on a light theme the score
  (2.6:1), collection years (3.2:1) and cast initials (1.05:1) were unreadable; onDark variants now.
- The lint-rule contract tests share one ESLint with a one-off warm-up (a cold first lint took 8 s,
  past vitest's 5 s).
2026-09-25 13:55:26 +02:00

139 lines
4.8 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Play, X } from "lucide-react";
import { api } from "../lib/api";
import { plexLang } from "../i18n";
import { qk } from "../lib/queryKeys";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
// Up-next / end-card (E6 S4). Shown at episode end when a next item exists and the pref is on, INSTEAD
// of the old instant jump — so binge stays under the viewer's control (D6-A). Counts down ~10s then
// advances; "Play now" advances immediately; Cancel/✕ dismisses and stays on the finished frame. It only
// lives on the play-to-the-end path; credits-auto-skip advances earlier and never reaches this card.
const COUNTDOWN_S = 10;
export function UpNextCard({
nextId,
onAdvance,
onCancel,
}: {
nextId: string;
onAdvance: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const lang = plexLang();
const nextQ = useQuery({
queryKey: qk.plexItem(nextId, lang),
queryFn: () => api.plexItem(nextId, lang),
});
const next = nextQ.data;
// Countdown. `onAdvance` is read through a ref so the 100ms interval never restarts on re-render.
const [remaining, setRemaining] = useState(COUNTDOWN_S);
const advanceRef = useRef(onAdvance);
advanceRef.current = onAdvance;
useEffect(() => {
const started = performance.now();
const iv = window.setInterval(() => {
const left = COUNTDOWN_S - (performance.now() - started) / 1000;
if (left <= 0) {
window.clearInterval(iv);
setRemaining(0);
advanceRef.current();
} else {
setRemaining(left);
}
}, 100);
return () => window.clearInterval(iv);
}, []);
const secs = Math.ceil(remaining);
const frac = remaining / COUNTDOWN_S; // 1 → 0 as it counts down
const R = 10;
const C = 2 * Math.PI * R;
// Title: "Show · S2·E5" over the episode title for episodes; the plain title for movies/queue items.
const heading =
next?.kind === "episode" && next.show_title
? `${next.show_title} · S${next.season_number ?? "?"}·E${next.episode_number ?? "?"}`
: (next?.title ?? "");
const sub = next?.kind === "episode" ? next.title : null;
return (
<div
role="region"
aria-label={t("plex.player.upNext.title")}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-media-chrome/95 p-3.5 text-white shadow-2xl"
>
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
{next && (
<img
src={api.plexImageUrl(nextId, "thumb")}
alt=""
className="h-full w-full object-cover"
draggable={false}
/>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="mb-0.5 text-2xs font-semibold uppercase tracking-wide text-white/50">
{t("plex.player.upNext.title")}
</div>
<div className="truncate text-sm font-semibold leading-tight">{heading || "…"}</div>
{sub && <div className="truncate text-xs text-white/60">{sub}</div>}
<div className="mt-auto flex items-center gap-2 pt-2.5">
<Button size="sm" round onClick={onAdvance} className="text-xs font-semibold">
<span className="relative grid h-5.5 w-5.5 flex-none place-items-center">
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
<circle
cx="12"
cy="12"
r={R}
fill="none"
stroke="rgba(0,0,0,0.25)"
strokeWidth="2"
/>
<circle
cx="12"
cy="12"
r={R}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={C * (1 - frac)}
/>
</svg>
<Play className="h-2.5 w-2.5 fill-black" />
</span>
{t("plex.player.upNext.playNow")}
<span className="tabular-nums">{secs}</span>
</Button>
<Button
variant="overlay"
size="sm"
round
onClick={onCancel}
className="border border-white/25 text-xs"
>
{t("plex.player.upNext.cancel")}
</Button>
</div>
</div>
<IconButton
variant="overlay"
onClick={onCancel}
label={t("plex.player.upNext.cancel")}
className="absolute right-1 top-1"
>
<X />
</IconButton>
</div>
);
}