Files
siftlode/frontend/src/components/PlexPlayer.tsx
T
peter 00539a4d97 fix(plex): deep-review follow-ups on E6 transcode + player polish
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.
2026-08-30 02:04:17 +02:00

1320 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
ArrowLeft,
Clock,
Download,
Gauge,
Info,
Keyboard,
Languages,
Loader2,
Maximize,
Minimize,
Pause,
Play,
RotateCcw,
Settings,
SkipBack,
SkipForward,
Square,
Volume2,
VolumeX,
X,
} from "lucide-react";
import { api, HttpError } from "../lib/api";
import {
audioOrdForLang,
clamp01,
clockParts,
DEFAULT_PREFS,
fmt,
HLS_SUB_LAG,
subOrdForLang,
subShadowCss,
type PlexPlayerPrefs,
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { UpNextCard } from "./UpNextCard";
import {
useAutoSkip,
useControlsVisibility,
useFullscreen,
usePlayerVolume,
usePlexProgress,
usePlexTracks,
useWallClock,
} from "../lib/plexPlayerHooks";
import { fullscreenElement } from "../lib/fullscreen";
import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { useBackToClose } from "../lib/history";
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
// currentTime = sessionStart + video.currentTime and, on a seek beyond the generated region,
// restart the session at the target. Watch state / resume persist to plex_states.
// `queue` (ordered rating_keys) drives play-through for a playlist: prev/next + auto-advance follow
// the queue instead of the item's own episode neighbours.
type Props = { itemId: string; onClose: () => void; queue?: string[] };
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const { t } = useTranslation();
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
// through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change
// rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume
// position (losing live progress). See the detail effect's deps.
const tRef = useRef(t);
tRef.current = t;
const qc = useQueryClient();
const [id, setId] = useState(itemId);
// Serve the player's title/summary/rich detail in the active language (E5 S6), matching the grid.
const lang = plexLang();
const detailQ = useQuery({
queryKey: qk.plexItem(id, lang),
queryFn: () => api.plexItem(id, lang),
});
const detail = detailQ.data;
const duration = detail?.duration_seconds ?? 0;
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
// False once unmounted, so a late play() (from MANIFEST_PARSED / loadedmetadata firing after Back)
// can't start a detached <video> playing audio in the background.
const aliveRef = useRef(true);
const wrapRef = useRef<HTMLDivElement>(null);
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
const hwRef = useRef<boolean | null>(null);
const durationRef = useRef(0);
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
const multiAudioRef = useRef(false);
multiAudioRef.current = (detail?.audio_streams.length ?? 0) > 1;
const [playing, setPlaying] = useState(false);
const [abs, setAbs] = useState(0); // absolute current time
// Live mirror of `abs` for stable callbacks + the progress checkpoint (seek/skip/ended write it too).
const absRef = useRef(0);
absRef.current = abs;
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
const { fs, toggleFs } = useFullscreen(wrapRef);
const [ready, setReady] = useState(false);
// True while playback is stalled waiting for data (mid-stream rebuffer) or a seek is in flight — drives
// a spinner so a stall reads as "loading, hang on" instead of a frozen frame. Distinct from `ready`,
// which only covers the INITIAL session load; this covers stalls AFTER playback has started (e.g. the
// brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up).
const [buffering, setBuffering] = useState(false);
// The current session re-encodes the video (playable="transcode"). Its cold-start (the first transcode
// segment) is genuinely slower than a remux stall, so the initial-load overlay says "Preparing stream…"
// instead of the generic "Loading…" — honest, and it stops a warm-up reading as a hang.
const [sessionIsTranscode, setSessionIsTranscode] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
// this and re-fetches when a seek-restart changes it.
const [sessionK, setSessionK] = useState(0);
// Per-account persisted player prefs — survive F5, carry across items.
const [prefs, patchPrefs] = useAccountPersistedObject<PlexPlayerPrefs>(
LS.plexPlayerPrefs,
DEFAULT_PREFS,
);
const { volume, muted } = prefs;
// Refs for values read inside stable callbacks / the keyboard handler (avoid stale closures).
const barAutoHideRef = useRef(prefs.barAutoHide);
barAutoHideRef.current = prefs.barAutoHide;
const seekStepRef = useRef(prefs.seekStep);
seekStepRef.current = prefs.seekStep;
const fineSeekStepRef = useRef(prefs.fineSeekStep);
fineSeekStepRef.current = prefs.fineSeekStep;
const aoffRef = useRef(prefs.audioOffset);
aoffRef.current = prefs.audioOffset;
const [menuOpen, setMenuOpen] = useState(false); // gear = tuning (tabbed)
const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
"sync",
);
const audioRef = useRef<number | null>(null);
const menuOpenRef = useRef(false);
const menuRef = useRef<HTMLDivElement>(null);
const menuTriggerRef = useRef<HTMLButtonElement>(null);
const tracksRef = useRef<HTMLDivElement | null>(null);
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
const { uiVisible, wake } = useControlsVisibility({ barAutoHideRef, menuOpenRef, videoRef });
// Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]);
// The tracks menu is both dismiss-tracked and edge-faded, so its two refs are merged here.
// Must be stable: an inline arrow would detach (null) and re-attach the node on every render,
// and the fade's node-state ref would re-render in response — a loop. `fade.ref` is a setState.
const tracksFade = useScrollFade();
const setTracksNode = useCallback(
(node: HTMLElement | null) => {
tracksRef.current = node as HTMLDivElement | null;
tracksFade.ref(node);
},
[tracksFade.ref],
);
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
const [helpOpen, setHelpOpen] = useState(false);
const helpOpenRef = useRef(false);
helpOpenRef.current = helpOpen;
// "I" toggles a rich info overlay (poster/cast/ratings) over the running video.
const [infoOpen, setInfoOpen] = useState(false);
const infoOpenRef = useRef(false);
infoOpenRef.current = infoOpen;
// "D" toggles the stats-for-nerds debug overlay (active mode, codec, dropped frames, buffer…).
const [statsOpen, setStatsOpen] = useState(false);
const statsOpenRef = useRef(false);
statsOpenRef.current = statsOpen;
// Up-next end-card: shown at episode end (with a next item) instead of an instant jump (S4).
const [upNextOpen, setUpNextOpen] = useState(false);
const upNextOpenRef = useRef(false);
upNextOpenRef.current = upNextOpen;
const upNextPrefRef = useRef(prefs.upNext);
upNextPrefRef.current = prefs.upNext;
const now = useWallClock();
durationRef.current = duration;
// Track selection is (re)derived from the saved language prefs when each item's detail arrives
// (see the detail effect below) — nothing to reset on id change.
// --- media setup: (re)load a session for `id` at a start offset -------------------------------
const loadSession = useCallback(
async (startAt: number, resumePlay = true) => {
const video = videoRef.current;
if (!video) return;
setReady(false);
setBuffering(false);
setLoadError(null);
// Clear the prior item's transcode flag up front so the cold-start overlay can't show a stale
// "Preparing stream…" for a not-yet-known session while the plexSession request is in flight —
// it's re-set honestly from the response below.
setSessionIsTranscode(false);
let sess;
try {
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
// selected client-side after the manifest loads.
const m = multiAudioRef.current;
sess = await api.plexSession(id, startAt, m ? null : audioRef.current, aoffRef.current, m);
} catch (e) {
// Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
// needs full transcoding (a later phase).
const status = e instanceof HttpError ? e.status : undefined;
setLoadError(
status === 501
? tRef.current("plex.player.errTranscode")
: status === 404
? tRef.current("plex.player.errNotFound")
: tRef.current("plex.player.errGeneric"),
);
return;
}
// Surface the active mode for the stats overlay + the honest cold-start label: a transcode session
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
const isTranscode = sess.mode === "hls" && !!sess.transcode;
hwRef.current = isTranscode ? (sess.hw ?? false) : null;
setSessionIsTranscode(isTranscode);
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
// content-accurate. Direct-play (start_s 0) uses the file's real clock — no compensation.
const trueStart =
sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s;
sessionStartRef.current = trueStart;
setSessionK(trueStart);
// Belt-and-suspenders resume: the play() fired on MANIFEST_PARSED / loadedmetadata can be
// rejected when the media isn't ready yet (slow first segments over a proxy), which would leave
// the video paused after a seek even though the user was watching. Retry once the element can
// actually play. Idempotent — only acts if still paused.
if (resumePlay) {
const onCanPlay = () => {
if (aliveRef.current && videoRef.current?.paused) videoRef.current.play().catch(() => {});
};
video.addEventListener("canplay", onCanPlay, { once: true });
}
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
if (sess.mode === "hls" && Hls.isSupported()) {
const hls = new Hls({ maxBufferLength: 30, enableWorker: true });
hlsRef.current = hls;
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
hls.attachMedia(video);
// Subtitles are native <track>s (not muxed into the HLS), so nothing subtitle-related here.
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setReady(true);
if (aliveRef.current && resumePlay) video.play().catch(() => {});
});
// Restore the selected audio track ONCE hls.js has parsed the renditions — doing it on
// MANIFEST_PARSED was too early (the track list wasn't populated, so the set was dropped and
// playback stayed on the default track while the UI showed the restored selection).
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
if (multiAudioRef.current) {
const want = audioRef.current ?? 0;
if (hls.audioTrack !== want) {
try {
hls.audioTrack = want;
} catch {
/* out of range — ignore */
}
}
}
});
// A fatal HLS error (e.g. the remux session died / the file became unreadable) would
// otherwise leave the spinner up forever — surface it instead.
hls.on(Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setLoadError(tRef.current("plex.player.errGeneric"));
});
} else {
// direct file (or Safari native HLS)
video.src = sess.url;
video.load();
const onMeta = () => {
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
setReady(true);
if (aliveRef.current && resumePlay) video.play().catch(() => {});
video.removeEventListener("loadedmetadata", onMeta);
};
video.addEventListener("loadedmetadata", onMeta);
}
},
[id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
);
// Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state; the shared
// media refs stay component-owned (loadSession above + the detail effect below write audioRef too).
const {
audioOrd,
setAudioOrd,
subOrd,
setSubOrd,
changeAudio,
changeSubtitle,
cycleAudio,
cycleSubtitle,
changeAudioOffset,
textSubs,
selectedSub,
subTrackOffset,
} = usePlexTracks({
id,
detail,
sessionK,
subOffset: prefs.subOffset,
subPos: prefs.subPos,
loadSession,
patchPrefs,
videoRef,
hlsRef,
multiAudioRef,
audioRef,
aoffRef,
absRef,
});
// Start playback when the detail arrives (resume from the saved position unless already watched).
useEffect(() => {
if (!detail) return;
// Restore the per-account audio/subtitle language choice, resolved to THIS item's tracks. Set the
// audio ref synchronously so the loadSession below picks the right track on the first request.
const aOrd = audioOrdForLang(detail.audio_streams ?? [], prefs.audioLang);
audioRef.current = aOrd;
setAudioOrd(aOrd);
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], prefs.subLang));
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
loadSession(resume);
return () => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
};
// Prefs are applied at item load only; changing one via the picker must NOT re-run this (it would
// double-load the session — the picker handlers already reload/toggle).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detail, loadSession]);
// Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
// play() can't leave audio playing on a detached element after leaving the player.
useEffect(() => {
return () => {
aliveRef.current = false;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
const v = videoRef.current;
if (v) {
try {
v.pause();
v.removeAttribute("src");
v.load();
} catch {
/* ignore */
}
}
};
}, []);
// --- time tracking + progress checkpoints ----------------------------------------------------
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onTime = () => {
const a = sessionStartRef.current + video.currentTime;
setAbs(a);
try {
const end = video.seekable.length ? sessionStartRef.current + video.seekable.end(0) : a;
setSeekableEnd(end);
} catch {
/* ignore */
}
};
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
// A seek that restarts the hls.js session detaches/re-attaches the media, firing 'emptied' —
// which silently flips paused=true WITHOUT a 'pause' event, so the play/pause pair alone let the
// button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events
// so the icon can never contradict the video.
const syncPlaying = () => setPlaying(!video.paused);
// Buffering spinner: `waiting`/`stalled` = playback stopped for lack of data; `seeking` = a seek is
// in flight. Cleared once data flows again (`playing`/`canplay`/`seeked`) or the user pauses (a
// deliberate pause isn't a stall, and would otherwise leave the spinner stuck).
const onWaiting = () => setBuffering(true);
const onResumed = () => setBuffering(false);
video.addEventListener("timeupdate", onTime);
video.addEventListener("play", onPlay);
video.addEventListener("pause", onPause);
video.addEventListener("emptied", syncPlaying);
video.addEventListener("canplay", syncPlaying);
video.addEventListener("seeked", syncPlaying);
video.addEventListener("waiting", onWaiting);
video.addEventListener("stalled", onWaiting);
video.addEventListener("seeking", onWaiting);
video.addEventListener("playing", onResumed);
video.addEventListener("canplay", onResumed);
video.addEventListener("seeked", onResumed);
video.addEventListener("pause", onResumed);
return () => {
video.removeEventListener("timeupdate", onTime);
video.removeEventListener("play", onPlay);
video.removeEventListener("pause", onPause);
video.removeEventListener("emptied", syncPlaying);
video.removeEventListener("canplay", syncPlaying);
video.removeEventListener("seeked", syncPlaying);
video.removeEventListener("waiting", onWaiting);
video.removeEventListener("stalled", onWaiting);
video.removeEventListener("seeking", onWaiting);
video.removeEventListener("playing", onResumed);
video.removeEventListener("canplay", onResumed);
video.removeEventListener("seeked", onResumed);
video.removeEventListener("pause", onResumed);
};
}, [id]);
// Periodic + on-unmount progress checkpoint (interval flush + pause/pagehide + reopen cache patch).
usePlexProgress({ id, qc, videoRef, absRef, durationRef });
// --- seeking (native within the loaded region, else restart the session) ---------------------
const seekTo = useCallback(
(target: number) => {
const video = videoRef.current;
if (!video) return;
const wasPlaying = !video.paused; // preserve play/pause intent across the seek
const dur = durationRef.current || target;
const T = Math.max(0, Math.min(target, dur - 1));
absRef.current = T; // reflect the target immediately so a save/beacon right after a seek is accurate
const rel = T - sessionStartRef.current;
if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) {
video.currentTime = rel; // native seek within the loaded region keeps playing on its own
} else {
loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
}
},
[loadSession],
);
// --- controls --------------------------------------------------------------------------------
const togglePlay = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (v.paused) v.play().catch(() => {});
else v.pause();
}, []);
// Volume/mute for the HTML5 <video> (0..1, independent mute), persisted + re-applied per session.
const { setVol, nudgeVolume, toggleMute, onWheel } = usePlayerVolume({
videoRef,
volume,
muted,
ready,
patchPrefs,
infoOpenRef,
helpOpenRef,
});
const go = useCallback((otherId: string | null | undefined) => {
if (otherId) {
// Stop the outgoing media BEFORE switching. If the next item fails to load (e.g. no local
// file), loadSession returns without replacing the <video> source, leaving the old episode
// still playing — it would then fire 'ended' against the NEW id and mark the wrong item
// watched (and scrobble it to Plex). Pausing here severs that stale-tail race.
try {
videoRef.current?.pause();
} catch {
/* ignore */
}
setAbs(0);
setId(otherId);
}
}, []);
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
const qIdx = queue ? queue.indexOf(id) : -1;
const prevId = queue ? (qIdx > 0 ? queue[qIdx - 1] : null) : detail?.prev_id;
const nextId = queue
? qIdx >= 0 && qIdx < queue.length - 1
? queue[qIdx + 1]
: null
: detail?.next_id;
// Auto-skip intro/credits (countdown → jump to marker end / next item). The refs bridge to the
// earlier keyboard/back handlers that cancel a running countdown.
const { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip } = useAutoSkip({
id,
abs,
markers: detail?.markers,
autoSkipIntro: prefs.autoSkipIntro,
autoSkipCredits: prefs.autoSkipCredits,
autoSkipDelay: prefs.autoSkipDelay,
nextId,
go,
seekTo,
videoRef,
});
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
const download = useCallback(() => {
const a = document.createElement("a");
a.href = api.plexDownloadUrl(id);
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
}, [id]);
const settingsTabs = [
{ key: "sync" as const, label: t("plex.player.sync") },
{ key: "playback" as const, label: t("plex.player.playback") },
...(textSubs.length > 0
? [{ key: "subtitle" as const, label: t("plex.player.subStyle") }]
: []),
{ key: "clock" as const, label: t("plex.player.clock") },
];
const activeTab = settingsTabs.some((x) => x.key === settingsTab) ? settingsTab : "sync";
// Auto-advance to the next episode when one finishes.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onEnded = () => {
// Only a genuine play-to-the-end counts. A next episode that failed to load never played
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
if (absRef.current <= 0) return;
api.plexSetState(id, "watched").catch(() => {});
// With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel)
// and let IT advance — binge stays in the viewer's control (S4/D6-A). The card only appears here,
// on play-to-the-end; credits-auto-skip advances earlier and this handler never fires for it.
if (nextId && upNextPrefRef.current) setUpNextOpen(true);
else if (nextId) go(nextId);
// Nothing queued next → dismiss the player back to the previous page (same as Stop/Escape),
// instead of stranding a black, ended <video> on screen.
else onClose();
};
video.addEventListener("ended", onEnded);
return () => video.removeEventListener("ended", onEnded);
}, [id, nextId, go, onClose]);
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
const advanceUpNext = useCallback(() => {
setUpNextOpen(false);
if (nextId) go(nextId);
}, [nextId, go]);
useEffect(() => setUpNextOpen(false), [id]);
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
// off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
// with the info/shortcuts panel open jumped straight to the feed instead of just closing the panel.
const handleBack = useCallback(() => {
if (menuOpenRef.current) {
setMenuOpen(false);
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
else onClose();
}, [onClose]);
// The mouse "back" button (and the browser Back) fire history popstate — which would pop the whole
// player subview and drop straight to the grid, even with a panel or an auto-skip countdown up.
// While any of those is showing we register a history entry (BackClose below) whose Back handler
// runs this: cancel the countdown, else close the topmost panel — matching ⌫ / Esc exactly.
const backIntent = useCallback(() => {
if (skipProgressRef.current != null) {
cancelAutoSkipRef.current();
return;
}
handleBack();
}, [handleBack, skipProgressRef, cancelAutoSkipRef]);
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
const layerIdRef = useRef<number | null>(null);
useEffect(() => {
const id = pushLayer();
layerIdRef.current = id;
return () => {
removeLayer(id);
layerIdRef.current = null;
};
}, []);
// --- keyboard --------------------------------------------------------------------------------
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
wake(); // keyboard use also reveals the controls
switch (e.key) {
case " ":
// Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
// playback when the focus isn't on a control — matching PlayerModal's guard.
if (tag === "BUTTON" || tag === "A") return;
e.preventDefault();
togglePlay();
break;
case "k":
e.preventDefault();
togglePlay();
break;
case "f":
toggleFs();
break;
case "ArrowLeft":
e.preventDefault();
if (e.shiftKey) go(prevId);
else seekTo(absRef.current - (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
break;
case "ArrowRight":
e.preventDefault();
if (e.shiftKey) go(nextId);
else seekTo(absRef.current + (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
break;
case "ArrowUp":
e.preventDefault();
nudgeVolume(0.05);
break;
case "ArrowDown":
e.preventDefault();
nudgeVolume(-0.05);
break;
case "m":
toggleMute();
break;
case "a":
case "A":
cycleAudio();
break;
case "s":
case "S":
cycleSubtitle();
break;
case "h":
case "H":
setHelpOpen((v) => !v);
break;
case "i":
case "I":
setInfoOpen((v) => !v);
break;
case "d":
case "D":
setStatsOpen((v) => !v);
break;
case "Backspace":
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
e.preventDefault();
if (skipProgressRef.current != null)
cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
else handleBack();
break;
case "Escape":
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the
// consumed-event flag first (UX-8): a layer's `document` listener runs before this `window`
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
if (escapeHandled(e)) break;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (menuOpenRef.current) {
setMenuOpen(false);
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
else if (!fullscreenElement()) onClose();
break;
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [
togglePlay,
toggleFs,
seekTo,
go,
nudgeVolume,
toggleMute,
cycleAudio,
cycleSubtitle,
detail,
prevId,
nextId,
onClose,
wake,
handleBack,
skipProgressRef,
cancelAutoSkipRef,
]);
// Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
// (0..1), not pixels: the bar sits inside the transform:scale(1.25) subtree, so a px `left` would be
// re-scaled and drift right of the cursor (only 0 stayed correct). A % `left` is relative to the
// bar's own width and scales cleanly.
const [hover, setHover] = useState<{ frac: number; t: number } | null>(null);
// Seekbar scrubbing: the absolute time under the dragging finger. While non-null the head/thumb
// preview this position; the real seek fires once on release (avoids restarting the session on
// every out-of-buffer step of the drag). A plain click is a down+up at one spot → seeks there.
const [scrub, setScrub] = useState<number | null>(null);
// Volume bar: a 0–100 value tooltip under the cursor (mirrors the seekbar), plus click/drag to set.
const [volHover, setVolHover] = useState<number | null>(null); // 0..1 under the cursor
const volBarRef = useRef<HTMLDivElement>(null);
const volAt = useCallback((clientX: number) => {
const el = volBarRef.current;
if (!el) return 0;
const r = el.getBoundingClientRect();
return clamp01((clientX - r.left) / r.width);
}, []);
const pct = duration > 0 ? (abs / duration) * 100 : 0;
const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;
// While scrubbing the head follows the finger (preview); otherwise it tracks playback.
const headPct = scrub != null && duration > 0 ? (scrub / duration) * 100 : pct;
// Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal, ChatDock,
// BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the
// moment that scroller gets a mask/filter/transform/contain it becomes a stacking context, and
// this z-overlay is then scoped inside it — the rail, the side panel and the header paint straight
// over the player. That is exactly what the scroller's edge-fade did before this moved out.
return createPortal(
<div
ref={wrapRef}
className="fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none"
onMouseMove={wake}
onClick={wake}
onWheel={onWheel}
// Windowed (pseudo-fullscreen): everything is 25% larger for lean-back / HTPC legibility. Laid
// out at 80vw×80vh and scaled ×1.25 from the top-left so it fills the viewport exactly (no
// overflow, controls stay on-screen). We use `transform: scale` rather than `zoom` deliberately:
// under `zoom`, event clientX stays in CSS pixels while getBoundingClientRect returns zoomed
// pixels, so the seek/volume/hover math (clientX − rect.left)/rect.width breaks; with `transform`
// both are in the same viewport space, so that ratio stays correct. Skip buttons ride this too.
//
// In NATIVE fullscreen we must NOT carry the ×1.25 transform: with the element at 80vw×80vh, the
// scaled layer is composited letterboxed and the video never grows past its windowed size (the UA
// does NOT force this fixed, explicitly-sized element to fill). So in `fs` we size the wrapper to
// the full screen (100vw×100vh) with NO transform — the flex box then centres the video and its
// `max-h-full`/`max-w-full` aspect-fit fills the available height. The % seek-bar left and the
// clientX/rect math stay correct under the identity transform.
style={{
cursor: uiVisible ? "default" : "none",
...(fs
? { width: "100vw", height: "100vh" }
: {
width: "80vw",
height: "80vh",
transform: "scale(1.25)",
transformOrigin: "top left",
}),
}}
>
{/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this
player owns the only <video> on screen. */}
<style>{`video::cue{font-size:${prefs.subSize}%;color:${prefs.subColor};background-color:${prefs.subBg};${subShadowCss(
prefs.subShadow,
prefs.subShadowColor,
)}}`}</style>
<video
ref={videoRef}
// Fill the wrapper (which is sized to the viewport / screen) and letterbox by aspect via
// object-contain. NOT `w-auto h-auto max-*`: that caps the video at its intrinsic resolution
// (a 720p stream → 1280×720) and never scales it UP, so in fullscreen it sat tiny and centred.
className="w-full h-full object-contain"
onClick={togglePlay}
playsInline
>
{/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
above flips modes; the browser fetches a track's VTT the first time it's shown. */}
{selectedSub && (
<track
// One track = the selected sub only (no multi-track overlap). Key by the shifted-offset so
// a seek-restart or a (debounced) sub-offset change remounts → fresh VTT fetch.
key={`${id}-${selectedSub.ord}-${subTrackOffset.toFixed(2)}`}
kind="subtitles"
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset)}
srcLang={selectedSub.language ?? undefined}
label={selectedSub.label}
default
/>
)}
</video>
{/* Loading / buffering / error overlay. `!ready` = initial session load; `buffering` = a
mid-playback stall or in-flight seek (see the state); `loadError` surfaces a fatal failure so
it can't hide behind a running video. Spinner on load/stall, error text otherwise. */}
{(!ready || buffering || loadError) && (
<div
role="status"
aria-live="polite"
className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none"
>
{loadError ? (
<span className="max-w-md text-danger">{loadError}</span>
) : (
<div className="flex flex-col items-center gap-3 text-white/80">
<Loader2 className="w-10 h-10 animate-spin" aria-hidden />
{/* Announce the stall to assistive tech. Initial load shows visible text; a mid-playback
rebuffer/seek (ready, no error) has no visible label, so give it an sr-only one via the
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
{!ready ? (
<span>
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
</span>
) : buffering ? (
<span className="sr-only">{t("plex.player.buffering")}</span>
) : null}
</div>
)}
</div>
)}
{/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
{(menuOpen ||
tracksOpen ||
helpOpen ||
infoOpen ||
statsOpen ||
upNextOpen ||
skipProgress != null) && <BackClose onBack={backIntent} />}
{/* Top bar */}
<div
className={`absolute top-0 inset-x-0 p-4 bg-linear-to-b from-black/70 to-transparent transition-opacity ${
uiVisible ? "opacity-100" : "opacity-0"
}`}
>
<div className="flex items-center gap-3 text-white">
<button
onClick={handleBack}
className="p-1.5 rounded-lg hover:bg-white/15"
title={t("plex.player.back")}
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="min-w-0">
<div className="font-semibold truncate">
{detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title}
</div>
{detail?.kind === "episode" && (
<div className="text-xs text-white/70 truncate">
S{detail.season_number}·E{detail.episode_number} — {detail.title}
</div>
)}
</div>
{/* Live wall clock — handy on a lean-back / HTPC screen (toggle + style in the Clock tab). */}
{prefs.clockShow &&
(() => {
const c = clockParts(now, prefs);
return (
<div
className="ml-auto flex items-center gap-1.5 tabular-nums shrink-0"
style={{ color: prefs.clockColor, fontSize: `${prefs.clockSize}%` }}
>
<Clock className="h-4 w-4 opacity-70" />
<span>
{c.time}
{c.date ? ` · ${c.date}` : ""}
</span>
</div>
);
})()}
</div>
</div>
{/* Skip intro / credits (with the auto-skip countdown fill, if armed) */}
{activeMarker && (
<button
onClick={() => doSkip(activeMarker)}
className={`absolute right-6 bottom-28 overflow-hidden rounded-lg bg-white/90 px-4 py-2 text-sm font-medium text-black transition hover:bg-white ${
uiVisible ? "opacity-100" : "opacity-80"
}`}
>
{skipProgress != null && (
<>
{/* draining fill across the whole button… */}
<span
className="absolute inset-y-0 left-0 bg-accent/50"
style={{ width: `${skipProgress * 100}%` }}
/>
{/* …plus a crisp bottom bar so the countdown is unmistakable */}
<span
className="absolute bottom-0 left-0 h-1 bg-accent"
style={{ width: `${skipProgress * 100}%` }}
/>
</>
)}
<span className="relative">
{activeMarker.type === "intro"
? t("plex.player.skipIntro")
: t("plex.player.skipCredits")}
</span>
</button>
)}
{/* Bottom controls */}
<div
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
{/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on
release so a long drag doesn't restart the stream on every step. */}
<div
className="relative h-1.5 rounded-full bg-white/25 cursor-pointer group mb-2"
onPointerDown={(e) => {
const el = e.currentTarget as HTMLElement;
try {
el.setPointerCapture(e.pointerId);
} catch {
/* capture unavailable — dragging still works via buttons state */
}
const r = el.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - r.left, r.width));
setScrub((x / r.width) * duration);
}}
onPointerMove={(e) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
const frac = Math.max(0, Math.min((e.clientX - r.left) / r.width, 1));
const tt = frac * duration;
setHover({ frac, t: tt });
if (e.buttons & 1) setScrub(tt); // dragging with the primary button held
}}
onPointerUp={() => {
if (scrub != null) {
seekTo(scrub);
setScrub(null);
}
}}
onMouseLeave={() => setHover(null)}
>
{/* Hover timestamp tooltip — follows the cursor along the bar. */}
{hover && duration > 0 && (
<div
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white"
style={{ left: `${hover.frac * 100}%` }}
>
{fmt(hover.t)}
</div>
)}
<div
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
style={{ width: `${bufPct}%` }}
/>
{/* intro/credit marker regions */}
{detail?.markers.map((m, i) => (
<div
key={i}
className="absolute inset-y-0 bg-warn/50"
style={{
left: `${(m.start_s / duration) * 100}%`,
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
}}
/>
))}
<div
className="absolute inset-y-0 left-0 bg-accent rounded-full"
style={{ width: `${headPct}%` }}
/>
<div
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
scrub != null ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
style={{ left: `${headPct}%` }}
/>
</div>
<div className="flex items-center gap-3 text-white">
<Ctrl
label={t("plex.player.rewind10")}
onClick={() => seekTo(absRef.current - 10)}
align="start"
>
<Skip10 dir="back" />
</Ctrl>
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</Ctrl>
<Ctrl label={t("plex.player.forward10")} onClick={() => seekTo(absRef.current + 10)}>
<Skip10 dir="forward" />
</Ctrl>
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
<RotateCcw className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.stop")} onClick={onClose}>
<Square className="w-5 h-5" />
</Ctrl>
{(detail?.kind === "episode" || queue) && (
<>
<Ctrl label={t("plex.player.prev")} onClick={() => go(prevId)} disabled={!prevId}>
<SkipBack className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.next")} onClick={() => go(nextId)} disabled={!nextId}>
<SkipForward className="w-5 h-5" />
</Ctrl>
</>
)}
<div className="flex items-center gap-2">
<Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
{muted || volume === 0 ? (
<VolumeX className="w-5 h-5" />
) : (
<Volume2 className="w-5 h-5" />
)}
</Ctrl>
{/* Volume bar: plain accent fill on a neutral track (as before); hover shows the 0–100
value under the cursor; click/drag sets it. */}
<div
ref={volBarRef}
className="relative h-1.5 w-24 cursor-pointer rounded-full bg-white/25"
onPointerDown={(e) => {
try {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
} catch {
/* capture unavailable — dragging still works via buttons state */
}
const v = volAt(e.clientX);
setVol(v, v === 0);
}}
onPointerMove={(e) => {
const v = volAt(e.clientX);
setVolHover(v);
if (e.buttons & 1) setVol(v, v === 0); // dragging with the primary button held
}}
onMouseLeave={() => setVolHover(null)}
>
{/* filled level */}
<div
className="absolute inset-y-0 left-0 rounded-full bg-accent"
style={{ width: `${(muted ? 0 : volume) * 100}%` }}
/>
{/* thumb */}
<div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent shadow-sm"
style={{ left: `${(muted ? 0 : volume) * 100}%` }}
/>
{/* hover value tooltip (0–100), like the seek bar */}
{volHover != null && (
<div
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white"
style={{ left: `${volHover * 100}%` }}
>
{Math.round(volHover * 100)}
</div>
)}
</div>
</div>
<div className="text-xs tabular-nums text-white/90">
{fmt(abs)} / {fmt(duration)}
<span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
{/* Projected finish time (wall clock now + time remaining), in the clock's own format —
pushes later while paused, since the remaining time then rides the advancing clock. */}
{duration > 0 && (
<span className="ml-1.5 text-white/45">
(
{
clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs)
.time
}
)
</span>
)}
</div>
<div className="ml-auto flex items-center gap-2">
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */}
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
<div className="relative">
<button
ref={tracksTriggerRef}
onClick={() => setTracksOpen((o) => !o)}
aria-label={t("plex.player.tracks")}
className={`p-1 hover:text-accent ${tracksOpen ? "text-accent" : ""}`}
>
<Languages className="w-5 h-5" />
</button>
{tracksOpen && (
<div
ref={setTracksNode}
style={tracksFade.style}
onClick={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl"
>
{detail.audio_streams.length > 1 && (
<>
<PanelHead>{t("plex.player.audio")}</PanelHead>
{detail.audio_streams.map((a) => (
<button
key={a.ord}
onClick={() => changeAudio(a.ord)}
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
>
{a.label}
</button>
))}
</>
)}
{textSubs.length > 0 && (
<>
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
<button
onClick={() => changeSubtitle(null)}
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
subOrd == null ? "text-accent" : "text-white"
}`}
>
{t("plex.player.subOff")}
</button>
{textSubs.map((s) => (
<button
key={s.ord}
onClick={() => changeSubtitle(s.ord)}
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
subOrd === s.ord ? "text-accent" : "text-white"
}`}
>
{s.label}
</button>
))}
</>
)}
</div>
)}
</div>
)}
{/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
{detail && (
<div className="relative">
<button
ref={menuTriggerRef}
onClick={() => setMenuOpen((o) => !o)}
aria-label={t("plex.player.settings")}
className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`}
>
<Settings className="w-5 h-5" />
</button>
{menuOpen && (
<PlexPlayerSettings
menuRef={menuRef}
settingsTabs={settingsTabs}
activeTab={activeTab}
setSettingsTab={setSettingsTab}
prefs={prefs}
patchPrefs={patchPrefs}
changeAudioOffset={changeAudioOffset}
now={now}
/>
)}
</div>
)}
<Ctrl label={t("plex.player.stats.title")} onClick={() => setStatsOpen((v) => !v)}>
<Gauge className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
<Info className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
<Keyboard className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.download")} onClick={download}>
<Download className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Ctrl>
</div>
</div>
</div>
{/* Stats-for-nerds debug overlay (toggle with "d" or the gauge button). Non-modal corner panel —
the video keeps playing and stays interactive behind it. */}
{statsOpen && (
<PlexStatsOverlay
videoRef={videoRef}
hlsRef={hlsRef}
playable={detail?.playable ?? ""}
hwRef={hwRef}
sessionStartRef={sessionStartRef}
absRef={absRef}
durationRef={durationRef}
onClose={() => setStatsOpen(false)}
/>
)}
{/* Up-next end-card (episode end, next item queued, pref on). Non-modal — the finished frame
stays behind it; the card's own countdown advances unless the viewer cancels. */}
{upNextOpen && nextId && (
<UpNextCard
nextId={nextId}
onAdvance={advanceUpNext}
onCancel={() => setUpNextOpen(false)}
/>
)}
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
{helpOpen && (
<div
className="absolute inset-0 z-menu grid place-items-center bg-black/60 p-4"
onClick={(e) => {
e.stopPropagation();
setHelpOpen(false);
}}
>
<div
className="w-[min(92vw,26rem)] rounded-2xl border border-white/15 bg-neutral-900/95 p-5 text-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 font-semibold">
<Keyboard className="w-5 h-5" /> {t("plex.player.help")}
</div>
<button
onClick={() => setHelpOpen(false)}
className="p-1 rounded-lg hover:bg-white/10"
aria-label={t("plex.player.close")}
>
<X className="w-5 h-5" />
</button>
</div>
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
{(
[
[["Space", "K"], "plex.player.keys.playPause"],
[["←", "→"], "plex.player.keys.seek"],
[["Ctrl ←", "Ctrl →"], "plex.player.keys.fineSeek"],
...(detail?.kind === "episode"
? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]]
: []),
[["↑", "↓"], "plex.player.keys.volume"],
[["M"], "plex.player.keys.mute"],
[["A"], "plex.player.keys.audio"],
[["S"], "plex.player.keys.subtitle"],
[["F"], "plex.player.keys.fullscreen"],
[["I"], "plex.player.keys.info"],
[["D"], "plex.player.keys.stats"],
[["⌫", "Esc"], "plex.player.keys.back"],
[["H"], "plex.player.keys.help"],
] as [string[], string][]
).map(([keys, labelKey]) => (
<Fragment key={labelKey}>
<dt className="flex flex-wrap gap-1">
{keys.map((k) => (
<kbd
key={k}
className="rounded-sm bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
>
{k}
</kbd>
))}
</dt>
<dd className="text-white/80">{t(labelKey)}</dd>
</Fragment>
))}
</dl>
</div>
</div>
)}
{/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
{infoOpen && detail && (
<div
className="absolute inset-0 z-menu grid place-items-center overflow-y-auto bg-black/70 p-4"
onClick={(e) => {
e.stopPropagation();
setInfoOpen(false);
}}
>
<div
className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-neutral-900/95 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<Suspense fallback={null}>
<PlexInfo detail={detail} variant="overlay" onClose={() => setInfoOpen(false)} />
</Suspense>
</div>
</div>
)}
</div>,
document.body,
);
}
// Renders nothing; while mounted it claims one history entry so the browser/mouse Back button runs
// `onBack` (close the panel / cancel the skip) instead of navigating out of the player.
function BackClose({ onBack }: { onBack: () => void }) {
useBackToClose(onBack);
return null;
}