Files
siftlode/frontend/src/components/PlexPlayer.tsx
T
peter 5ab6778b97 fix(player): a Restart of the paused film plays the new session; a key on faded bands only shows them
The same-film restart read the paused state, restarted the session without play, and resumed the
outgoing stream beside it. Space or Enter on a control the bands had faded from pressed it unseen;
now it only brings them back, and Space plays or pauses.
2026-09-27 21:56:23 +02:00

1932 lines
92 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 { 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 { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useFocusKeeper } from "../lib/useFocusKeeper";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
ArrowLeft,
Clock,
Download,
Gauge,
Info,
Keyboard,
Languages,
Loader2,
Maximize,
Minimize,
MoreHorizontal,
Pause,
Play,
Settings,
SkipBack,
SkipForward,
Volume2,
VolumeX,
} from "lucide-react";
import { api, HttpError } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import {
audioOrdForLang,
clockParts,
DEFAULT_PREFS,
fmt,
HLS_SUB_LAG,
subOrdForLang,
subShadowCss,
type PlexPlayerPrefs,
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead } from "./plexPlayerControls";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { createFragMeter } from "../lib/plexStats";
import { enabled, log } from "../lib/log";
import { logMediaElement } from "../lib/mediaLog";
import { UpNextCard } from "./UpNextCard";
import Toaster from "./ui/Toaster";
import { useOverlayOrder } from "./ui/overlayOrder";
import {
playhead,
useAutoSkip,
useControlsVisibility,
useFullscreen,
usePlayerVolume,
usePlexProgress,
usePlexTracks,
useWallClock,
} from "../lib/plexPlayerHooks";
import { fullscreenElement } from "../lib/fullscreen";
import {
dispatch,
isActivationTarget,
isTypingTarget,
toggleKeyboardHelp,
useHelpConditions,
useKeyboardHelpOpen,
useKeyScope,
usePaletteOpen,
} from "../lib/keymap";
import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { useBackToClose } from "../lib/history";
import { trapTab } from "./ui/dialogFocus";
import {
pushLayer,
removeLayer,
anyLayerAbove,
anyBlockingLayerAbove,
escapeHandled,
} from "../lib/layerStack";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { MenuCheck } from "./ui/MenuCheck";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Slider } from "./ui/NativeControls";
import { formatUnit } from "../lib/format";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
// Reclaims of a 404'd HLS session, within `RECLAIM_WINDOW_MS`, before the player stops and says so.
// Two covers the real case (the session was reaped, once) without letting a server that cannot serve
// the stream keep the viewer on a spinner (see the ERROR handler).
const MAX_SESSION_RECLAIMS = 2;
// A ROLLING window, because "consecutive" could not bound anything: the budget used to be cleared by
// the first fragment of any session, and a reclaimed session essentially always delivers one —
// `start_session` does not return until its first segment exists. So both cases the ERROR handler
// names restarted ffmpeg forever anyway (measured: 40 loss cycles → 41 sessions, never an error).
// Two losses inside two minutes is a session that keeps being lost; one an hour apart is not.
const RECLAIM_WINDOW_MS = 120_000;
// C5: Back restarts the item after this many seconds in, and steps to the previous one before it.
const BACK_TO_PREV_S = 3;
// C5: a touch double-tap on the picture's left or right third skips this far; each further tap
// inside the window skips again (the keyboard keeps ← / → at the viewer's own step).
// The player's menus sit on the always-dark media chrome, under white text, in every theme.
const PLAYER_MENU =
"rounded-xl border border-white/15 bg-media-chrome/95 text-sm text-white shadow-2xl";
const PLAYER_ROW = "block w-full rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
// A track choice: the chosen one wears the tick in the leading slot (DESIGN "one of several"), which
// the highlight's white text cannot wash out the way it did the accent-coloured label (round 2, P6).
const TRACK_ROW =
"flex w-full min-h-9 items-center gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
const TAP_SKIP_S = 10;
const DOUBLE_TAP_MS = 300;
// 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.
// `fromStart` (the card's Restart, C5) applies to the item the player opened with; every item it
// moves on to starts at its own resume point.
type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean };
export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: 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>(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);
// A control that goes while it holds the focus — Next onto the last item or a series finale —
// hands it to its neighbour instead of <body> (round 2, P5). The player is portaled outside the
// page scroller, whose keeper does not watch it.
useFocusKeeper(wrapRef);
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
// Which loadSession call is the current one. Everything after its `await` checks it: a response
// that lands after a newer call, an item change or the player's unmount is dropped — it used to
// replace whatever was playing (Next twice quickly, the slower transcode answering last, played the
// middle item under the last one's title and recorded its progress there) or attach a stream to the
// detached <video> after Back, never destroyed, keeping the server session warm.
const sessGenRef = useRef(0);
// Removes the media listeners the current session put on the <video> (its canplay / loadedmetadata
// handlers). A newer session, an item change and the unmount all call it: the generation check
// guards the `await`, and these fire LATER — a superseded direct-play item's loadedmetadata used to
// run on the next item's media and seek it to the old item's resume point (whose first checkpoint
// then saved it), or start the old media playing under the new title.
const detachSessionRef = useRef<() => void>(() => {});
// Whether the <video>'s clock belongs to the session `sessionStartRef` describes. False from the
// moment a session is asked for until it has answered: the media still attached meanwhile is the
// OLD one, so `sessionStartRef + currentTime` was the previous item's position (go()'s pause() fires
// a timeupdate AFTER the reset to 0) — sent as the new item's subtitle position and saved as its
// progress, or, during a seek-restart, the pre-seek spot overwriting the target.
const clockLiveRef = useRef(false);
// The item whose session is attached to the <video> right now (see loadSession's failure path).
const attachedIdRef = useRef<string | null>(null);
// 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);
// Whether the current HW transcode tone-mapped a 10-bit/HDR source on the GPU (OpenCL). Feeds the
// stats mode row's "· HDR→SDR" tag; null/false for an 8-bit HW, software, or non-transcode session.
const tonemapRef = useRef<boolean | null>(null);
const durationRef = useRef(0);
// Rolling per-fragment bitrate meter for the stats overlay. Fed from hls.js FRAG_LOADED below and
// reset with each session, because the master playlist's own BANDWIDTH is not a measurement — for a
// remux it is the AAC track's nominal 192k + 10%, i.e. a constant 211 kbps (see plexStats.ts).
const fragMeterRef = useRef(createFragMeter());
// ≥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;
// Where the current session was ASKED to start — set before the request goes out, so the subtitle
// poll has a position before `sessionK` (the session's answer) and before the first timeupdate.
const loadStartRef = useRef(0);
// How often this player may reclaim a session the server answered 404 for, before it stops and
// says playback failed. `at` is when the window started; the item-start effect zeroes it, so a
// budget spent on one episode cannot deny the next one its first legitimate reclaim.
const restartsRef = useRef({ n: 0, at: 0 });
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;
// The two language prefs are read only at ITEM LOAD (the detail effect below), so they go through
// a ref for the same reason `tRef` above does: naming them in that effect's deps would re-run it
// when the user picks a track from the player's own menu, double-loading the session the picker
// has already reloaded. The ref says "latest value, but not a trigger" in code, which an excused
// dep list only said in prose. Same shape as barAutoHideRef/seekStepRef either side of this.
const langPrefsRef = useRef({ audioLang: prefs.audioLang, subLang: prefs.subLang });
langPrefsRef.current = { audioLang: prefs.audioLang, subLang: prefs.subLang };
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); // the tuning panel (tabbed), from ⋯ → Settings
// The fixed ⋯ (C5): the rare tools — settings, media info, stats, shortcuts, the original file.
const [moreOpen, setMoreOpen] = useState(false);
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>(null);
menuOpenRef.current = menuOpen || tracksOpen || moreOpen; // keep the control bar up while one is open
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
const {
uiVisible,
controlsShown,
wake,
holdOnHover,
holdOnFocus,
toggle: toggleControls,
show: showControls,
} = useControlsVisibility({
barAutoHideRef,
menuOpenRef,
videoRef,
});
// The pointer resting on either band holds the controls up (F22), and so does a keyboard focus in
// one (E6) — not the focus a click leaves on a button, which would pin the bar until the next click;
// `:focus-visible` is the browser's own "this focus came from the keyboard". Both bands wear these.
const holdWhileHovered = {
"data-player-band": "", // what a key on a faded-out control is told apart by (onKey, below)
onPointerEnter: () => holdOnHover(true),
onPointerLeave: () => holdOnHover(false),
onFocus: (e: React.FocusEvent<HTMLElement>) => holdOnFocus(e.target.matches(":focus-visible")),
onBlur: (e: React.FocusEvent<HTMLElement>) => {
if (!e.currentTarget.contains(e.relatedTarget)) holdOnFocus(false);
},
};
// The tuning panel stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
// It renders inside the player, whose Tab trap spans it; the ⋯ counts as inside, since the ⋯ is
// what closes it (below).
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
// The ⋯ and the tracks list are real menus on the popover engine (E28 review D3): roving arrows,
// Home/End, Escape as a layer, and the focus back on the trigger however they close. They were
// hand-built <div>s — no roles, no arrows, and Escape left the focus on <body>. The engine portals
// them into the fullscreen element when there is one, so they show in fullscreen too.
const morePop = useAnchoredPopover({
open: moreOpen,
// The ⋯ also CLOSES the tuning panel it opened, rather than stacking the menu over it.
onOpenChange: (open) => {
if (open && menuOpen) setMenuOpen(false);
else setMoreOpen(open);
},
placement: "top-end",
offset: 8,
// ↑/↓ are the volume wherever the focus is in the player, the ⋯ included.
openOnArrowKey: false,
});
const tracksPop = useAnchoredPopover({
open: tracksOpen,
onOpenChange: setTracksOpen,
placement: "top-end",
offset: 8,
fitHeight: true,
openOnArrowKey: false,
});
const tracksFade = useScrollFade();
// The ⋯ is both the menu's trigger and the tuning panel's dismiss exception and focus return.
const setMoreReference = morePop.refs.setReference;
const moreTriggerRef = useCallback(
(node: HTMLButtonElement | null) => {
menuTriggerRef.current = node;
setMoreReference(node);
},
[setMoreReference],
);
// The keyboard cheat sheet is app-wide now (ui/KeyboardHelp, opened by the global "h"), not this
// player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to
// know an overlay is up before it steals a wheel scroll for volume.
const helpOpen = useKeyboardHelpOpen();
const helpOpenRef = useRef(false);
helpOpenRef.current = helpOpen;
// Read for the Backspace guard below: these two overlays open OVER the player and own the key.
const paletteOpenRef = useRef(false);
paletteOpenRef.current = usePaletteOpen();
// On-screen confirmation for a KEYBOARD track switch. A/S have cycled audio and subtitles since
// E5, but silently — you changed the track and the only way to see what you landed on was to open
// the tracks menu, which is exactly the modal round-trip the shortcut exists to avoid. The menu
// itself needs no OSD: it shows the selection where you clicked.
const [osd, setOsd] = useState<string | null>(null);
const osdTimer = useRef<number | null>(null);
const flashOsd = useCallback((text: string) => {
setOsd(text);
if (osdTimer.current) window.clearTimeout(osdTimer.current);
osdTimer.current = window.setTimeout(() => setOsd(null), 2200);
}, []);
useEffect(
() => () => {
if (osdTimer.current) window.clearTimeout(osdTimer.current);
},
[],
);
// "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;
const gen = ++sessGenRef.current;
const current = () => gen === sessGenRef.current && aliveRef.current;
detachSessionRef.current();
detachSessionRef.current = () => {};
const listen = (type: string, fn: () => void, once = false) => {
const guarded = () => {
if (current()) fn();
};
video.addEventListener(type, guarded, { once });
const prev = detachSessionRef.current;
detachSessionRef.current = () => {
prev();
video.removeEventListener(type, guarded);
};
};
clockLiveRef.current = false;
loadStartRef.current = startAt;
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) {
if (!current()) return; // superseded or left: not this request's error to show
// A failed seek-restart leaves the old session attached and its clock still true; a failed
// move to another item leaves the previous item's media, which is not this item's clock.
clockLiveRef.current = attachedIdRef.current === id;
// 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;
}
if (!current()) 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;
tonemapRef.current = isTranscode ? (sess.tonemap ?? 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);
// From here the <video>'s clock is this session's: the old source is replaced just below, and a
// timeupdate its teardown queues already reads the new start.
clockLiveRef.current = true;
attachedIdRef.current = id;
// 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) {
listen(
"canplay",
() => {
if (videoRef.current?.paused) videoRef.current.play().catch(() => {});
},
true,
);
}
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
if (sess.mode === "hls" && Hls.isSupported()) {
// startPosition: 0 — our HLS is a growing EVENT playlist with no ENDLIST (see _HLS_TAIL in
// stream.py), which hls.js classifies as LIVE. Its default (startPosition: -1) then starts
// playback at the live edge and hugs it, so it never builds a forward buffer (rebuffer
// spinners), and on a resume/seek session the segments' absolute PTS (-copyts, offset by the
// server's -ss) don't line up with the live-edge timeline → 0 fragments load → permanent
// stall. But the seek-restart model means fragment 0 IS the requested content position
// (`?t=` positioned the server ffmpeg there), so we always want to start VOD-style at 0 and
// buffer forward up to maxBufferLength. Applies to remux + transcode alike (both use copyts).
const hls = new Hls({ maxBufferLength: 30, enableWorker: true, startPosition: 0 });
hlsRef.current = hls;
// Measure the real delivered bitrate: bytes per second of MEDIA, per fragment. A new session
// is a new stream (different start offset, possibly a different audio rendition), so the
// rolling window starts empty rather than carrying the previous session's numbers over.
fragMeterRef.current.reset();
hls.on(Hls.Events.FRAG_LOADED, (_e, data) => {
fragMeterRef.current.add({
type: data.frag.type,
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
durationS: data.frag.duration,
});
});
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.
// Whether this session has ever played. A reclaim of one that has not (below) keeps the
// start's own `resumePlay`: its <video> is still paused, and that is not the viewer's pause.
let parsed = false;
hls.on(Hls.Events.MANIFEST_PARSED, () => {
parsed = true;
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.
//
// Except a 404: the session is gone, not broken. Either the server reaped it (a pause longer
// than its idle limit, a deploy) — hls.js never retries a 4xx, so this used to end playback
// with "couldn't play" after the buffer ran out, and a new session at the playhead is what
// closing and reopening the player did by hand — or it was superseded before it ever played:
// two requests for one item in flight at once (a seek twice in quick succession; StrictMode
// replaying the start effect in development) and the server's LAST spawn wins, which may be
// the older request's. The newer request's answer then names a session that is already
// gone, and its playlist 404s before a manifest parsed — "couldn't start" on a file that
// plays (Arrow S03E14, 2026-09-27). The server answers a superseded session exactly so the
// client asks again; the reclaim is that asking.
//
// Bounded by the budget below, not by whether the session parsed: `parsed` belongs to ONE hls
// instance and every restart builds a fresh one, so it could never bound a loop. The
// server also answers 404 for "that segment is not there YET" — indistinguishable from "the
// session is gone" on the wire — so a transcode that falls behind realtime, or two viewers of
// one item taking turns reclaiming its session, restarted ffmpeg forever behind a spinner
// that never became an error (deep-review round 5). A couple of reclaims is what a genuine
// reap needs; past that the honest answer is that playback failed.
hls.on(Hls.Events.ERROR, (_e, data) => {
// Fatal ones are what the user saw fail; the recoverable ones hls.js retries by itself
// only matter to a detailed diagnosis.
if (data.fatal || enabled("DEBUG")) {
log.at(
data.fatal ? "WARNING" : "DEBUG",
"player.hls_error",
`plex hls ${data.details}`,
{
player: "plex",
fatal: data.fatal,
type: data.type,
details: data.details,
status: data.response?.code ?? null,
},
);
}
if (!data.fatal || !current()) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && data.response?.code === 404) {
const now = Date.now();
// A new window: the last loss was long enough ago that this is a fresh incident, not the
// same session being lost over and over. The anchor moves with EVERY loss, because with
// it left at the window's start the window tumbled instead of rolling: any loss period
// longer than half the window satisfied the reset on every second loss, so the budget
// never reached its bound and the reclaim loop it exists to end ran forever (measured:
// 200 losses 70s apart → 200 reclaims, never an error; 5s apart → 2 and an error, which
// is why the committed test, spacing its cases 0s and 10min apart, could not see it —
// deep-review round 7).
if (now - restartsRef.current.at > RECLAIM_WINDOW_MS) restartsRef.current.n = 0;
restartsRef.current.at = now;
if (restartsRef.current.n < MAX_SESSION_RECLAIMS) {
restartsRef.current.n += 1;
// Through the ref, not by name: this is loadSession calling ITSELF from inside its own
// useCallback, which the checker rightly reads as "accessed before it is declared".
// The ref (assigned right below the callback) is the file's existing answer to that —
// detachSessionRef and cancelAutoSkipRef do the same for the same reason.
void loadSessionRef.current(
playhead(absRef, loadStartRef),
parsed ? !video.paused : resumePlay,
);
return;
}
}
setLoadError(tRef.current("plex.player.errGeneric"));
});
} else {
// direct file (or Safari native HLS)
video.src = sess.url;
video.load();
listen(
"loadedmetadata",
() => {
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
setReady(true);
if (resumePlay) video.play().catch(() => {});
},
true,
);
}
},
[id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
);
// The self-restart path above reaches the CURRENT loadSession through this, so the recursion is
// not a forward reference to a binding that is still being defined.
const loadSessionRef = useRef(loadSession);
// eslint-disable-next-line react-hooks/immutability -- the latest-value ref the self-restart path above reads; see the comment there for why the recursion cannot name loadSession directly.
loadSessionRef.current = loadSession;
// 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,
subShowable,
subNotice,
subGen,
subTrackKey,
subTrackListeners,
} = usePlexTracks({
id,
detail,
sessionK,
subOffset: prefs.subOffset,
subPos: prefs.subPos,
loadSession,
patchPrefs,
videoRef,
hlsRef,
multiAudioRef,
audioRef,
aoffRef,
absRef,
loadStartRef,
});
// Start playback when the detail arrives (resume from the saved position unless already watched) —
// ONCE per item. The detail object changes under a playing item too: react-query refetches it on
// reconnect (Wi-Fi blips, the query is stale after 30 s and every checkpoint moved the position),
// the in-player info panel invalidates it after a watched toggle, a language switch fetches it
// again. Keyed on its identity, each of those restarted the film at the last saved checkpoint — or
// at 0 after marking it watched (deep-review round 4). The trade-off: a detail that was stale in
// the cache when the player opened is not corrected by the refetch that follows; the player's own
// leave-patch (usePlexProgress) keeps the cache honest for everything played here.
const startedForRef = useRef<string | null>(null);
// Raised by the first `go()`: `fromStart` belongs to the item the player opened on, and coming BACK
// to that item (Back within 3 s of the next) resumes it like any other. A flag, not a consumed
// prop: StrictMode replays this effect, and a consumed one resumed on the replay.
const movedOnRef = useRef(false);
// An item `go` was asked to play from its start (a collection sibling's Restart in the info
// overlay); the opening item's own `fromStart` is the prop above.
const startAtZeroRef = useRef<string | null>(null);
useEffect(() => {
if (!detail || detail.id !== id || startedForRef.current === id) return;
startedForRef.current = id;
// A fresh item is a fresh incident: the reclaim budget must not carry over from the previous
// one (the player stays mounted across a queue advance — `go()` only changes `id`).
restartsRef.current = { n: 0, at: 0 };
// 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 { audioLang, subLang } = langPrefsRef.current;
const aOrd = audioOrdForLang(detail.audio_streams ?? [], audioLang);
audioRef.current = aOrd;
setAudioOrd(aOrd);
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], subLang));
const resume =
detail.status !== "watched" &&
!(fromStart && !movedOnRef.current) &&
startAtZeroRef.current !== id
? detail.position_seconds || 0
: 0;
void loadSession(resume);
}, [detail, id, fromStart, loadSession, audioRef, setAudioOrd, setSubOrd]);
// Leaving the item: a session still in flight for it must not land after it, and its stream goes.
useEffect(
() => () => {
sessGenRef.current++;
detachSessionRef.current();
detachSessionRef.current = () => {};
startedForRef.current = null;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
},
[id],
);
// 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.
//
// The setup half matters as much as the cleanup: React may run the cleanup and then MOUNT AGAIN
// (StrictMode does exactly that in development). With the flag only ever lowered, it stayed false
// for the life of the player — every play() is gated on it, so the player never started by itself,
// and a seek that restarts the session left it paused (bug hunt 2026-09, found on localdev).
//
// The element is captured HERE, not read in the cleanup: React detaches a ref before it runs an
// unmount's effect cleanups, so `videoRef.current` was already null there and the stop/detach below
// never ran (only StrictMode's simulated unmount, which keeps refs, ever reached it).
useEffect(() => {
aliveRef.current = true;
const v = videoRef.current;
return () => {
aliveRef.current = false;
// The rule's advice — copy the ref into a variable and use the copy — is precisely wrong here.
// sessGenRef is a LIVE counter: bumping it is how the unmount tells every in-flight session
// that its response no longer belongs to anything, and a value captured when the effect ran
// would invalidate a generation that has since been superseded instead of the current one.
// eslint-disable-next-line react-hooks/exhaustive-deps
sessGenRef.current++;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
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 = () => {
if (!clockLiveRef.current) return; // the old session's clock (see clockLiveRef)
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);
const detachLog = logMediaElement(video, "plex");
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 () => {
detachLog();
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).
const { settle: settleProgress, finish: finishProgress } = usePlexProgress({
id,
qc,
videoRef,
absRef,
durationRef,
});
// --- seeking (native within the loaded region, else restart the session) ---------------------
const seekTo = useCallback(
(target: number, play?: boolean) => {
const video = videoRef.current;
if (!video) return;
// Play/pause intent across the seek: the caller's when it has one (a Restart plays), else
// whatever the video was doing.
const wasPlaying = play ?? !video.paused;
const dur = durationRef.current || target;
const T = Math.max(0, Math.min(target, dur - 1));
// Reflect the target immediately so a save/beacon right after a seek is accurate — in the STATE
// too: `absRef` is re-synced from `abs` on every render, and the restart below renders at once,
// so the ref alone was back at the pre-seek spot before anything could read it.
absRef.current = T;
setAbs(T);
const rel = T - sessionStartRef.current;
// Native only when the attached media IS the current session. While one is still being asked
// for, `sessionStartRef` and `seekable` describe the OLD one: a native seek there left the
// pending restart current, and its answer then jumped to the earlier target.
if (
clockLiveRef.current &&
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
if (play) video.play().catch(() => {});
} 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 reportWatchedError = useCallback(
(e: unknown) => notifyActionError(e, tRef.current("plex.watchedFailed")),
[],
);
const go = useCallback(
(otherId: string | null | undefined, watchedWrite?: Promise<unknown>, fromZero = false) => {
if (otherId && otherId === id) {
// The item already playing (the info overlay's collection strip lists the film itself):
// nothing to switch to. The switch below would pause it and stop trusting its clock, and
// `setId` to the same id loads nothing — a frozen 0:00 (E28 review round 2, P1). The play
// intent goes INTO the seek: a paused film whose session starts past 0 restarts the session,
// and a play() beside that resumed the outgoing stream while the new one stayed paused.
if (fromZero) seekTo(0, true);
else videoRef.current?.play().catch(() => {});
return;
}
if (otherId) {
startAtZeroRef.current = fromZero ? otherId : null;
// A played-through item settles as watched, with no checkpoint at the credits (see
// usePlexProgress's `finish`) — said BEFORE the pause below, whose own checkpoint on the
// 'pause' event can land before the player has re-rendered for the next item. The write is
// handed over so a REFUSED one takes the finish back instead of leaving the cache lying.
if (watchedWrite) finishProgress(watchedWrite);
// 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 */
}
// Settle the item being left while the position still is its own (see usePlexProgress), and
// stop trusting the old media's clock: the timeupdate that pause() queues lands after the
// reset below and would otherwise hand the old position to the new item.
settleProgress();
clockLiveRef.current = false;
movedOnRef.current = true;
setAbs(0);
setId(otherId);
}
},
[id, seekTo, settleProgress, finishProgress],
);
// 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;
// Whether the row has a Next. An episode's next is in its detail, unknown while the next episode
// loads; the slot keeps its last answer meanwhile (a disabled Next), because the button unmounting
// took the focus that had just pressed it to <body> (E28 review E4). Adjusted during render.
const nextKnown = queue || !detailQ.isPending ? !!nextId : undefined;
const [nextSlot, setNextSlot] = useState(false);
if (nextKnown !== undefined && nextKnown !== nextSlot) setNextSlot(nextKnown);
const showNext = nextKnown ?? nextSlot;
// Back (C5): past the first BACK_TO_PREV_S seconds it restarts the item; inside them it steps to the
// previous one (at its resume point, as `go` opens every item) — or restarts again on the first.
const backToPrev = abs <= BACK_TO_PREV_S && !!prevId;
const backOrPrev = useCallback(() => {
if (playhead(absRef, loadStartRef) <= BACK_TO_PREV_S && prevId) go(prevId);
else seekTo(0);
}, [prevId, go, seekTo]);
// What the last press came from. A touch tap on the picture only shows or hides the controls (C3:
// measured live, a tap used to reveal the bar AND pause); a mouse click there still plays/pauses.
// Kept from `pointerdown`, because the `click` that follows is not a PointerEvent everywhere.
const pointerRef = useRef("mouse");
// The picture's taps: one shows/hides the controls; a second within DOUBLE_TAP_MS on the left or
// right third skips ∓TAP_SKIP_S, as does each further one (C5), and takes back the first tap's
// show/hide. The middle third has no skip, so a quick second tap there just toggles again.
const tapRef = useRef<{ at: number; zone: number; wasShown: boolean; skips: number } | null>(
null,
);
// A tap that brings the bands back keeps them out of the pointer's way until a double-tap can no
// longer follow (E28 review E5): the second tap of a skip at the bottom would otherwise land on the
// band the first one just revealed — the seek bar, a seek to wherever the finger was.
const [tapShield, setTapShield] = useState(false);
const tapShieldTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(tapShieldTimer.current), []);
const onPictureClick = useCallback(
(e: React.MouseEvent<HTMLVideoElement>) => {
if (pointerRef.current !== "touch") {
togglePlay();
return;
}
// The wrapper's own click wakes the controls, which would undo a hide.
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
const x = r.width > 0 ? (e.clientX - r.left) / r.width : 0.5;
const zone = x < 1 / 3 ? -1 : x > 2 / 3 ? 1 : 0;
const now = performance.now();
const prev = tapRef.current;
if (zone !== 0 && prev && prev.zone === zone && now - prev.at < DOUBLE_TAP_MS) {
if (prev.skips === 0) showControls(prev.wasShown);
const skips = prev.skips + 1;
tapRef.current = { ...prev, at: now, skips };
seekTo(playhead(absRef, loadStartRef) + zone * TAP_SKIP_S);
flashOsd(
t(zone < 0 ? "plex.player.tapBack" : "plex.player.tapForward", {
d: formatUnit(skips * TAP_SKIP_S, "second"),
}),
);
return;
}
tapRef.current = { at: now, zone, wasShown: uiVisible, skips: 0 };
if (!uiVisible) {
setTapShield(true);
window.clearTimeout(tapShieldTimer.current);
tapShieldTimer.current = window.setTimeout(() => setTapShield(false), DOUBLE_TAP_MS);
}
toggleControls();
},
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, uiVisible],
);
// 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,
reportWatchedError,
});
// 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";
// The ⋯ menu's rows (C5), in the lab's order. Settings and the media info need the item's detail.
const ico = "h-4 w-4 shrink-0";
const moreItems = [
...(detail
? [
{
id: "settings",
label: t("plex.player.settings"),
icon: <Settings className={ico} />,
run: () => setMenuOpen(true),
},
{
id: "info",
label: t("plex.player.infoBtn"),
icon: <Info className={ico} />,
run: () => setInfoOpen((v) => !v),
},
]
: []),
{
id: "stats",
label: t("plex.player.stats.title"),
icon: <Gauge className={ico} />,
run: () => setStatsOpen((v) => !v),
},
{
id: "help",
label: t("plex.player.help"),
icon: <Keyboard className={ico} />,
run: toggleKeyboardHelp,
},
{
id: "download",
label: t("plex.player.download"),
icon: <Download className={ico} />,
run: download,
},
];
// 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;
const watchedWrite = api.plexSetState(id, "watched");
watchedWrite.catch(reportWatchedError);
// Whatever follows (the up-next card, the next item, closing) leaves a FINISHED item — unless
// the server refuses the mark, in which case `finish` takes itself back (see usePlexProgress).
finishProgress(watchedWrite);
// 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, finishProgress, reportWatchedError]);
// 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]);
// The "up next" card belongs to the item that was playing, so a queue advance closes it. Adjusted
// during the render that changes `id` rather than in an effect: as an effect it closed one commit
// after the new episode had already drawn behind it.
const [upNextForId, setUpNextForId] = useState(id);
if (upNextForId !== id) {
setUpNextForId(id);
setUpNextOpen(false);
}
// "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 info overlay) before leaving the
// player. The arrow used to call onClose() unconditionally, so Back with the info panel open
// jumped straight to the feed instead of just closing the panel. The shortcuts sheet is no longer
// in this cascade: it is a Modal above us now, and owns its own Escape layer and Back-to-close.
const handleBack = useCallback(() => {
if (menuOpenRef.current) {
setMenuOpen(false);
setTracksOpen(false);
setMoreOpen(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]);
// Tell the shared cheat sheet which keys are live here, and whether ⇧← / ⇧→ apply (they step
// episodes, so they are listed only inside a series).
useKeyScope("plexPlayer");
useHelpConditions({ episode: detail?.kind === "episode" });
// Its toast host's rank among open overlays (see ui/Toaster).
const overlayOrder = useOverlayOrder();
// 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 seekBy = (e: KeyboardEvent, dir: -1 | 1, fine: boolean) => {
e.preventDefault();
const step = fine ? fineSeekStepRef.current : seekStepRef.current;
seekTo(playhead(absRef, loadStartRef) + dir * step);
};
const step = (e: KeyboardEvent, id: string | null | undefined) => {
e.preventDefault();
go(id);
};
const onKey = (e: KeyboardEvent) => {
// A Space or Enter on a control the bands have faded out from under (a key user's focus stays
// put when they hide) must not press it unseen — Back leaving the player, Next skipping the
// episode. That key only brings the bands back; Space also does what a viewer who sees no
// button means by it, and pauses or plays. Read before `wake` below shows them.
const blind =
!controlsShown() &&
isActivationTarget(e) &&
!!(e.target as Element | null)?.closest?.("[data-player-band]") &&
(e.key === " " || e.key === "Enter");
// Keyboard use also reveals the controls — but typing in a player field is not "using the
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
// opts in), which is why this is a guard around `wake` rather than an early return.
// A slider is not a text field: its arrows are the remote too (E28 review E6).
if (!isTypingTarget(e) || (e.target as Element | null)?.matches?.('input[type="range"]'))
wake();
if (blind) {
e.preventDefault();
if (e.key === " ") togglePlay();
return;
}
// The shared dialog Tab trap. This player paints an opaque full-viewport layer over the app,
// so Tab leaving it lands on a control nobody can see, where the ring is invisible and the
// next Enter fires something blind. Every other overlay in the tree traps (ui/Modal,
// PlayerModal, OnboardingWizard, Welcome); this one never did, and deep-review round 4 caught
// it when the recorder's step prompt — which docks in this player's toast host — handed its
// own trap to "the dialog below" and found none. Only while nothing that owns the keyboard is
// layered above: a Modal opened over the player traps for itself. `wake()` above has already
// revealed the control band, so the trap cannot leave focus on a faded-out button.
if (e.key === "Tab") {
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
if (wrapRef.current) trapTab(e, wrapRef.current);
return;
}
dispatch(e, "plexPlayer", {
playPause: (ev) => {
// Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
// playback when the focus isn't on a control — the same shared rule PlayerModal uses.
if (isActivationTarget(ev)) return;
ev.preventDefault();
togglePlay();
},
fullscreen: () => toggleFs(),
seekBack: (ev) => seekBy(ev, -1, false),
seekFwd: (ev) => seekBy(ev, 1, false),
fineBack: (ev) => seekBy(ev, -1, true),
fineFwd: (ev) => seekBy(ev, 1, true),
prevEpisode: (ev) => step(ev, prevId),
nextEpisode: (ev) => step(ev, nextId),
volumeUp: (ev) => {
ev.preventDefault();
nudgeVolume(0.05);
},
volumeDown: (ev) => {
ev.preventDefault();
nudgeVolume(-0.05);
},
mute: () => toggleMute(),
audio: () => {
const ord = cycleAudio();
if (ord === undefined) return; // one track (or none): nothing to say
const label = detail?.audio_streams.find((a) => a.ord === (ord ?? 0))?.label;
flashOsd(`${t("plex.player.audio")} · ${label ?? "—"}`);
},
subtitle: () => {
const ord = cycleSubtitle();
if (ord === undefined) return;
const label =
ord == null
? t("plex.player.subOff")
: (detail?.subtitle_streams.find((x) => x.ord === ord)?.label ?? "—");
flashOsd(`${t("plex.player.subtitles")} · ${label}`);
},
info: () => setInfoOpen((v) => !v),
stats: () => setStatsOpen((v) => !v),
back: (ev) => {
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
// Back button (shared cascade).
// Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY
// and its focused close button is not a typing target, so the key reaches us regardless.
// Two wrong answers so far: deferring to nothing at all (round 1's bug — the cheat sheet
// took ⌫ and the player stopped underneath it), then deferring to `anyLayerAbove` outright
// (round 1's fix, round 2's bug — the player's OWN gear/tracks popovers push layers too, so
// ⌫ stopped closing them), then naming just the sheet and palette (round 2's fix, round 3's
// bug — the app-wide ErrorDialog is neither, so ⌫ tore the player down under it).
// The rule that holds: the player's own panels answer first, because `handleBack`'s cascade
// is what closes them; anything else layered above us takes the key.
const ownPanelOpen =
menuOpenRef.current ||
infoOpenRef.current ||
statsOpenRef.current ||
upNextOpenRef.current;
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
return;
ev.preventDefault();
if (skipProgressRef.current != null)
cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
else handleBack();
},
escape: (ev) => {
// 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(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (menuOpenRef.current) {
setMenuOpen(false);
setTracksOpen(false);
setMoreOpen(false);
} else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
else if (!fullscreenElement()) onClose();
},
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [
togglePlay,
toggleFs,
seekTo,
go,
nudgeVolume,
toggleMute,
cycleAudio,
cycleSubtitle,
flashOsd,
t,
detail,
prevId,
nextId,
onClose,
wake,
controlsShown,
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);
// The volume slider shows its level while the pointer is on it.
const [volHover, setVolHover] = useState(false);
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"
onPointerDown={(e) => {
pointerRef.current = e.pointerType;
}}
// A pointer MOVE wakes the bar; a touch has none of its own, and the mouse events a browser
// fakes after a tap would show the bar the tap just hid.
onPointerMove={(e) => {
if (e.pointerType !== "touch") wake();
}}
onClick={wake}
onWheel={onWheel}
// Windowed (pseudo-fullscreen): everything is 25% larger for lean-back / HTPC legibility. Laid
// out at 80vw×80dvh and scaled ×1.25 from the top-left so it fills the viewport exactly (no
// overflow, controls stay on-screen; `dvh` so a tablet's toolbar does not push the bar off it). 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×80dvh, 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×100dvh) 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: "100dvh" }
: {
width: "80vw",
height: "80dvh",
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={(el) => {
videoRef.current = el;
// So Brave iOS's background play keeps it going when the app is left (lib/browserBackgroundPlay).
exposeVideoToBackgroundPlay(el);
}}
// 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.
// `touch-manipulation`: a double-tap is the skip, never the browser's zoom.
className="w-full h-full object-contain touch-manipulation"
onClick={onPictureClick}
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 && subShowable && (
<track
// One track = the selected sub only (no multi-track overlap). The key (see usePlexTracks)
// is what remounts it for a fresh VTT fetch: a seek-restart, an offset change, a widened
// track, or a failed load.
key={subTrackKey}
kind="subtitles"
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset, subGen)}
srcLang={selectedSub.language ?? undefined}
label={selectedSub.label}
{...subTrackListeners}
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>
)}
{/* Subtitle-extraction notice. Pulling a track out of a big MKV is a full read of the container
(154 s for a 17 GB film, measured), so it happens in the background — around the playhead
first. Until then the <track> is deliberately NOT mounted, and this says why instead of
leaving the viewer to conclude that switching subtitles "does nothing" (bug hunt 2026-09,
item 8). A failure is said even over a mounted window (see `subNotice`). It sits one rung
above the track-switch OSD so the two never overlap. Both notices are `z-base`, under the
settings and tracks popovers (`z-menu`): an open menu is never drawn over (E28 audit). */}
<div
className="pointer-events-none absolute inset-x-0 bottom-36 z-base flex justify-center px-4"
aria-live="polite"
>
{selectedSub && subNotice && (
<div className="flex max-w-full items-center gap-2 truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
{subNotice !== "preparing" ? (
<span className="text-danger">{t(`plex.player.subUnavailable.${subNotice}`)}</span>
) : (
<>
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
<span>{t("plex.player.subPreparing", { label: selectedSub.label })}</span>
</>
)}
</div>
)}
</div>
{/* Keyboard track-switch OSD. Sits ABOVE the control bar's own space so it reads even while the
bar is showing, and inside the player element so it survives fullscreen. `aria-live` because
the whole point is confirming something you cannot otherwise see.
The REGION is always mounted and only the bubble is conditional: a polite region has to
pre-exist in the accessibility tree for a later insertion to be announced, so one created
together with its own first message is unreliably read — and since `osd` clears after 2.2s,
conditioning the wrapper re-created it for every switch, not just the first (E10 review
round 1). Toaster.tsx carries the same rule and the same shape. */}
<div
className="pointer-events-none absolute inset-x-0 bottom-24 z-base flex justify-center px-4"
aria-live="polite"
>
{osd && (
<div className="max-w-full truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
{osd}
</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 || infoOpen || statsOpen || upNextOpen || skipProgress != null) && (
<BackClose onBack={backIntent} />
)}
{/* Top bar */}
<div
{...holdWhileHovered}
className={`absolute top-0 inset-x-0 p-4 bg-linear-to-b from-black/70 to-transparent transition-opacity ${
uiVisible
? `opacity-100${tapShield ? " pointer-events-none" : ""}`
: "opacity-0 pointer-events-none"
}`}
>
<div className="flex items-center gap-3 text-white">
<IconButton
variant="overlay"
size="player"
round
onClick={handleBack}
label={t("plex.player.back")}
>
<ArrowLeft />
</IconButton>
<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
variant="media"
onClick={() => doSkip(activeMarker)}
className={`absolute right-6 bottom-28 overflow-hidden ${
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
{...holdWhileHovered}
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-3 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
uiVisible
? `opacity-100${tapShield ? " pointer-events-none" : ""}`
: "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. The track stays thin
inside a 2.75rem hit area (C3): a finger could not find the 6px bar, and `touch-none`
makes a drag scrub instead of panning the page. */}
<div
className="group relative flex h-11 cursor-pointer touch-none items-center"
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={(e) => {
if (scrub != null) {
seekTo(scrub);
setScrub(null);
}
// A finger never "leaves": its time tip goes with it.
if (e.pointerType === "touch") setHover(null);
}}
onPointerCancel={() => {
setScrub(null);
setHover(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="relative h-1.5 w-full rounded-full bg-white/25">
<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}%` }}
/>
{/* The head: on hover for a mouse, always on touch (C3), where nothing hovers. */}
<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 [@media(hover:none)]:opacity-100"
}`}
style={{ left: `${headPct}%` }}
/>
</div>
</div>
{/* The C5 row, one line on a portrait tablet: play/pause, Back, Next (only with a next
item), mute, the time, then audio & subtitles, the fixed ⋯ and fullscreen. No Stop
(closing is stopping), no Restart (Back is, and the card's), no ±10 s (← / → and the
touch double-tap are). */}
<div className="flex items-center gap-3 text-white">
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</Ctrl>
<Ctrl
label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")}
onClick={backOrPrev}
>
<SkipBack className="w-5 h-5" />
</Ctrl>
{showNext && (
<Ctrl
label={t("plex.player.next")}
onClick={() => nextId && 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>
{/* The browser's own range, like every other slider (DESIGN.md A6): its hover, focus,
keyboard and screen-reader support come with it — the hand-drawn bar it replaces had
none of them. The level shows above it while hovered. A pointer drag hands the focus
back so Space and the arrows keep driving the player, not the slider. */}
{/* Not on touch: a tablet's volume is its buttons (iPadOS ignores a page's), and the
row has to fit a portrait screen. */}
<div
className="relative w-24 [@media(hover:none)]:hidden"
onPointerEnter={() => setVolHover(true)}
onPointerLeave={() => setVolHover(false)}
>
<Slider
min={0}
max={1}
step={0.01}
value={muted ? 0 : volume}
aria-label={t("plex.player.volume")}
onChange={(v) => setVol(v, v === 0)}
onPointerUp={(e) => e.currentTarget.blur()}
/>
{volHover && (
<div className="pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white">
{Math.round((muted ? 0 : volume) * 100)}
</div>
)}
</div>
</div>
<div className="min-w-0 truncate 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. Picking
a track keeps it open: the change is seen where it was made, and the other group is
one arrow away. */}
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
<>
<IconButton
variant="overlay"
size="player"
round
ref={tracksPop.refs.setReference}
{...tracksPop.getReferenceProps()}
aria-expanded={tracksOpen}
label={t("plex.player.tracks")}
>
<Languages />
</IconButton>
<PopoverSurface
popover={tracksPop}
ariaLabel={t("plex.player.tracks")}
className={`${PLAYER_MENU} w-56`}
>
{/* Portaled, but a React child of the player: a click or a wheel here must not
reach the stage's play-toggle and volume handlers. */}
<div
ref={tracksFade.ref}
onClick={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
style={{ ...tracksFade.style, maxHeight: "min(60dvh, var(--fit-max-h))" }}
className="overflow-auto no-scrollbar p-2"
>
{detail.audio_streams.length > 1 && (
<div role="group" aria-label={t("plex.player.audio")}>
<PanelHead>{t("plex.player.audio")}</PanelHead>
{detail.audio_streams.map((a) => (
<PopoverItem
key={a.ord}
tone="media"
role="menuitemradio"
checked={(audioOrd ?? 0) === a.ord}
label={a.label}
onSelect={() => changeAudio(a.ord)}
className={`${TRACK_ROW} ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
<span className="truncate">{a.label}</span>
</PopoverItem>
))}
</div>
)}
{textSubs.length > 0 && (
<div role="group" aria-label={t("plex.player.subtitles")}>
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
<PopoverItem
tone="media"
role="menuitemradio"
checked={subOrd == null}
label={t("plex.player.subOff")}
onSelect={() => changeSubtitle(null)}
className={`${TRACK_ROW} ${subOrd == null ? "text-accent" : "text-white"}`}
>
<MenuCheck kind="one" on={subOrd == null} />
<span className="truncate">{t("plex.player.subOff")}</span>
</PopoverItem>
{textSubs.map((sub) => (
<PopoverItem
key={sub.ord}
tone="media"
role="menuitemradio"
checked={subOrd === sub.ord}
label={sub.label}
onSelect={() => changeSubtitle(sub.ord)}
className={`${TRACK_ROW} ${
subOrd === sub.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={subOrd === sub.ord} />
<span className="truncate">{sub.label}</span>
</PopoverItem>
))}
</div>
)}
</div>
</PopoverSurface>
</>
)}
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
so the ⋯ also closes it. */}
<div className="relative">
<IconButton
variant="overlay"
size="player"
round
ref={moreTriggerRef}
{...morePop.getReferenceProps()}
aria-expanded={moreOpen || menuOpen}
label={t("plex.player.more")}
>
<MoreHorizontal />
</IconButton>
<PopoverSurface
popover={morePop}
ariaLabel={t("plex.player.more")}
className={`${PLAYER_MENU} w-60 p-1.5`}
>
<div onClick={(e) => e.stopPropagation()} className="flex flex-col">
{moreItems.map((it) => (
<PopoverItem
key={it.id}
tone="media"
label={it.label}
// Closes first, so the focus is back on the ⋯ before the tool opens: a dialog
// opened from here returns the focus to where it found it.
onSelect={() => closeThenAct(() => setMoreOpen(false), it.run)}
className={`${PLAYER_ROW} flex min-h-9 items-center gap-2.5 text-white`}
>
{it.icon}
{it.label}
</PopoverItem>
))}
</div>
</PopoverSurface>
{menuOpen && (
<PlexPlayerSettings
menuRef={menuRef}
returnFocusTo={menuTriggerRef}
settingsTabs={settingsTabs}
activeTab={activeTab}
setSettingsTab={setSettingsTab}
prefs={prefs}
patchPrefs={patchPrefs}
changeAudioOffset={changeAudioOffset}
now={now}
/>
)}
</div>
<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}
fragMeterRef={fragMeterRef}
audioLabel={
detail?.audio_streams.find((a) => a.ord === (audioOrd ?? 0))?.label ?? undefined
}
subLabel={
subOrd == null
? t("plex.player.subOff")
: (detail?.subtitle_streams.find((x) => x.ord === subOrd)?.label ?? undefined)
}
playable={detail?.playable ?? ""}
hwRef={hwRef}
tonemapRef={tonemapRef}
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)}
/>
)}
{/* 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-media-chrome/95 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<Suspense fallback={null}>
<PlexInfo
detail={detail}
variant="overlay"
onClose={() => setInfoOpen(false)}
// A collection sibling plays right here (E28 review D1), as Next would.
onPlayItem={(other, fromZero) => {
setInfoOpen(false);
go(other, undefined, fromZero);
}}
/>
</Suspense>
</div>
</div>
)}
{/* The app's Toaster is underneath this full-screen player (and outside it in native
fullscreen), so toasts raised while watching show here instead. See ui/Toaster. */}
<Toaster overlay order={overlayOrder} />
</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;
}