- F16: --rating (theme-scoped gold, 4.5:1 on the page and its chip), --rating-media, --live/--live-fg, --media-chrome replace 14 palette literals; a lint rule refuses palette classes; the theme contract measures them. - The Plex player's notices drew over its open settings/tracks popovers: notices z-base, popovers z-menu. - The player's info overlay rendered theme tokens on its always-dark panel: on a light theme the score (2.6:1), collection years (3.2:1) and cast initials (1.05:1) were unreadable; onDark variants now. - The lint-rule contract tests share one ESLint with a one-off warm-up (a cold first lint took 8 s, past vitest's 5 s).
1668 lines
78 KiB
TypeScript
1668 lines
78 KiB
TypeScript
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 { 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,
|
||
} from "lucide-react";
|
||
import { api, HttpError } from "../lib/api";
|
||
import { notifyActionError } from "../lib/actionErrors";
|
||
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 { 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 { Pressable } from "./ui/Pressable";
|
||
|
||
// 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;
|
||
|
||
// 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>(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 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); // 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 tracksFadeRef = tracksFade.ref;
|
||
const setTracksNode = useCallback(
|
||
(node: HTMLElement | null) => {
|
||
tracksRef.current = node as HTMLDivElement | null;
|
||
tracksFadeRef(node);
|
||
},
|
||
[tracksFadeRef],
|
||
);
|
||
// 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. Only such a session can be "lost" (below): one that
|
||
// fails before its manifest is simply a failure, and must not restart itself in a loop.
|
||
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 on a session that WAS playing: the server has reaped it (a pause longer than
|
||
// its idle limit, a deploy) and the stream is simply gone, not broken. hls.js never retries a
|
||
// 4xx, so this used to end playback with "couldn't play" after the buffer ran out; a new
|
||
// session at the playhead is what closing and reopening the player did by hand.
|
||
//
|
||
// Bounded, because `parsed` cannot bound it: it belongs to ONE hls instance and every restart
|
||
// builds a fresh one, so it only ever blocks a session that died before its manifest. 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 (parsed && 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), !video.paused);
|
||
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);
|
||
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" ? detail.position_seconds || 0 : 0;
|
||
void loadSession(resume);
|
||
}, [detail, id, 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) => {
|
||
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));
|
||
// 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
|
||
} 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>) => {
|
||
if (otherId) {
|
||
// 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;
|
||
setAbs(0);
|
||
setId(otherId);
|
||
}
|
||
},
|
||
[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;
|
||
|
||
// 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";
|
||
|
||
// 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);
|
||
} 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) => {
|
||
// 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.
|
||
if (!isTypingTarget(e)) wake();
|
||
// 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);
|
||
} 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,
|
||
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={(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.
|
||
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 && 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
|
||
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">
|
||
<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
|
||
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(playhead(absRef, loadStartRef) - 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(playhead(absRef, loadStartRef) + 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">
|
||
<IconButton
|
||
variant="overlay"
|
||
size="player"
|
||
round
|
||
ref={tracksTriggerRef}
|
||
onClick={() => setTracksOpen((o) => !o)}
|
||
aria-expanded={tracksOpen}
|
||
label={t("plex.player.tracks")}
|
||
>
|
||
<Languages />
|
||
</IconButton>
|
||
{tracksOpen && (
|
||
<div
|
||
ref={setTracksNode}
|
||
style={tracksFade.style}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onWheel={(e) => e.stopPropagation()}
|
||
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu 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) => (
|
||
<Pressable
|
||
key={a.ord}
|
||
onClick={() => changeAudio(a.ord)}
|
||
aria-pressed={(audioOrd ?? 0) === a.ord}
|
||
className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
|
||
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
|
||
}`}
|
||
>
|
||
{a.label}
|
||
</Pressable>
|
||
))}
|
||
</>
|
||
)}
|
||
{textSubs.length > 0 && (
|
||
<>
|
||
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
|
||
<Pressable
|
||
onClick={() => changeSubtitle(null)}
|
||
aria-pressed={subOrd == null}
|
||
className={`block w-full rounded px-2 py-1 hover:bg-white/10 ${
|
||
subOrd == null ? "text-accent" : "text-white"
|
||
}`}
|
||
>
|
||
{t("plex.player.subOff")}
|
||
</Pressable>
|
||
{textSubs.map((s) => (
|
||
<Pressable
|
||
key={s.ord}
|
||
onClick={() => changeSubtitle(s.ord)}
|
||
aria-pressed={subOrd === s.ord}
|
||
className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
|
||
subOrd === s.ord ? "text-accent" : "text-white"
|
||
}`}
|
||
>
|
||
{s.label}
|
||
</Pressable>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
|
||
{detail && (
|
||
<div className="relative">
|
||
<IconButton
|
||
variant="overlay"
|
||
size="player"
|
||
round
|
||
ref={menuTriggerRef}
|
||
onClick={() => setMenuOpen((o) => !o)}
|
||
aria-expanded={menuOpen}
|
||
label={t("plex.player.settings")}
|
||
>
|
||
<Settings />
|
||
</IconButton>
|
||
{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={toggleKeyboardHelp}>
|
||
<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}
|
||
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)} />
|
||
</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;
|
||
}
|