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:`.
140 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|