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