Files
siftlode/frontend/src/components/UpNextCard.tsx
T
peter 2768ab5bdd fix(gate): the plan-code guard catches decision codes, and the purge takes them
A plan's decision ids (`D1`, `D4`, `D6-A`) cited in 11 comments and test titles named a document
no reader of this repo has, and the guard had no pattern for them. It has now (mirrored in
audit_metrics), with cases for each form the citations took and for the look-alikes it must leave
alone (a JPEG marker, "3D"); the 11 lines say their why without the id. Case test seen red without
the pattern, the scan red on a planted `D9:`.
2026-10-05 03:16:13 +02:00

140 lines
4.9 KiB
TypeScript

import { useEffect, useEffectEvent, 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 { cardImage } from "../lib/plexImage";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
// Up-next / end-card. 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. 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, i18n } = useTranslation();
const lang = plexLang(i18n.language);
const nextQ = useQuery({
queryKey: qk.plexItem(nextId, lang),
queryFn: () => api.plexItem(nextId, lang),
});
const next = nextQ.data;
// Countdown. `onAdvance` is an effect event so the 100ms interval never restarts on re-render.
const [remaining, setRemaining] = useState(COUNTDOWN_S);
const advance = useEffectEvent(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);
advance();
} 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
{...cardImage(api.plexImageUrl(nextId, "thumb"))}
alt=""
loading="lazy"
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>
);
}