Eight verified findings from the sprint deep-review: - Transcode cap: _enforce_cap now counts only transcode sessions, so a live remux (stream-copy) stream can no longer be LRU-evicted mid-play — matching the config comment + the HU/EN Config hint that promise remux is uncapped. - Player Back: the browser/mouse Back interceptor (BackClose) now also mounts for the stats overlay and up-next card, so Back closes them first instead of ejecting the whole player (keyboard/Esc already did). - Honest cold-start: loadSession clears sessionIsTranscode up front, so a transcode->direct/remux switch no longer flashes a stale "Preparing stream..." during the session request. - Stats overlay a11y: the close (X) buttons in the stats, help and info overlays now read as "Close"/"Bezaras" (new plex.player.close key), not "Back"; dropped aria-live from the per-second stat grid so a screen reader is not flooded with telemetry. Tests: route-level transcode routing (transcode item -> HLS re-encode session, remux stays copy, direct opens none); start_session threading the preset/crf/max_height knobs + hw/preexec into the ffmpeg argv; UpNextCard countdown auto-advance, Play-now, Cancel and the S?-E? heading fallback. Also brings UpNextCard.tsx / plexStats.ts / plexStats.test.ts to prettier.
136 lines
4.9 KiB
TypeScript
136 lines
4.9 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";
|
|
|
|
// 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-neutral-900/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
|
|
onClick={onAdvance}
|
|
className="flex items-center gap-1.5 rounded-full bg-accent px-3 py-1.5 text-xs font-semibold text-black hover:brightness-110"
|
|
>
|
|
<span className="relative grid h-[22px] w-[22px] 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
|
|
onClick={onCancel}
|
|
className="rounded-full border border-white/25 px-3 py-1.5 text-xs text-white/85 hover:bg-white/10"
|
|
>
|
|
{t("plex.player.upNext.cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onCancel}
|
|
aria-label={t("plex.player.upNext.cancel")}
|
|
className="absolute right-2 top-2 rounded p-0.5 text-white/50 hover:bg-white/10 hover:text-white"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|