Seeking to a spot outside the current remux window restarts the hls.js session, which detaches/re-attaches the media and fires 'emptied' — that silently flips the element to paused WITHOUT a 'pause' event. Because the play flag only tracked 'play'/'pause', the button kept showing 'playing' while the video had actually stopped, and on a slow (proxied) load the post-manifest play() could be rejected, leaving it stuck paused until a click on the video. Root cause confirmed by instrumenting the media events during a seek: emptied(paused=true) with no accompanying pause event. Fix: resync the play flag from video.paused on the settling events (emptied/canplay/seeked) so the button can never contradict reality; preserve the pre-seek play/pause intent through the session restart (loadSession takes resumePlay); and retry play once on 'canplay' so a rejected early play() still resumes. Verified on :8080 — backward seek (session restart), forward seek (native) and manual pause all keep the icon in sync (0 desync samples) and resume correctly.
1688 lines
74 KiB
TypeScript
1688 lines
74 KiB
TypeScript
import {
|
||
Fragment,
|
||
lazy,
|
||
Suspense,
|
||
useCallback,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
type ReactNode,
|
||
type WheelEvent as ReactWheelEvent,
|
||
} from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import Hls from "hls.js";
|
||
import {
|
||
ArrowLeft,
|
||
Clock,
|
||
Download,
|
||
Info,
|
||
Keyboard,
|
||
Languages,
|
||
Maximize,
|
||
Minimize,
|
||
Pause,
|
||
Play,
|
||
RotateCcw,
|
||
Settings,
|
||
SkipBack,
|
||
SkipForward,
|
||
Square,
|
||
Volume2,
|
||
VolumeX,
|
||
X,
|
||
} from "lucide-react";
|
||
import { api, type PlexMarker } from "../lib/api";
|
||
import { useAccountPersistedObject } from "../lib/storage";
|
||
import { useDismiss } from "../lib/useDismiss";
|
||
import { useBackToClose } from "../lib/history";
|
||
|
||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||
|
||
// Audio/subtitle selections are persisted per-account by LANGUAGE, not by ordinal — the same track
|
||
// ordinal means different languages in different files, so we re-resolve the saved language to this
|
||
// item's track layout on load (and it carries across episodes/movies, like Plex's preferred-language).
|
||
type AudioStream = { ord: number; language?: string | null; default: boolean };
|
||
type SubStream = { ord: number; language?: string | null; text: boolean };
|
||
function audioOrdForLang(streams: AudioStream[], lang: string): number | null {
|
||
if (!lang) return null; // "" = use the file's default first track (keeps direct-play, no forced HLS)
|
||
const i = streams.findIndex((s) => (s.language ?? "") === lang);
|
||
// absent (-1) or already the FIRST track (0, which the backend plays by default) → no override.
|
||
// (Match by index, NOT the `default` flag — some files mark a non-first track default, which made
|
||
// the restored language silently fall back to track 0.)
|
||
return i <= 0 ? null : streams[i].ord;
|
||
}
|
||
function subOrdForLang(subs: SubStream[], lang: string): number | null {
|
||
if (!lang) return null; // "" = subtitles off
|
||
const m = subs.find((s) => s.text && (s.language ?? "") === lang);
|
||
return m ? m.ord : null; // preferred language not available here → off
|
||
}
|
||
|
||
// Per-account player preferences, persisted so they survive F5 and carry across items.
|
||
type PlexPlayerPrefs = {
|
||
volume: number; // 0..1
|
||
muted: boolean;
|
||
wasPlaying: boolean; // resume-playing intent across reloads (best-effort; browsers may block autoplay)
|
||
audioLang: string; // "" = default first audio
|
||
subLang: string; // "" = subtitles off
|
||
subOffset: number; // seconds, +later / -earlier (client-side cue shift)
|
||
audioOffset: number; // seconds, +later / -earlier (server-side -itsoffset)
|
||
barAutoHide: boolean; // auto-hide the control bar during playback
|
||
seekStep: number; // plain ←/→ jump (s)
|
||
fineSeekStep: number; // Ctrl+←/→ jump (s)
|
||
subSize: number; // subtitle font size (%)
|
||
subColor: string; // subtitle text color (hex)
|
||
subBg: string; // subtitle background ("transparent" or hex)
|
||
subPos: number; // subtitle vertical position (cue line %, higher = lower on screen)
|
||
subShadow: number; // subtitle text-shadow blur px (0 = off)
|
||
subShadowColor: string; // subtitle text-shadow color (hex)
|
||
clockShow: boolean; // top-right wall clock
|
||
clock24h: boolean; // 24h (European) vs 12h am/pm
|
||
clockSize: number; // clock font size (%)
|
||
clockColor: string; // clock color (hex)
|
||
clockDate: boolean; // show the date alongside the time
|
||
clockDateStyle: "en" | "hu"; // DD-MON-YYYY Ddd vs YYYY-MON-DD Ddd
|
||
autoSkipIntro: boolean; // auto-skip the intro marker after a countdown
|
||
autoSkipCredits: boolean; // auto-skip credits → jump to the next item (binge)
|
||
autoSkipDelay: number; // countdown before an auto-skip fires (s, 0 = immediate)
|
||
};
|
||
const DEFAULT_PREFS: PlexPlayerPrefs = {
|
||
volume: 1,
|
||
muted: false,
|
||
wasPlaying: true,
|
||
audioLang: "",
|
||
subLang: "",
|
||
subOffset: 0,
|
||
audioOffset: 0,
|
||
barAutoHide: true,
|
||
seekStep: 10,
|
||
fineSeekStep: 1,
|
||
subSize: 100,
|
||
subColor: "#ffffff",
|
||
subBg: "transparent",
|
||
subPos: 88,
|
||
subShadow: 0,
|
||
subShadowColor: "#000000",
|
||
clockShow: true,
|
||
clock24h: true,
|
||
clockSize: 100,
|
||
clockColor: "#ffffff",
|
||
clockDate: false,
|
||
clockDateStyle: "hu",
|
||
autoSkipIntro: false,
|
||
autoSkipCredits: false,
|
||
autoSkipDelay: 5,
|
||
};
|
||
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
|
||
|
||
// A punchy subtitle shadow. The old single 0-offset blur washed out to nothing on bright scenes even
|
||
// at the max slider value; instead lay down eight offset copies (a solid dark OUTLINE around the
|
||
// glyphs) plus a soft glow, all scaled by the slider — legible over anything, e.g. yellow text on a
|
||
// snow-white frame. Empty string when the slider is 0 (shadow off).
|
||
function subShadowCss(px: number, color: string): string {
|
||
if (px <= 0) return "";
|
||
const o = px;
|
||
const b = Math.max(1, Math.round(px / 2));
|
||
const dirs: [number, number][] = [
|
||
[o, 0], [-o, 0], [0, o], [0, -o],
|
||
[o, o], [o, -o], [-o, o], [-o, -o],
|
||
];
|
||
const outline = dirs.map(([x, y]) => `${x}px ${y}px ${b}px ${color}`).join(",");
|
||
return `text-shadow:${outline},0 0 ${o * 2}px ${color};`;
|
||
}
|
||
|
||
// Top-right wall clock text: time (24h European or 12h am/pm) + optional date in one of two fixed
|
||
// styles — EN "08-JUL-2026 Wed" or HU "2026-JÚL-08 Sze" (uppercase short month, short weekday).
|
||
function clockParts(now: Date, p: PlexPlayerPrefs): { time: string; date: string | null } {
|
||
const time = now.toLocaleTimeString(p.clock24h ? "en-GB" : "en-US", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: !p.clock24h,
|
||
});
|
||
if (!p.clockDate) return { time, date: null };
|
||
const loc = p.clockDateStyle === "hu" ? "hu-HU" : "en-GB";
|
||
const dd = String(now.getDate()).padStart(2, "0");
|
||
const yyyy = now.getFullYear();
|
||
const mon = now.toLocaleString(loc, { month: "short" }).replace(/\./g, "").toUpperCase();
|
||
const dow = now.toLocaleString(loc, { weekday: "short" }).replace(/\./g, "");
|
||
const date = p.clockDateStyle === "hu" ? `${yyyy}-${mon}-${dd} ${dow}` : `${dd}-${mon}-${yyyy} ${dow}`;
|
||
return { time, date };
|
||
}
|
||
// hls.js plays our non-zero-start `-copyts` HLS stream ~1.0s BEHIND the media clock (measured by
|
||
// frame-capture: the first frame at source K lands at currentTime≈1.0, not 0 — constant, framerate- and
|
||
// segment-type-independent). Native-time subtitles (shifted by K) would therefore run 1s early, so we
|
||
// bake this compensation into the shift. Only on the HLS path; direct-play uses the file's real clock.
|
||
const HLS_SUB_LAG = 1.0;
|
||
|
||
// 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[] };
|
||
|
||
function fmt(t: number): string {
|
||
if (!isFinite(t) || t < 0) t = 0;
|
||
const s = Math.floor(t % 60);
|
||
const m = Math.floor((t / 60) % 60);
|
||
const h = Math.floor(t / 3600);
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||
}
|
||
|
||
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||
const { t } = useTranslation();
|
||
const qc = useQueryClient();
|
||
const [id, setId] = useState(itemId);
|
||
const detailQ = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
|
||
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
|
||
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;
|
||
// Auto-skip countdown state, via refs so the keyboard handler (defined earlier than the skip
|
||
// machinery) can read/cancel it without a declaration-order dependency.
|
||
const skipProgressRef = useRef<number | null>(null);
|
||
const cancelAutoSkipRef = useRef<() => void>(() => {});
|
||
|
||
const [playing, setPlaying] = useState(false);
|
||
const [abs, setAbs] = useState(0); // absolute current time
|
||
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
|
||
const [fs, setFs] = useState(false);
|
||
const [ready, setReady] = useState(false);
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
const [uiVisible, setUiVisible] = useState(true);
|
||
const hideTimer = useRef<number | null>(null);
|
||
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
|
||
const [audioOrd, setAudioOrd] = useState<number | null>(null);
|
||
const [subOrd, setSubOrd] = useState<number | 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>(
|
||
"siftlode.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;
|
||
// Debounced user subtitle offset: avoids re-fetching the VTT on every slider step while dragging.
|
||
const [subOffApplied, setSubOffApplied] = useState(prefs.subOffset);
|
||
useEffect(() => {
|
||
const tmr = window.setTimeout(() => setSubOffApplied(prefs.subOffset), 250);
|
||
return () => window.clearTimeout(tmr);
|
||
}, [prefs.subOffset]);
|
||
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);
|
||
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
|
||
audioRef.current = audioOrd;
|
||
menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
|
||
// 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]);
|
||
// 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;
|
||
const [now, setNow] = useState(() => new Date());
|
||
useEffect(() => {
|
||
const iv = window.setInterval(() => setNow(new Date()), 15000);
|
||
return () => window.clearInterval(iv);
|
||
}, []);
|
||
|
||
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);
|
||
setLoadError(null);
|
||
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: any) {
|
||
// 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).
|
||
setLoadError(
|
||
e?.status === 501
|
||
? t("plex.player.errTranscode")
|
||
: e?.status === 404
|
||
? t("plex.player.errNotFound")
|
||
: t("plex.player.errGeneric"),
|
||
);
|
||
return;
|
||
}
|
||
// 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(t("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, t],
|
||
);
|
||
|
||
// 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);
|
||
patchPrefs({ wasPlaying: true }); // remember play intent for F5 (best-effort; autoplay may be blocked)
|
||
};
|
||
const onPause = () => {
|
||
setPlaying(false);
|
||
patchPrefs({ wasPlaying: 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. (Does not touch the wasPlaying pref.)
|
||
const syncPlaying = () => setPlaying(!video.paused);
|
||
video.addEventListener("timeupdate", onTime);
|
||
video.addEventListener("play", onPlay);
|
||
video.addEventListener("pause", onPause);
|
||
video.addEventListener("emptied", syncPlaying);
|
||
video.addEventListener("canplay", syncPlaying);
|
||
video.addEventListener("seeked", syncPlaying);
|
||
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);
|
||
};
|
||
}, [id, patchPrefs]);
|
||
|
||
// Periodic + on-unmount progress checkpoint.
|
||
const absRef = useRef(0);
|
||
absRef.current = abs;
|
||
useEffect(() => {
|
||
const flush = () => {
|
||
if (absRef.current > 0 && durationRef.current > 0)
|
||
api.plexProgress(id, Math.floor(absRef.current), durationRef.current).catch(() => {});
|
||
};
|
||
const iv = window.setInterval(flush, 10000);
|
||
// Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload,
|
||
// so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a
|
||
// keepalive beacon so the POST survives the unload.
|
||
const onHide = () => {
|
||
if (absRef.current > 0 && durationRef.current > 0)
|
||
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current);
|
||
};
|
||
window.addEventListener("pagehide", onHide);
|
||
return () => {
|
||
window.clearInterval(iv);
|
||
window.removeEventListener("pagehide", onHide);
|
||
flush();
|
||
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
|
||
// the SAME session read react-query's CACHED detail — whose position_seconds predated this
|
||
// watch — so the resume landed at the old spot (the progress GET could also race the POST).
|
||
// Patch the cached detail here so a reopen resumes from where we left off. (F5 was always fine:
|
||
// a reload starts with an empty cache and fetches the fresh position.) Safe to do only on
|
||
// unmount — patching while mounted would retrigger the loadSession effect and reseek mid-play.
|
||
if (absRef.current > 0 && durationRef.current > 0) {
|
||
const pos = Math.floor(absRef.current);
|
||
qc.setQueryData(["plex-item", id], (old) =>
|
||
old ? { ...old, position_seconds: pos } : old,
|
||
);
|
||
}
|
||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
||
};
|
||
}, [id, qc]);
|
||
|
||
// --- 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();
|
||
}, []);
|
||
|
||
const applyVolume = useCallback((vol: number, mute: boolean) => {
|
||
const v = videoRef.current;
|
||
if (!v) return;
|
||
v.volume = clamp01(vol);
|
||
v.muted = mute;
|
||
}, []);
|
||
// Set volume/mute on the <video> AND persist (survives F5).
|
||
const setVol = useCallback(
|
||
(vol: number, mute: boolean) => {
|
||
applyVolume(vol, mute);
|
||
patchPrefs({ volume: clamp01(vol), muted: mute });
|
||
},
|
||
[applyVolume, patchPrefs],
|
||
);
|
||
const nudgeVolume = useCallback((d: number) => setVol(clamp01(prefs.volume + d), clamp01(prefs.volume + d) === 0), [prefs.volume, setVol]);
|
||
const toggleMute = useCallback(() => setVol(prefs.volume, !prefs.muted), [prefs.volume, prefs.muted, setVol]);
|
||
// Re-apply the persisted volume/mute to the <video> each time a session becomes ready (the element
|
||
// is fresh after a load/seek-restart, and starts at its default volume otherwise).
|
||
useEffect(() => {
|
||
if (ready) applyVolume(prefs.volume, prefs.muted);
|
||
}, [ready, applyVolume, prefs.volume, prefs.muted]);
|
||
// Mouse-wheel over the player adjusts volume (5 units per notch); overlays scroll normally.
|
||
const onWheel = useCallback(
|
||
(e: ReactWheelEvent) => {
|
||
if (infoOpenRef.current || helpOpenRef.current) return;
|
||
nudgeVolume(e.deltaY < 0 ? 0.05 : -0.05);
|
||
},
|
||
[nudgeVolume],
|
||
);
|
||
|
||
const toggleFs = useCallback(() => {
|
||
const el = wrapRef.current;
|
||
if (!el) return;
|
||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
||
else el.requestFullscreen().catch(() => {});
|
||
}, []);
|
||
useEffect(() => {
|
||
const onFs = () => setFs(!!document.fullscreenElement);
|
||
document.addEventListener("fullscreenchange", onFs);
|
||
return () => document.removeEventListener("fullscreenchange", onFs);
|
||
}, []);
|
||
|
||
const go = useCallback(
|
||
(otherId: string | null | undefined) => {
|
||
if (otherId) {
|
||
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;
|
||
|
||
// 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]);
|
||
|
||
// Switching AUDIO re-maps the track server-side, so it restarts the session at the current
|
||
// position (like a seek). The ref is updated synchronously so loadSession sees it.
|
||
const changeAudio = useCallback(
|
||
(ord: number | null) => {
|
||
audioRef.current = ord;
|
||
setAudioOrd(ord);
|
||
// Remember the LANGUAGE (not the ord) so F5 / the next item restores this choice. Menu stays open.
|
||
patchPrefs({ audioLang: ord == null ? "" : (detail?.audio_streams.find((s) => s.ord === ord)?.language ?? "") });
|
||
if (multiAudioRef.current && hlsRef.current) {
|
||
// Multi-rendition: switch the audio track CLIENT-SIDE — instant, same timeline, no restart.
|
||
try {
|
||
hlsRef.current.audioTrack = ord ?? 0;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
} else {
|
||
loadSession(absRef.current); // single-audio: re-map the track server-side (session restart)
|
||
}
|
||
},
|
||
[loadSession, detail, patchPrefs],
|
||
);
|
||
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
|
||
// (applied by the effect below), NO session restart. Off = null.
|
||
const changeSubtitle = useCallback(
|
||
(ord: number | null) => {
|
||
setSubOrd(ord);
|
||
patchPrefs({ subLang: ord == null ? "" : (detail?.subtitle_streams.find((s) => s.ord === ord)?.language ?? "") });
|
||
},
|
||
[detail, patchPrefs],
|
||
);
|
||
// 'A' cycles audio tracks; 'S' cycles subtitle (off → each text sub → off).
|
||
const cycleAudio = useCallback(() => {
|
||
const streams = detail?.audio_streams ?? [];
|
||
if (streams.length < 2) return;
|
||
const idx = streams.findIndex((s) => s.ord === (audioRef.current ?? 0));
|
||
const next = streams[(idx + 1) % streams.length];
|
||
changeAudio(next.ord === 0 ? null : next.ord);
|
||
}, [detail, changeAudio]);
|
||
const cycleSubtitle = useCallback(() => {
|
||
const subs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
|
||
const order: (number | null)[] = [null, ...subs.map((s) => s.ord)];
|
||
const i = order.findIndex((o) => o === subOrd);
|
||
changeSubtitle(order[(i + 1) % order.length]);
|
||
}, [detail, subOrd, changeSubtitle]);
|
||
// Audio A/V-sync offset: persist immediately (for the slider) but debounce the session restart so
|
||
// dragging doesn't respawn ffmpeg on every step.
|
||
const aoffTimer = useRef<number | null>(null);
|
||
const changeAudioOffset = useCallback(
|
||
(v: number) => {
|
||
patchPrefs({ audioOffset: v });
|
||
aoffRef.current = v;
|
||
if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
|
||
aoffTimer.current = window.setTimeout(() => loadSession(absRef.current), 500);
|
||
},
|
||
[patchPrefs, loadSession],
|
||
);
|
||
|
||
// Text subtitles offered in the picker; image subs (PGS/VobSub) can't be shown as text.
|
||
const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
|
||
// Render ONLY the selected sub as a single <track> (below) — rendering all of them and flipping
|
||
// modes raced (multiple "showing" at once → overlapping HU+EN) especially across a session restart.
|
||
const selectedSub = textSubs.find((s) => s.ord === subOrd) ?? null;
|
||
// The backend shifts the absolute-time VTT by (sessionK − userSubOffset): sessionK aligns it to the
|
||
// HLS session's zero-based clock; subtracting the user offset makes +offset show the sub LATER. Uses
|
||
// the DEBOUNCED user offset so slider drags don't refetch the whole VTT per step.
|
||
// sessionK is already lag-compensated (see loadSession), so it is the true content-zero the VTT
|
||
// must be shifted by; the user offset nudges on top.
|
||
const subTrackOffset = sessionK - subOffApplied;
|
||
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";
|
||
// Force the single mounted track to "showing" (its VTT loads on mount; `default` alone can be flaky
|
||
// after a remount). Re-runs when the selection or the shifted-track identity changes (fresh <track>).
|
||
useEffect(() => {
|
||
const video = videoRef.current;
|
||
if (!video) return;
|
||
for (const tr of Array.from(video.textTracks)) tr.mode = "showing";
|
||
}, [subOrd, id, subTrackOffset]);
|
||
|
||
// Position subtitle cues per the user's vertical setting (prefs.subPos). The video fills the viewport
|
||
// height, so a default (line "auto") cue renders at the extreme bottom and clips — line% lifts it.
|
||
// Re-applied on each cue change (hls.js streams cues in as subtitle segments load; no per-cue event).
|
||
useEffect(() => {
|
||
const video = videoRef.current;
|
||
if (!video) return;
|
||
const placeAll = () => {
|
||
for (const tr of Array.from(video.textTracks)) {
|
||
for (const c of Array.from(tr.cues || [])) {
|
||
try {
|
||
(c as VTTCue).snapToLines = false;
|
||
(c as VTTCue).line = prefs.subPos;
|
||
} catch {
|
||
/* not a positionable cue */
|
||
}
|
||
}
|
||
}
|
||
};
|
||
const bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll);
|
||
for (const tr of Array.from(video.textTracks)) bind(tr);
|
||
const onAddTrack = (e: TrackEvent) => {
|
||
if (e.track) {
|
||
bind(e.track as TextTrack);
|
||
placeAll();
|
||
}
|
||
};
|
||
video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
|
||
placeAll();
|
||
return () => {
|
||
video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
|
||
for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
|
||
};
|
||
}, [id, prefs.subPos]);
|
||
|
||
// Auto-advance to the next episode when one finishes.
|
||
useEffect(() => {
|
||
const video = videoRef.current;
|
||
if (!video) return;
|
||
const onEnded = () => {
|
||
api.plexSetState(id, "watched").catch(() => {});
|
||
if (nextId) go(nextId);
|
||
};
|
||
video.addEventListener("ended", onEnded);
|
||
return () => video.removeEventListener("ended", onEnded);
|
||
}, [id, nextId, go]);
|
||
|
||
// Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key).
|
||
const wake = useCallback(() => {
|
||
setUiVisible(true);
|
||
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
||
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
|
||
hideTimer.current = window.setTimeout(() => {
|
||
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
|
||
if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false);
|
||
}, 3000);
|
||
}, []);
|
||
|
||
// "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 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]);
|
||
|
||
// --- 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 " ":
|
||
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 "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":
|
||
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 (!document.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]);
|
||
|
||
const activeMarker: PlexMarker | undefined = detail?.markers.find(
|
||
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||
);
|
||
const activeMarkerRef = useRef(activeMarker);
|
||
activeMarkerRef.current = activeMarker;
|
||
|
||
// Auto-skip intro/credits: entering a marker whose auto-skip is on runs a countdown (skipProgress
|
||
// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — intro → jump to the
|
||
// marker end, credits → next item (binge). Esc/Backspace during the countdown cancels it (and must
|
||
// NOT navigate back). 0s delay = skip immediately.
|
||
const [skipProgress, setSkipProgress] = useState<number | null>(null);
|
||
skipProgressRef.current = skipProgress;
|
||
const cancelledMarkerRef = useRef<string | null>(null);
|
||
// The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress
|
||
// alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway.
|
||
const skipTimerRef = useRef<number | null>(null);
|
||
const doSkip = useCallback(
|
||
(m: PlexMarker) => {
|
||
setSkipProgress(null);
|
||
if (m.type === "credits" && nextId) go(nextId);
|
||
else seekTo(m.end_s);
|
||
},
|
||
[go, nextId, seekTo],
|
||
);
|
||
const cancelAutoSkip = useCallback(() => {
|
||
const m = activeMarkerRef.current;
|
||
if (m) cancelledMarkerRef.current = `${m.type}-${m.start_s}`; // don't re-arm this same marker
|
||
if (skipTimerRef.current) {
|
||
window.clearInterval(skipTimerRef.current); // actually stop the countdown, not just blank it
|
||
skipTimerRef.current = null;
|
||
}
|
||
setSkipProgress(null);
|
||
}, []);
|
||
cancelAutoSkipRef.current = cancelAutoSkip;
|
||
useEffect(() => {
|
||
const m = activeMarker;
|
||
if (!m) return setSkipProgress(null);
|
||
const key = `${m.type}-${m.start_s}`;
|
||
const enabled = m.type === "intro" ? prefs.autoSkipIntro : prefs.autoSkipCredits;
|
||
if (!enabled || cancelledMarkerRef.current === key) return setSkipProgress(null);
|
||
if (prefs.autoSkipDelay <= 0) return doSkip(m); // immediate
|
||
let elapsed = 0;
|
||
setSkipProgress(0);
|
||
const iv = window.setInterval(() => {
|
||
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
|
||
// deliberately-paused video. It resumes from where it stopped when playback resumes.
|
||
if (videoRef.current?.paused) return;
|
||
elapsed += 0.1;
|
||
const p = Math.min(1, elapsed / prefs.autoSkipDelay);
|
||
setSkipProgress(p);
|
||
if (p >= 1) {
|
||
window.clearInterval(iv);
|
||
skipTimerRef.current = null;
|
||
doSkip(m);
|
||
}
|
||
}, 100);
|
||
skipTimerRef.current = iv;
|
||
return () => {
|
||
window.clearInterval(iv);
|
||
skipTimerRef.current = null;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [activeMarker?.type, activeMarker?.start_s, prefs.autoSkipIntro, prefs.autoSkipCredits, prefs.autoSkipDelay, doSkip]);
|
||
|
||
// 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;
|
||
|
||
return (
|
||
<div
|
||
ref={wrapRef}
|
||
className="fixed left-0 top-0 z-50 bg-black flex items-center justify-center select-none"
|
||
onMouseMove={wake}
|
||
onClick={wake}
|
||
onWheel={onWheel}
|
||
// Everything in the player 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 → +25% like everything.
|
||
style={{
|
||
cursor: uiVisible ? "default" : "none",
|
||
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}
|
||
className="max-h-full max-w-full w-auto h-auto"
|
||
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>
|
||
|
||
{!ready && (
|
||
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
|
||
{loadError ? (
|
||
<span className="max-w-md text-red-300">{loadError}</span>
|
||
) : (
|
||
<span className="text-white/70">{t("plex.player.loading")}</span>
|
||
)}
|
||
</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 || skipProgress != null) && (
|
||
<BackClose onBack={backIntent} />
|
||
)}
|
||
|
||
{/* Top bar */}
|
||
<div
|
||
className={`absolute top-0 inset-x-0 p-4 bg-gradient-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-gradient-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 bg-black/90 px-1.5 py-0.5 text-[11px] 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-amber-400/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.playPause")} onClick={togglePlay} align="start">
|
||
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
||
</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"
|
||
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 bg-black/90 px-1.5 py-0.5 text-[11px] 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={tracksRef}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="glass-menu absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto 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 && (
|
||
<div
|
||
ref={menuRef}
|
||
onWheel={(e) => e.stopPropagation()}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="glass-menu absolute bottom-full right-0 mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
||
>
|
||
<div className="mb-1.5 flex gap-1">
|
||
{settingsTabs.map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setSettingsTab(tab.key)}
|
||
className={`flex-1 whitespace-nowrap rounded px-2 py-1 text-[11px] ${
|
||
activeTab === tab.key ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{activeTab === "sync" && (
|
||
<>
|
||
<Slider
|
||
label={t("plex.player.subOffset")}
|
||
value={prefs.subOffset}
|
||
min={-10}
|
||
max={10}
|
||
step={0.1}
|
||
suffix="s"
|
||
onChange={(v) => patchPrefs({ subOffset: v })}
|
||
onReset={() => patchPrefs({ subOffset: 0 })}
|
||
/>
|
||
<Slider
|
||
label={t("plex.player.audioOffset")}
|
||
value={prefs.audioOffset}
|
||
min={-5}
|
||
max={5}
|
||
step={0.1}
|
||
suffix="s"
|
||
onChange={changeAudioOffset}
|
||
onReset={() => changeAudioOffset(0)}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{activeTab === "playback" && (
|
||
<>
|
||
<Slider
|
||
label={t("plex.player.seekStep")}
|
||
value={prefs.seekStep}
|
||
min={5}
|
||
max={30}
|
||
step={1}
|
||
suffix="s"
|
||
onChange={(v) => patchPrefs({ seekStep: v })}
|
||
/>
|
||
<Slider
|
||
label={t("plex.player.fineSeek")}
|
||
value={prefs.fineSeekStep}
|
||
min={0.1}
|
||
max={5}
|
||
step={0.1}
|
||
suffix="s"
|
||
onChange={(v) => patchPrefs({ fineSeekStep: v })}
|
||
/>
|
||
<Toggle
|
||
label={t("plex.player.autoHide")}
|
||
checked={prefs.barAutoHide}
|
||
onChange={(v) => patchPrefs({ barAutoHide: v })}
|
||
/>
|
||
<Toggle
|
||
label={t("plex.player.autoSkipIntro")}
|
||
checked={prefs.autoSkipIntro}
|
||
onChange={(v) => patchPrefs({ autoSkipIntro: v })}
|
||
/>
|
||
<Toggle
|
||
label={t("plex.player.autoSkipCredits")}
|
||
checked={prefs.autoSkipCredits}
|
||
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
|
||
/>
|
||
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
|
||
<Slider
|
||
label={t("plex.player.autoSkipDelay")}
|
||
value={prefs.autoSkipDelay}
|
||
min={0}
|
||
max={10}
|
||
step={1}
|
||
suffix="s"
|
||
onChange={(v) => patchPrefs({ autoSkipDelay: v })}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{activeTab === "subtitle" && (
|
||
<>
|
||
<Slider
|
||
label={t("plex.player.subSize")}
|
||
value={prefs.subSize}
|
||
min={50}
|
||
max={250}
|
||
step={5}
|
||
suffix="%"
|
||
onChange={(v) => patchPrefs({ subSize: v })}
|
||
/>
|
||
<Slider
|
||
label={t("plex.player.subPos")}
|
||
value={prefs.subPos}
|
||
min={50}
|
||
max={95}
|
||
step={1}
|
||
suffix="%"
|
||
onChange={(v) => patchPrefs({ subPos: v })}
|
||
/>
|
||
<div className="flex items-center justify-between px-1 py-1.5">
|
||
<span className="text-[12px] text-white/80">{t("plex.player.subColor")}</span>
|
||
<input
|
||
type="color"
|
||
value={prefs.subColor}
|
||
onChange={(e) => patchPrefs({ subColor: e.target.value })}
|
||
className="h-6 w-10 cursor-pointer rounded bg-transparent"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between px-1 py-1.5">
|
||
<span className="text-[12px] text-white/80">{t("plex.player.subBg")}</span>
|
||
<div className="flex items-center gap-2">
|
||
<label className="flex items-center gap-1 text-[11px] text-white/70">
|
||
<input
|
||
type="checkbox"
|
||
checked={prefs.subBg === "transparent"}
|
||
onChange={(e) => patchPrefs({ subBg: e.target.checked ? "transparent" : "#000000" })}
|
||
className="accent-accent"
|
||
/>
|
||
{t("plex.player.subBgNone")}
|
||
</label>
|
||
{prefs.subBg !== "transparent" && (
|
||
<input
|
||
type="color"
|
||
value={prefs.subBg}
|
||
onChange={(e) => patchPrefs({ subBg: e.target.value })}
|
||
className="h-6 w-10 cursor-pointer rounded bg-transparent"
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<Slider
|
||
label={t("plex.player.subShadow")}
|
||
value={prefs.subShadow}
|
||
min={0}
|
||
max={10}
|
||
step={1}
|
||
suffix="px"
|
||
onChange={(v) => patchPrefs({ subShadow: v })}
|
||
/>
|
||
{prefs.subShadow > 0 && (
|
||
<div className="flex items-center justify-between px-1 py-1.5">
|
||
<span className="text-[12px] text-white/80">{t("plex.player.subShadowColor")}</span>
|
||
<input
|
||
type="color"
|
||
value={prefs.subShadowColor}
|
||
onChange={(e) => patchPrefs({ subShadowColor: e.target.value })}
|
||
className="h-6 w-10 cursor-pointer rounded bg-transparent"
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{activeTab === "clock" && (
|
||
<>
|
||
<Toggle
|
||
label={t("plex.player.clockShow")}
|
||
checked={prefs.clockShow}
|
||
onChange={(v) => patchPrefs({ clockShow: v })}
|
||
/>
|
||
<Toggle
|
||
label={t("plex.player.clock24h")}
|
||
checked={prefs.clock24h}
|
||
onChange={(v) => patchPrefs({ clock24h: v })}
|
||
/>
|
||
<Slider
|
||
label={t("plex.player.clockSize")}
|
||
value={prefs.clockSize}
|
||
min={70}
|
||
max={200}
|
||
step={5}
|
||
suffix="%"
|
||
onChange={(v) => patchPrefs({ clockSize: v })}
|
||
/>
|
||
<div className="flex items-center justify-between px-1 py-1.5">
|
||
<span className="text-[12px] text-white/80">{t("plex.player.clockColor")}</span>
|
||
<input
|
||
type="color"
|
||
value={prefs.clockColor}
|
||
onChange={(e) => patchPrefs({ clockColor: e.target.value })}
|
||
className="h-6 w-10 cursor-pointer rounded bg-transparent"
|
||
/>
|
||
</div>
|
||
<Toggle
|
||
label={t("plex.player.clockDate")}
|
||
checked={prefs.clockDate}
|
||
onChange={(v) => patchPrefs({ clockDate: v })}
|
||
/>
|
||
{prefs.clockDate && (
|
||
<div className="flex gap-1 px-1 py-1">
|
||
{(["hu", "en"] as const).map((st) => (
|
||
<button
|
||
key={st}
|
||
onClick={() => patchPrefs({ clockDateStyle: st })}
|
||
className={`flex-1 rounded px-2 py-1 text-[11px] ${
|
||
prefs.clockDateStyle === st ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
|
||
}`}
|
||
>
|
||
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<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>
|
||
|
||
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
|
||
{helpOpen && (
|
||
<div
|
||
className="absolute inset-0 z-20 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.back")}
|
||
>
|
||
<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"],
|
||
[["⌫", "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 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-20 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>
|
||
);
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// --- settings-panel building blocks ------------------------------------------------------------
|
||
function PanelHead({ children }: { children: ReactNode }) {
|
||
return <div className="px-1 pt-2 pb-1 text-[11px] uppercase tracking-wide text-white/50">{children}</div>;
|
||
}
|
||
function Slider({
|
||
label,
|
||
value,
|
||
min,
|
||
max,
|
||
step,
|
||
suffix,
|
||
onChange,
|
||
onReset,
|
||
}: {
|
||
label: string;
|
||
value: number;
|
||
min: number;
|
||
max: number;
|
||
step: number;
|
||
suffix?: string;
|
||
onChange: (v: number) => void;
|
||
onReset?: () => void;
|
||
}) {
|
||
return (
|
||
<div className="px-1 py-1">
|
||
<div className="mb-1 flex items-center justify-between text-[12px] text-white/80">
|
||
<span>{label}</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="tabular-nums text-white/60">
|
||
{Number.isInteger(value) ? value : value.toFixed(1)}
|
||
{suffix ?? ""}
|
||
</span>
|
||
{onReset && (
|
||
<button onClick={onReset} className="text-white/40 hover:text-white" aria-label="reset">
|
||
<RotateCcw className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min={min}
|
||
max={max}
|
||
step={step}
|
||
value={value}
|
||
onChange={(e) => onChange(Number(e.target.value))}
|
||
className="w-full accent-accent"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||
return (
|
||
<label className="flex cursor-pointer items-center justify-between px-1 py-1.5">
|
||
<span className="text-[12px] text-white/80">{label}</span>
|
||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="h-4 w-4 accent-accent" />
|
||
</label>
|
||
);
|
||
}
|
||
|
||
// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
|
||
// viewport, unlike a native title tooltip on the bottom control bar).
|
||
function Ctrl({
|
||
label,
|
||
onClick,
|
||
disabled,
|
||
align = "center",
|
||
children,
|
||
}: {
|
||
label: string;
|
||
onClick: () => void;
|
||
disabled?: boolean;
|
||
// Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
|
||
align?: "start" | "center" | "end";
|
||
children: ReactNode;
|
||
}) {
|
||
const pos = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
|
||
return (
|
||
<div className="relative flex group/ctrl">
|
||
<button
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
aria-label={label}
|
||
className="p-1 hover:text-accent disabled:opacity-30"
|
||
>
|
||
{children}
|
||
</button>
|
||
<span
|
||
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100`}
|
||
>
|
||
{label}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|