On an iPad the bar covered the text whenever it was up: the browser places a native cue by its line, which nothing animates. The track now stays loaded as hidden and PlexSubtitles draws the active cues, measured against the bar and lifted above it in 200 ms, back down when it hides. The viewer's size, colour, background and outline settings style the text directly. Also fixes a test typing slip from the search pill change.
1727 lines
79 KiB
TypeScript
1727 lines
79 KiB
TypeScript
import {
|
||
lazy,
|
||
Suspense,
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useRef,
|
||
useState,
|
||
useSyncExternalStore,
|
||
} from "react";
|
||
import { qk } from "../lib/queryKeys";
|
||
import { createPortal } from "react-dom";
|
||
import { useTranslation } from "react-i18next";
|
||
import { plexLang } from "../i18n";
|
||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||
import { useFocusKeeper } from "../lib/useFocusKeeper";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
ArrowLeft,
|
||
Clock,
|
||
Download,
|
||
Gauge,
|
||
Info,
|
||
Keyboard,
|
||
Languages,
|
||
Loader2,
|
||
Maximize,
|
||
Minimize,
|
||
MoreHorizontal,
|
||
Pause,
|
||
Play,
|
||
Settings,
|
||
SkipBack,
|
||
SkipForward,
|
||
Volume2,
|
||
VolumeX,
|
||
} from "lucide-react";
|
||
import { api, HttpError, type PlexTrackNaming } from "../lib/api";
|
||
import { notifyActionError } from "../lib/actionErrors";
|
||
import { useLatest } from "../lib/useLatest";
|
||
import { seekableEndOf } from "../lib/seekableEnd";
|
||
import {
|
||
audioOrdForLang,
|
||
clockParts,
|
||
DEFAULT_PREFS,
|
||
fmt,
|
||
subOrdForLang,
|
||
type PlexPlayerPrefs,
|
||
} from "../lib/plexPlayerPrefs";
|
||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||
import { createFragMeter } from "../lib/plexStats";
|
||
import { episodeLine } from "../lib/plexDetail";
|
||
import { HlsEngine } from "../lib/hlsEngine";
|
||
import { createHlsPort } from "../lib/hlsPort";
|
||
import { enabled, log } from "../lib/log";
|
||
import { logMediaElement } from "../lib/mediaLog";
|
||
import { useMediaSession } from "../lib/mediaSession";
|
||
import { UpNextCard } from "./UpNextCard";
|
||
import Toaster from "./ui/Toaster";
|
||
import { useOverlayOrder } from "./ui/overlayOrder";
|
||
import {
|
||
useAutoSkip,
|
||
useControlsVisibility,
|
||
useFullscreen,
|
||
usePlayerVolume,
|
||
usePlexProgress,
|
||
usePlexTracks,
|
||
useWallClock,
|
||
} from "../lib/plexPlayerHooks";
|
||
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
|
||
import {
|
||
dispatch,
|
||
isActivationTarget,
|
||
isTypingTarget,
|
||
toggleKeyboardHelp,
|
||
useHelpConditions,
|
||
useKeyboardHelpOpen,
|
||
useKeyScope,
|
||
} from "../lib/keymap";
|
||
import { LS, useAccountPersistedObject } from "../lib/storage";
|
||
import { useDismiss } from "../lib/useDismiss";
|
||
import { useScrollFade } from "../lib/useScrollFade";
|
||
import { useBackToClose } from "../lib/history";
|
||
import PlexSubtitles from "./PlexSubtitles";
|
||
import { lockBodyScroll, trapTab } from "./ui/dialogFocus";
|
||
import {
|
||
pushLayer,
|
||
removeLayer,
|
||
anyLayerAbove,
|
||
anyBlockingLayerAbove,
|
||
escapeHandled,
|
||
} from "../lib/layerStack";
|
||
import { Button } from "./ui/Button";
|
||
import { IconButton } from "./ui/IconButton";
|
||
import { MenuCheck } from "./ui/MenuCheck";
|
||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||
import { Slider } from "./ui/NativeControls";
|
||
import { formatUnit } from "../lib/format";
|
||
import { trackLabel } from "../lib/vocab";
|
||
|
||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||
|
||
// C5: Back restarts the item after this many seconds in, and steps to the previous one before it.
|
||
const BACK_TO_PREV_S = 3;
|
||
// C5: a touch double-tap on the picture's left or right third skips this far; each further tap
|
||
// inside the window skips again (the keyboard keeps ← / → at the viewer's own step).
|
||
// The player's menus sit on the always-dark media chrome, under white text, in every theme.
|
||
const PLAYER_MENU =
|
||
"rounded-xl border border-white/15 bg-media-chrome/95 text-sm text-white shadow-2xl";
|
||
const PLAYER_ROW = "block w-full rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
|
||
// A track choice: the chosen one wears the tick in the leading slot (DESIGN "one of several"), which
|
||
// the highlight's white text cannot wash out the way it did the accent-coloured label.
|
||
const TRACK_ROW =
|
||
"flex w-full min-h-9 items-center gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
|
||
const TAP_SKIP_S = 10;
|
||
const DOUBLE_TAP_MS = 300;
|
||
|
||
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
|
||
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
|
||
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
|
||
// currentTime = sessionStart + video.currentTime and, on a seek beyond the generated region,
|
||
// restart the session at the target. Watch state / resume persist to plex_states.
|
||
|
||
// `queue` (ordered rating_keys) drives play-through for a playlist: prev/next + auto-advance follow
|
||
// the queue instead of the item's own episode neighbours.
|
||
// `fromStart` (the card's Restart, C5) applies to the item the player opened with; every item it
|
||
// moves on to starts at its own resume point.
|
||
type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean };
|
||
type Panel = "none" | "more" | "tracks" | "settings" | "info" | "upNext";
|
||
|
||
export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) {
|
||
const { t, i18n } = useTranslation();
|
||
// A track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)").
|
||
const audioName = (a: PlexTrackNaming) => trackLabel(t, a, "audio", i18n.language);
|
||
const subtitleName = (x: PlexTrackNaming) => trackLabel(t, x, "subtitle", i18n.language);
|
||
const qc = useQueryClient();
|
||
const [id, setId] = useState(itemId);
|
||
// Serve the player's title/summary/rich detail in the active language, matching the grid.
|
||
const lang = plexLang(i18n.language);
|
||
const detailQ = useQuery({
|
||
queryKey: qk.plexItem(id, lang),
|
||
queryFn: () => api.plexItem(id, lang),
|
||
});
|
||
const detail = detailQ.data;
|
||
const duration = detail?.duration_seconds ?? 0;
|
||
|
||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||
const wrapRef = useRef<HTMLDivElement>(null);
|
||
// The bottom band, which the subtitles keep above while it shows.
|
||
const bottomBarRef = useRef<HTMLDivElement>(null);
|
||
// A control that goes while it holds the focus — Next onto the last item or a series finale —
|
||
// hands it to its neighbour instead of <body>. The player is portaled outside the
|
||
// page scroller, whose keeper does not watch it.
|
||
useFocusKeeper(wrapRef);
|
||
// The page stays put under the player: on an iPad a drag on the video scrolled (or rubber-banded) the
|
||
// document behind it, and the strip it moved showed the page's own background under the player.
|
||
// Locked, with no overscroll, and the root painted black so a sub-pixel gap reads as the player's.
|
||
useEffect(() => {
|
||
const unlock = lockBodyScroll();
|
||
const root = document.documentElement.style;
|
||
const before = { background: root.backgroundColor, overscroll: root.overscrollBehavior };
|
||
root.backgroundColor = "black";
|
||
root.overscrollBehavior = "none";
|
||
return () => {
|
||
unlock();
|
||
root.backgroundColor = before.background;
|
||
root.overscrollBehavior = before.overscroll;
|
||
};
|
||
}, []);
|
||
// The key of the last press that landed on faded bands, while it is held (see onKey).
|
||
const blindKeyRef = useRef<string | null>(null);
|
||
const durationRef = useLatest(duration);
|
||
// Rolling per-fragment bitrate meter for the stats overlay. Fed from hls.js FRAG_LOADED below and
|
||
// reset with each session, because the master playlist's own BANDWIDTH is not a measurement — for a
|
||
// remux it is the AAC track's nominal 192k + 10%, i.e. a constant 211 kbps (see plexStats.ts).
|
||
const fragMeterRef = useRef(createFragMeter());
|
||
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
|
||
// client-side (no restart). Ref so the engine and changeAudio read it fresh.
|
||
const multiAudioRef = useLatest((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 = useLatest(abs);
|
||
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
|
||
const { fs, toggleFs } = useFullscreen(wrapRef);
|
||
// 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);
|
||
|
||
// Per-account persisted player prefs — survive F5, carry across items.
|
||
const [prefs, patchPrefs] = useAccountPersistedObject<PlexPlayerPrefs>(
|
||
LS.plexPlayerPrefs,
|
||
DEFAULT_PREFS,
|
||
);
|
||
const { volume, muted } = prefs;
|
||
const aoffRef = useLatest(prefs.audioOffset);
|
||
const audioRef = useRef<number | null>(null);
|
||
|
||
// The session machine (lib/hlsEngine.ts): which server session the <video> plays, what a seek or a
|
||
// track change asks for, and which answers still belong to anything. One per player instance,
|
||
// connected to the player's refs from a layout effect — before any effect or handler can use it.
|
||
const [engine] = useState(() => new HlsEngine());
|
||
useLayoutEffect(
|
||
() =>
|
||
engine.connect({
|
||
media: () => videoRef.current,
|
||
requestSession: (itemId, startAt, p) =>
|
||
api.plexSession(itemId, startAt, p.multi ? null : p.audio, p.aoff, p.multi),
|
||
stopSession: (itemId, sessionId) => api.plexStopSession(itemId, sessionId),
|
||
createHls: (handlers) => {
|
||
// A new session is a new stream (another offset, maybe another audio rendition): the
|
||
// bitrate window starts empty rather than carrying the previous one's numbers over.
|
||
fragMeterRef.current.reset();
|
||
return createHlsPort(handlers);
|
||
},
|
||
params: () => ({
|
||
multi: multiAudioRef.current,
|
||
audio: audioRef.current,
|
||
aoff: aoffRef.current,
|
||
}),
|
||
position: () => absRef.current,
|
||
duration: () => durationRef.current,
|
||
now: () => Date.now(),
|
||
statusOf: (e) => (e instanceof HttpError ? e.status : undefined),
|
||
onLoadStart: () => setBuffering(false),
|
||
onFragLoaded: (frag) => fragMeterRef.current.add(frag),
|
||
// Fatal ones are what the viewer saw fail; the recoverable ones hls.js retries by itself only
|
||
// matter to a detailed diagnosis.
|
||
onHlsError: (e) => {
|
||
if (!e.fatal && !enabled("DEBUG")) return;
|
||
log.at(e.fatal ? "WARNING" : "DEBUG", "player.hls_error", `plex hls ${e.details}`, {
|
||
player: "plex",
|
||
fatal: e.fatal,
|
||
type: e.type,
|
||
details: e.details,
|
||
status: e.status,
|
||
});
|
||
},
|
||
}),
|
||
[engine, multiAudioRef, aoffRef, absRef, durationRef],
|
||
);
|
||
// `ready`: the INITIAL load of the current session is done. `transcode`: it re-encodes the video, so
|
||
// its cold start is genuinely slower than a remux and the overlay says "Preparing stream…" instead of
|
||
// a generic "Loading…". `sessionK`: its real media start — subtitles are absolute-time VTT shifted by
|
||
// it, so the <track> re-fetches when a seek-restart changes it.
|
||
const {
|
||
ready,
|
||
error: engineError,
|
||
transcode: sessionIsTranscode,
|
||
sessionK,
|
||
} = useSyncExternalStore(engine.subscribe, engine.getSnapshot);
|
||
const loadError =
|
||
engineError === "transcode"
|
||
? t("plex.player.errTranscode")
|
||
: engineError === "notFound"
|
||
? t("plex.player.errNotFound")
|
||
: engineError
|
||
? t("plex.player.errGeneric")
|
||
: null;
|
||
// Refs for values read inside stable callbacks / the keyboard handler (avoid stale closures).
|
||
const barAutoHideRef = useLatest(prefs.barAutoHide);
|
||
// The two language prefs are read only at ITEM LOAD (the detail effect below), so they go through
|
||
// a ref: naming them in that effect's deps would re-run it when the user picks a track from the
|
||
// player's own menu, double-loading the session the picker has already reloaded. The ref says "latest value, but not a trigger" in code, which an excused
|
||
// dep list only said in prose. Same shape as barAutoHideRef/seekStepRef either side of this.
|
||
const langPrefsRef = useLatest({ audioLang: prefs.audioLang, subLang: prefs.subLang });
|
||
const seekStepRef = useLatest(prefs.seekStep);
|
||
const fineSeekStepRef = useLatest(prefs.fineSeekStep);
|
||
// What is open over the video, one at a time: the fixed ⋯ (C5, the rare tools), the quick tracks
|
||
// menu, the tabbed tuning panel (from ⋯ → Settings), the rich info overlay ("I") and the up-next
|
||
// end-card. One value rather than a flag each, so two of them can never stand on top of each
|
||
// other and Back/Escape have exactly one thing to close. The stats readout ("D") is not in it: a
|
||
// corner readout, not a panel, which stays up beside them (the sync offset is tuned watching it).
|
||
const [panel, setPanel] = useState<Panel>("none");
|
||
const panelRef = useLatest(panel);
|
||
const infoOpenRef = useLatest(panel === "info"); // the wheel is the overlay's scroll, not volume
|
||
// A panel closes only itself: a popover's late "closed" must not shut the panel that replaced it.
|
||
const closePanel = useCallback((p: Panel) => setPanel((cur) => (cur === p ? "none" : cur)), []);
|
||
const togglePanel = useCallback((p: Panel) => setPanel((cur) => (cur === p ? "none" : p)), []);
|
||
// The tuning panel's tab outlives its closing: it reopens where it was left.
|
||
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
|
||
"sync",
|
||
);
|
||
// keep the control bar up while a menu is open
|
||
const menuOpenRef = useLatest(panel === "more" || panel === "tracks" || panel === "settings");
|
||
const menuRef = useRef<HTMLDivElement>(null);
|
||
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
|
||
const {
|
||
uiVisible,
|
||
controlsShown,
|
||
wake,
|
||
holdOnHover,
|
||
holdOnFocus,
|
||
toggle: toggleControls,
|
||
show: showControls,
|
||
} = useControlsVisibility({
|
||
barAutoHideRef,
|
||
menuOpenRef,
|
||
videoRef,
|
||
});
|
||
// The pointer resting on either band holds the controls up (F22), and so does a keyboard focus in
|
||
// one — not the focus a click leaves on a button, which would pin the bar until the next click;
|
||
// `:focus-visible` is the browser's own "this focus came from the keyboard". Both bands wear these.
|
||
const holdWhileHovered = {
|
||
"data-player-band": "", // what a key on a faded-out control is told apart by (onKey, below)
|
||
onPointerEnter: () => holdOnHover(true),
|
||
onPointerLeave: () => holdOnHover(false),
|
||
onFocus: (e: React.FocusEvent<HTMLElement>) => holdOnFocus(e.target.matches(":focus-visible")),
|
||
onBlur: (e: React.FocusEvent<HTMLElement>) => {
|
||
if (!e.currentTarget.contains(e.relatedTarget)) holdOnFocus(false);
|
||
},
|
||
};
|
||
// The tuning panel stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
|
||
// It renders inside the player, whose Tab trap spans it; the ⋯ counts as inside, since the ⋯ is
|
||
// what closes it (below).
|
||
useDismiss(panel === "settings", () => closePanel("settings"), [menuRef, menuTriggerRef]);
|
||
// The ⋯ and the tracks list are real menus on the popover engine: roving arrows,
|
||
// Home/End, Escape as a layer, and the focus back on the trigger however they close. They were
|
||
// hand-built <div>s — no roles, no arrows, and Escape left the focus on <body>. The engine portals
|
||
// them into the fullscreen element when there is one, so they show in fullscreen too.
|
||
const morePop = useAnchoredPopover({
|
||
open: panel === "more",
|
||
// The ⋯ also CLOSES the tuning panel it opened, rather than stacking the menu over it.
|
||
onOpenChange: (open) => {
|
||
if (!open) closePanel("more");
|
||
else setPanel((cur) => (cur === "settings" ? "none" : "more"));
|
||
},
|
||
placement: "top-end",
|
||
offset: 8,
|
||
// ↑/↓ are the volume wherever the focus is in the player, the ⋯ included.
|
||
openOnArrowKey: false,
|
||
});
|
||
const tracksPop = useAnchoredPopover({
|
||
open: panel === "tracks",
|
||
onOpenChange: (open) => (open ? setPanel("tracks") : closePanel("tracks")),
|
||
placement: "top-end",
|
||
offset: 8,
|
||
fitHeight: true,
|
||
openOnArrowKey: false,
|
||
});
|
||
const { setReference: setTracksReference } = tracksPop.refs;
|
||
const { ref: tracksFadeRef, style: tracksFadeStyle } = useScrollFade();
|
||
// The ⋯ is both the menu's trigger and the tuning panel's dismiss exception and focus return.
|
||
const setMoreReference = morePop.refs.setReference;
|
||
const moreTriggerRef = useCallback(
|
||
(node: HTMLButtonElement | null) => {
|
||
menuTriggerRef.current = node;
|
||
setMoreReference(node);
|
||
},
|
||
[setMoreReference],
|
||
);
|
||
// The keyboard cheat sheet is app-wide now (ui/KeyboardHelp, opened by the global "h"), not this
|
||
// player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to
|
||
// know an overlay is up before it steals a wheel scroll for volume.
|
||
const helpOpen = useKeyboardHelpOpen();
|
||
const helpOpenRef = useLatest(helpOpen);
|
||
|
||
// On-screen confirmation for a KEYBOARD track switch. A/S have long cycled audio and
|
||
// subtitles, but silently — you changed the track and the only way to see what you landed on was to open
|
||
// the tracks menu, which is exactly the modal round-trip the shortcut exists to avoid. The menu
|
||
// itself needs no OSD: it shows the selection where you clicked.
|
||
const [osd, setOsd] = useState<string | null>(null);
|
||
const osdTimer = useRef<number | null>(null);
|
||
const flashOsd = useCallback((text: string) => {
|
||
setOsd(text);
|
||
if (osdTimer.current) window.clearTimeout(osdTimer.current);
|
||
osdTimer.current = window.setTimeout(() => setOsd(null), 2200);
|
||
}, []);
|
||
useEffect(
|
||
() => () => {
|
||
if (osdTimer.current) window.clearTimeout(osdTimer.current);
|
||
},
|
||
[],
|
||
);
|
||
// "D" toggles the stats-for-nerds debug overlay (active mode, codec, dropped frames, buffer…).
|
||
const [statsOpen, setStatsOpen] = useState(false);
|
||
const statsOpenRef = useLatest(statsOpen);
|
||
const upNextPrefRef = useLatest(prefs.upNext);
|
||
const now = useWallClock();
|
||
|
||
// 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)start a session for `id` at a start offset -----------------------------
|
||
const restartSession = useCallback(
|
||
(startAt: number, play = true) => engine.start(id, startAt, play),
|
||
[engine, id],
|
||
);
|
||
|
||
// Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state; the shared
|
||
// media refs stay component-owned (the detail effect below writes audioRef too).
|
||
const {
|
||
audioOrd,
|
||
setAudioOrd,
|
||
subOrd,
|
||
setSubOrd,
|
||
changeAudio,
|
||
changeSubtitle,
|
||
cycleAudio,
|
||
cycleSubtitle,
|
||
changeAudioOffset,
|
||
textSubs,
|
||
selectedSub,
|
||
subTrackOffset,
|
||
subShowable,
|
||
subNotice,
|
||
subGen,
|
||
subTrackKey,
|
||
subTrackListeners,
|
||
} = usePlexTracks({
|
||
id,
|
||
detail,
|
||
sessionK,
|
||
subOffset: prefs.subOffset,
|
||
restartSession,
|
||
setAudioTrack: (track: number) => engine.setAudioTrack(track),
|
||
patchPrefs,
|
||
videoRef,
|
||
audioRef,
|
||
aoffRef,
|
||
playhead: () => engine.playhead(),
|
||
});
|
||
|
||
// Start playback when the detail arrives (resume from the saved position unless already watched) —
|
||
// ONCE per item. The detail object changes under a playing item too: react-query refetches it on
|
||
// reconnect (Wi-Fi blips, the query is stale after 30 s and every checkpoint moved the position),
|
||
// the in-player info panel invalidates it after a watched toggle, a language switch fetches it
|
||
// again. Keyed on its identity, each of those restarted the film at the last saved checkpoint — or
|
||
// at 0 after marking it watched. The trade-off: a detail that was stale in
|
||
// the cache when the player opened is not corrected by the refetch that follows; the player's own
|
||
// leave-patch (usePlexProgress) keeps the cache honest for everything played here.
|
||
const startedForRef = useRef<string | null>(null);
|
||
// Raised by the first `go()`: `fromStart` belongs to the item the player opened on, and coming BACK
|
||
// to that item (Back within 3 s of the next) resumes it like any other. A flag, not a consumed
|
||
// prop: StrictMode replays this effect, and a consumed one resumed on the replay.
|
||
const movedOnRef = useRef(false);
|
||
// An item `go` was asked to play from its start (a collection sibling's Restart in the info
|
||
// overlay); the opening item's own `fromStart` is the prop above.
|
||
const startAtZeroRef = useRef<string | null>(null);
|
||
useEffect(() => {
|
||
if (!detail || detail.id !== id || startedForRef.current === id) return;
|
||
startedForRef.current = id;
|
||
engine.newItem();
|
||
// Restore the per-account audio/subtitle language choice, resolved to THIS item's tracks. Set the
|
||
// audio ref synchronously so the session request below picks the right track on the first ask.
|
||
const { audioLang, subLang } = langPrefsRef.current;
|
||
const aOrd = audioOrdForLang(detail.audio_streams ?? [], audioLang);
|
||
audioRef.current = aOrd;
|
||
setAudioOrd(aOrd);
|
||
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], subLang));
|
||
const resume =
|
||
detail.status !== "watched" &&
|
||
!(fromStart && !movedOnRef.current) &&
|
||
startAtZeroRef.current !== id
|
||
? detail.position_seconds || 0
|
||
: 0;
|
||
restartSession(resume);
|
||
}, [
|
||
detail,
|
||
id,
|
||
fromStart,
|
||
engine,
|
||
restartSession,
|
||
audioRef,
|
||
langPrefsRef,
|
||
setAudioOrd,
|
||
setSubOrd,
|
||
]);
|
||
// Leaving the item: a session still in flight for it must not land after it, and its stream goes.
|
||
useEffect(
|
||
() => () => {
|
||
engine.leaveItem();
|
||
startedForRef.current = null;
|
||
},
|
||
[engine, id],
|
||
);
|
||
|
||
// Full media teardown on unmount (Back): the engine stops and detaches the <video> and its stream,
|
||
// so a late play() can't leave audio playing on a detached element after leaving the player.
|
||
//
|
||
// The setup half matters as much as the cleanup: React may run the cleanup and then MOUNT AGAIN
|
||
// (StrictMode does exactly that in development). Without `revive` the engine stayed closed for the
|
||
// life of the player — every play() is gated on it, so the player never started by itself, and a
|
||
// seek that restarts the session left it paused (bug hunt 2026-09, found on localdev).
|
||
//
|
||
// The element is captured HERE, not read in the cleanup: React detaches a ref before it runs an
|
||
// unmount's effect cleanups, so `videoRef.current` was already null there and the stop/detach
|
||
// never ran (only StrictMode's simulated unmount, which keeps refs, ever reached it).
|
||
useEffect(() => {
|
||
engine.revive();
|
||
const v = videoRef.current;
|
||
// An unload runs no effect cleanup: the server session is stopped here instead, unless the page
|
||
// goes into the back/forward cache and may come back to it.
|
||
const onHide = (e: PageTransitionEvent) => engine.pageHidden(e.persisted);
|
||
window.addEventListener("pagehide", onHide);
|
||
return () => {
|
||
window.removeEventListener("pagehide", onHide);
|
||
engine.destroy(v);
|
||
};
|
||
}, [engine]);
|
||
|
||
// --- time tracking + progress checkpoints ----------------------------------------------------
|
||
useEffect(() => {
|
||
const video = videoRef.current;
|
||
if (!video) return;
|
||
const onTime = () => {
|
||
const a = engine.absTime(video);
|
||
if (a == null) return; // the old session's clock (see HlsEngine's `live`)
|
||
setAbs(a);
|
||
const end = seekableEndOf(video, engine.sessionStart.current, a);
|
||
if (end != null) setSeekableEnd(end);
|
||
};
|
||
const onPlay = () => setPlaying(true);
|
||
const onPause = () => setPlaying(false);
|
||
// A seek that restarts the hls.js session detaches/re-attaches the media, firing 'emptied' —
|
||
// which silently flips paused=true WITHOUT a 'pause' event, so the play/pause pair alone let the
|
||
// button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events
|
||
// so the icon can never contradict the video.
|
||
const syncPlaying = () => setPlaying(!video.paused);
|
||
// Buffering spinner: `waiting`/`stalled` = playback stopped for lack of data; `seeking` = a seek is
|
||
// in flight. Cleared once data flows again (`playing`/`canplay`/`seeked`) or the user pauses (a
|
||
// deliberate pause isn't a stall, and would otherwise leave the spinner stuck).
|
||
const onWaiting = () => setBuffering(true);
|
||
const onResumed = () => setBuffering(false);
|
||
const detachLog = logMediaElement(video, "plex");
|
||
video.addEventListener("timeupdate", onTime);
|
||
video.addEventListener("play", onPlay);
|
||
video.addEventListener("pause", onPause);
|
||
video.addEventListener("emptied", syncPlaying);
|
||
video.addEventListener("canplay", syncPlaying);
|
||
video.addEventListener("seeked", syncPlaying);
|
||
video.addEventListener("waiting", onWaiting);
|
||
video.addEventListener("stalled", onWaiting);
|
||
video.addEventListener("seeking", onWaiting);
|
||
video.addEventListener("playing", onResumed);
|
||
video.addEventListener("canplay", onResumed);
|
||
video.addEventListener("seeked", onResumed);
|
||
video.addEventListener("pause", onResumed);
|
||
return () => {
|
||
detachLog();
|
||
video.removeEventListener("timeupdate", onTime);
|
||
video.removeEventListener("play", onPlay);
|
||
video.removeEventListener("pause", onPause);
|
||
video.removeEventListener("emptied", syncPlaying);
|
||
video.removeEventListener("canplay", syncPlaying);
|
||
video.removeEventListener("seeked", syncPlaying);
|
||
video.removeEventListener("waiting", onWaiting);
|
||
video.removeEventListener("stalled", onWaiting);
|
||
video.removeEventListener("seeking", onWaiting);
|
||
video.removeEventListener("playing", onResumed);
|
||
video.removeEventListener("canplay", onResumed);
|
||
video.removeEventListener("seeked", onResumed);
|
||
video.removeEventListener("pause", onResumed);
|
||
};
|
||
}, [id, engine]);
|
||
|
||
// Periodic + on-unmount progress checkpoint (interval flush + pause/pagehide + reopen cache patch).
|
||
const { settle: settleProgress, finish: finishProgress } = usePlexProgress({
|
||
id,
|
||
qc,
|
||
videoRef,
|
||
absRef,
|
||
durationRef,
|
||
});
|
||
|
||
// --- seeking (native within the loaded region, else restart the session) ---------------------
|
||
const seekTo = useCallback(
|
||
(target: number, play?: boolean) => {
|
||
const T = engine.clamp(target);
|
||
// Reflect the target immediately so a save/beacon right after a seek is accurate — in the STATE
|
||
// too: `absRef` is re-synced from `abs` after every commit, and a restart renders at once, so
|
||
// the ref alone was back at the pre-seek spot before anything could read it.
|
||
absRef.current = T;
|
||
setAbs(T);
|
||
engine.seek(id, T, play);
|
||
},
|
||
[engine, id, absRef],
|
||
);
|
||
|
||
// --- controls --------------------------------------------------------------------------------
|
||
// While a session is being asked for, play and pause are the pending session's intent (see
|
||
// HlsEngine.requestPlay): the media has not changed, so the button shows the intent itself.
|
||
const requestPlay = useCallback(
|
||
(want: boolean) => {
|
||
if (engine.requestPlay(want)) setPlaying(want);
|
||
},
|
||
[engine],
|
||
);
|
||
const togglePlay = useCallback(() => requestPlay(!engine.shownPlaying()), [engine, requestPlay]);
|
||
|
||
// The OS's media controls (lock screen, headset, media keys) drive the player through its own seek
|
||
// and play intent and read its own clock: the element's starts at the stream's start (a transcode
|
||
// begins at the resume point), and a seek outside the stream restarts it.
|
||
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
||
useMediaSession(
|
||
videoEl,
|
||
detail && {
|
||
title: detail.kind === "episode" ? episodeLine(detail) : detail.title,
|
||
artist:
|
||
detail.kind === "episode" ? detail.show_title : detail.year ? String(detail.year) : null,
|
||
artwork: api.plexImageUrl(id),
|
||
},
|
||
{
|
||
play: () => requestPlay(true),
|
||
pause: () => requestPlay(false),
|
||
seekTo: (s) => seekTo(s),
|
||
position: () => engine.playhead(),
|
||
duration: () => durationRef.current,
|
||
},
|
||
);
|
||
|
||
// Volume/mute for the HTML5 <video> (0..1, independent mute), persisted + re-applied per session.
|
||
const { setVol, nudgeVolume, toggleMute, onWheel } = usePlayerVolume({
|
||
videoRef,
|
||
volume,
|
||
muted,
|
||
ready,
|
||
patchPrefs,
|
||
infoOpenRef,
|
||
helpOpenRef,
|
||
});
|
||
|
||
const reportWatchedError = useCallback(
|
||
(e: unknown) => notifyActionError(e, t("plex.watchedFailed")),
|
||
[t],
|
||
);
|
||
|
||
const go = useCallback(
|
||
(otherId: string | null | undefined, watchedWrite?: Promise<unknown>, fromZero = false) => {
|
||
if (otherId && otherId === id) {
|
||
// The item already playing (the info overlay's collection strip lists the film itself):
|
||
// nothing to switch to. The switch below would pause it and stop trusting its clock, and
|
||
// `setId` to the same id loads nothing — a frozen 0:00. The play
|
||
// intent goes INTO the seek: a paused film whose session starts past 0 restarts the session,
|
||
// and a play() beside that resumed the outgoing stream while the new one stayed paused.
|
||
if (fromZero) seekTo(0, true);
|
||
else requestPlay(true);
|
||
return;
|
||
}
|
||
if (otherId) {
|
||
startAtZeroRef.current = fromZero ? otherId : null;
|
||
// A played-through item settles as watched, with no checkpoint at the credits (see
|
||
// usePlexProgress's `finish`) — said BEFORE the pause below, whose own checkpoint on the
|
||
// 'pause' event can land before the player has re-rendered for the next item. The write is
|
||
// handed over so a REFUSED one takes the finish back instead of leaving the cache lying.
|
||
if (watchedWrite) finishProgress(watchedWrite);
|
||
// Stop the outgoing media BEFORE switching. If the next item fails to load (e.g. no local
|
||
// file), the failed session request leaves the <video> source in place, 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.
|
||
const outgoing = videoRef.current;
|
||
if (outgoing) {
|
||
try {
|
||
outgoing.pause();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
// Settle the item being left while the position still is its own (see usePlexProgress), and
|
||
// stop trusting the old media's clock: the timeupdate that pause() queues lands after the
|
||
// reset below and would otherwise hand the old position to the new item.
|
||
settleProgress();
|
||
engine.releaseClock();
|
||
movedOnRef.current = true;
|
||
setAbs(0);
|
||
setId(otherId);
|
||
}
|
||
},
|
||
[id, engine, seekTo, requestPlay, settleProgress, finishProgress],
|
||
);
|
||
|
||
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
|
||
const qIdx = queue ? queue.indexOf(id) : -1;
|
||
const prevId = queue ? (qIdx > 0 ? queue[qIdx - 1] : null) : detail?.prev_id;
|
||
const nextId = queue
|
||
? qIdx >= 0 && qIdx < queue.length - 1
|
||
? queue[qIdx + 1]
|
||
: null
|
||
: detail?.next_id;
|
||
// Whether the row has a Next. An episode's next is in its detail, unknown while the next episode
|
||
// loads; the slot keeps its last answer meanwhile (a disabled Next), because the button unmounting
|
||
// took the focus that had just pressed it to <body>. Adjusted during render.
|
||
const nextKnown = queue || !detailQ.isPending ? !!nextId : undefined;
|
||
const [nextSlot, setNextSlot] = useState(false);
|
||
if (nextKnown !== undefined && nextKnown !== nextSlot) setNextSlot(nextKnown);
|
||
const showNext = nextKnown ?? nextSlot;
|
||
|
||
// Back (C5): past the first BACK_TO_PREV_S seconds it restarts the item; inside them it steps to the
|
||
// previous one (at its resume point, as `go` opens every item) — or restarts again on the first.
|
||
const backToPrev = abs <= BACK_TO_PREV_S && !!prevId;
|
||
const backOrPrev = useCallback(() => {
|
||
if (engine.playhead() <= BACK_TO_PREV_S && prevId) go(prevId);
|
||
else seekTo(0);
|
||
}, [prevId, go, seekTo, engine]);
|
||
|
||
// What the last press came from. A touch tap on the picture only shows or hides the controls (C3:
|
||
// measured live, a tap used to reveal the bar AND pause); a mouse click there still plays/pauses.
|
||
// Kept from `pointerdown`, because the `click` that follows is not a PointerEvent everywhere.
|
||
const pointerRef = useRef("mouse");
|
||
// The picture's taps: one shows/hides the controls; a second within DOUBLE_TAP_MS on the left or
|
||
// right third skips ∓TAP_SKIP_S, as does each further one (C5), and takes back the first tap's
|
||
// show/hide. The middle third has no skip, so a quick second tap there just toggles again.
|
||
const tapRef = useRef<{ at: number; zone: number; wasShown: boolean; skips: number } | null>(
|
||
null,
|
||
);
|
||
// A tap that brings the bands back keeps them out of the pointer's way until a double-tap can no
|
||
// longer follow: the second tap of a skip at the bottom would otherwise land on the
|
||
// band the first one just revealed — the seek bar, a seek to wherever the finger was.
|
||
const [tapShield, setTapShield] = useState(false);
|
||
const tapShieldTimer = useRef<number | undefined>(undefined);
|
||
useEffect(() => () => window.clearTimeout(tapShieldTimer.current), []);
|
||
const onPictureClick = useCallback(
|
||
(e: React.MouseEvent<HTMLVideoElement>) => {
|
||
if (pointerRef.current !== "touch") {
|
||
togglePlay();
|
||
return;
|
||
}
|
||
// The wrapper's own click wakes the controls, which would undo a hide.
|
||
e.stopPropagation();
|
||
const r = e.currentTarget.getBoundingClientRect();
|
||
const x = r.width > 0 ? (e.clientX - r.left) / r.width : 0.5;
|
||
const zone = x < 1 / 3 ? -1 : x > 2 / 3 ? 1 : 0;
|
||
const now = performance.now();
|
||
const prev = tapRef.current;
|
||
if (zone !== 0 && prev && prev.zone === zone && now - prev.at < DOUBLE_TAP_MS) {
|
||
if (prev.skips === 0) showControls(prev.wasShown);
|
||
const skips = prev.skips + 1;
|
||
tapRef.current = { ...prev, at: now, skips };
|
||
seekTo(engine.playhead() + zone * TAP_SKIP_S);
|
||
flashOsd(
|
||
t(zone < 0 ? "plex.player.tapBack" : "plex.player.tapForward", {
|
||
d: formatUnit(skips * TAP_SKIP_S, "second", lang),
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
tapRef.current = { at: now, zone, wasShown: uiVisible, skips: 0 };
|
||
if (!uiVisible) {
|
||
setTapShield(true);
|
||
window.clearTimeout(tapShieldTimer.current);
|
||
tapShieldTimer.current = window.setTimeout(() => setTapShield(false), DOUBLE_TAP_MS);
|
||
}
|
||
toggleControls();
|
||
},
|
||
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, lang, uiVisible, engine],
|
||
);
|
||
|
||
// Auto-skip intro/credits (countdown → jump to marker end / next item). The refs bridge to the
|
||
// earlier keyboard/back handlers that cancel a running countdown.
|
||
const { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip } = useAutoSkip({
|
||
id,
|
||
abs,
|
||
markers: detail?.markers,
|
||
autoSkipIntro: prefs.autoSkipIntro,
|
||
autoSkipCredits: prefs.autoSkipCredits,
|
||
autoSkipDelay: prefs.autoSkipDelay,
|
||
nextId,
|
||
go,
|
||
seekTo,
|
||
videoRef,
|
||
reportWatchedError,
|
||
});
|
||
|
||
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
|
||
const download = useCallback(() => {
|
||
const a = document.createElement("a");
|
||
a.href = api.plexDownloadUrl(id);
|
||
a.rel = "noopener";
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
}, [id]);
|
||
|
||
const settingsTabs = [
|
||
{ key: "sync" as const, label: t("plex.player.sync") },
|
||
{ key: "playback" as const, label: t("plex.player.playback") },
|
||
...(textSubs.length > 0
|
||
? [{ key: "subtitle" as const, label: t("plex.player.subStyle") }]
|
||
: []),
|
||
{ key: "clock" as const, label: t("plex.player.clock") },
|
||
];
|
||
const activeTab = settingsTabs.some((x) => x.key === settingsTab) ? settingsTab : "sync";
|
||
|
||
// The ⋯ menu's rows (C5), in the lab's order. Settings and the media info need the item's detail.
|
||
const ico = "h-4 w-4 shrink-0";
|
||
const moreItems = [
|
||
...(detail
|
||
? [
|
||
{
|
||
id: "settings",
|
||
label: t("plex.player.settings"),
|
||
icon: <Settings className={ico} />,
|
||
run: () => setPanel("settings"),
|
||
},
|
||
{
|
||
id: "info",
|
||
label: t("plex.player.infoBtn"),
|
||
icon: <Info className={ico} />,
|
||
run: () => togglePanel("info"),
|
||
},
|
||
]
|
||
: []),
|
||
{
|
||
id: "stats",
|
||
label: t("plex.player.stats.title"),
|
||
icon: <Gauge className={ico} />,
|
||
run: () => setStatsOpen((v) => !v),
|
||
},
|
||
{
|
||
id: "help",
|
||
label: t("plex.player.help"),
|
||
icon: <Keyboard className={ico} />,
|
||
run: toggleKeyboardHelp,
|
||
},
|
||
{
|
||
id: "download",
|
||
label: t("plex.player.download"),
|
||
icon: <Download className={ico} />,
|
||
run: download,
|
||
},
|
||
];
|
||
|
||
// Auto-advance to the next episode when one finishes.
|
||
useEffect(() => {
|
||
const video = videoRef.current;
|
||
if (!video) return;
|
||
const onEnded = () => {
|
||
// Only a genuine play-to-the-end counts. A next episode that failed to load never played
|
||
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
|
||
if (absRef.current <= 0) return;
|
||
const watchedWrite = api.plexSetState(id, "watched");
|
||
watchedWrite.catch(reportWatchedError);
|
||
// Whatever follows (the up-next card, the next item, closing) leaves a FINISHED item — unless
|
||
// the server refuses the mark, in which case `finish` takes itself back (see usePlexProgress).
|
||
finishProgress(watchedWrite);
|
||
// With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel)
|
||
// and let IT advance — binge stays in the viewer's control. 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) setPanel("upNext");
|
||
else if (nextId) go(nextId);
|
||
// Nothing queued next → dismiss the player back to the previous page (same as Stop/Escape),
|
||
// instead of stranding a black, ended <video> on screen.
|
||
else onClose();
|
||
};
|
||
video.addEventListener("ended", onEnded);
|
||
return () => video.removeEventListener("ended", onEnded);
|
||
}, [id, nextId, go, onClose, finishProgress, reportWatchedError, absRef, upNextPrefRef]);
|
||
|
||
// 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(() => {
|
||
closePanel("upNext");
|
||
if (nextId) go(nextId);
|
||
}, [nextId, go, closePanel]);
|
||
// The "up next" card belongs to the item that was playing, so a queue advance closes it. Adjusted
|
||
// during the render that changes `id` rather than in an effect: as an effect it closed one commit
|
||
// after the new episode had already drawn behind it.
|
||
const [upNextForId, setUpNextForId] = useState(id);
|
||
if (upNextForId !== id) {
|
||
setUpNextForId(id);
|
||
closePanel("upNext");
|
||
}
|
||
|
||
// The ONE Back cascade for the player's own ways of saying "back": the on-screen arrow, ⌫ and
|
||
// Escape. It peels off what is layered over the video — the auto-skip countdown, then the open
|
||
// panel, then the stats readout — and only then leaves the player. The mouse/browser Back peels
|
||
// the layers the viewer OPENED one per press, the one opened last first (each holds its own history
|
||
// entry, see BackClose below); the ones that open by themselves have none, so Back leaves past
|
||
// them. The shortcuts sheet is not in it: a Modal above us, with its own Escape layer and
|
||
// Back-to-close. Leaving closes whatever is still up first (`useHistorySubview`'s back).
|
||
// - The on-screen arrow is a click on "leave", not a cancel, so it does not stop a countdown.
|
||
// - Escape in fullscreen leaves fullscreen (the browser's own Escape) and not the player.
|
||
const back = useCallback(
|
||
(from: "arrow" | "key" | "escape") => {
|
||
if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||
else if (panelRef.current !== "none") setPanel("none");
|
||
else if (statsOpenRef.current) setStatsOpen(false);
|
||
else if (from !== "escape" || !fullscreenElement()) onClose();
|
||
},
|
||
[onClose, panelRef, statsOpenRef, skipProgressRef, cancelAutoSkipRef],
|
||
);
|
||
// The mouse/browser Back closes the layer whose history entry it popped (see BackClose below).
|
||
const closeStats = useCallback(() => setStatsOpen(false), []);
|
||
const closeAnyPanel = useCallback(() => setPanel("none"), []);
|
||
|
||
// Tell the shared cheat sheet which keys are live here, and whether ⇧← / ⇧→ apply (they step
|
||
// episodes, so they are listed only inside a series).
|
||
useKeyScope("plexPlayer");
|
||
useHelpConditions({ episode: detail?.kind === "episode" });
|
||
|
||
// Its toast host's rank among open overlays (see ui/Toaster).
|
||
const overlayOrder = useOverlayOrder();
|
||
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
|
||
// settings/tracks menus included) takes Escape first and this player defers.
|
||
const layerIdRef = useRef<number | null>(null);
|
||
useEffect(() => {
|
||
const id = pushLayer();
|
||
layerIdRef.current = id;
|
||
return () => {
|
||
removeLayer(id);
|
||
layerIdRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
// --- keyboard --------------------------------------------------------------------------------
|
||
useEffect(() => {
|
||
const seekBy = (e: KeyboardEvent, dir: -1 | 1, fine: boolean) => {
|
||
e.preventDefault();
|
||
const step = fine ? fineSeekStepRef.current : seekStepRef.current;
|
||
seekTo(engine.playhead() + dir * step);
|
||
};
|
||
const step = (e: KeyboardEvent, id: string | null | undefined) => {
|
||
e.preventDefault();
|
||
go(id);
|
||
};
|
||
const onKey = (e: KeyboardEvent) => {
|
||
// A Space or Enter on a control the bands have faded out from under (a key user's focus stays
|
||
// put when they hide) must not press it unseen — Back leaving the player, Next skipping the
|
||
// episode. That key only brings the bands back; Space also does what a viewer who sees no
|
||
// button means by it, and pauses or plays. Read before `wake` below shows them.
|
||
const blind =
|
||
!controlsShown() &&
|
||
isActivationTarget(e) &&
|
||
!!(e.target as Element | null)?.closest?.("[data-player-band]") &&
|
||
(e.key === " " || e.key === "Enter");
|
||
// Held, the key repeats — and by its first repeat the bands are up, so the repeat would press
|
||
// the control after all (Enter clicks on every repeat, Space re-arms its keyup click). A held
|
||
// key is one press (keymap.ts): its repeats are as blind as it was.
|
||
if (!e.repeat) blindKeyRef.current = blind ? e.key : null;
|
||
const swallow = blind || (e.repeat && e.key === blindKeyRef.current);
|
||
// Keyboard use also reveals the controls — but typing in a player field is not "using the
|
||
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
|
||
// opts in), which is why this is a guard around `wake` rather than an early return.
|
||
// A slider is not a text field: its arrows are the remote too.
|
||
if (!isTypingTarget(e) || (e.target as Element | null)?.matches?.('input[type="range"]'))
|
||
wake();
|
||
if (swallow) {
|
||
e.preventDefault();
|
||
if (e.key === " " && !e.repeat) togglePlay();
|
||
return;
|
||
}
|
||
// The shared dialog Tab trap. This player paints an opaque full-viewport layer over the app,
|
||
// so Tab leaving it lands on a control nobody can see, where the ring is invisible and the
|
||
// next Enter fires something blind. Every other overlay in the tree traps (ui/Modal,
|
||
// PlayerModal, OnboardingWizard, Welcome); this one never did, and a review caught
|
||
// it when the recorder's step prompt — which docks in this player's toast host — handed its
|
||
// own trap to "the dialog below" and found none. Only while nothing that owns the keyboard is
|
||
// layered above: a Modal opened over the player traps for itself. `wake()` above has already
|
||
// revealed the control band, so the trap cannot leave focus on a faded-out button.
|
||
if (e.key === "Tab") {
|
||
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
|
||
if (wrapRef.current) trapTab(e, wrapRef.current);
|
||
return;
|
||
}
|
||
dispatch(e, "plexPlayer", {
|
||
playPause: (ev) => {
|
||
// Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
|
||
// playback when the focus isn't on a control — the same shared rule PlayerModal uses.
|
||
if (isActivationTarget(ev)) return;
|
||
ev.preventDefault();
|
||
togglePlay();
|
||
},
|
||
fullscreen: () => toggleFs(),
|
||
seekBack: (ev) => seekBy(ev, -1, false),
|
||
seekFwd: (ev) => seekBy(ev, 1, false),
|
||
fineBack: (ev) => seekBy(ev, -1, true),
|
||
fineFwd: (ev) => seekBy(ev, 1, true),
|
||
prevEpisode: (ev) => step(ev, prevId),
|
||
nextEpisode: (ev) => step(ev, nextId),
|
||
volumeUp: (ev) => {
|
||
ev.preventDefault();
|
||
nudgeVolume(0.05);
|
||
},
|
||
volumeDown: (ev) => {
|
||
ev.preventDefault();
|
||
nudgeVolume(-0.05);
|
||
},
|
||
mute: () => toggleMute(),
|
||
audio: () => {
|
||
const ord = cycleAudio();
|
||
if (ord === undefined) return; // one track (or none): nothing to say
|
||
const track = detail?.audio_streams.find((a) => a.ord === (ord ?? 0));
|
||
flashOsd(
|
||
`${t("plex.player.audio")} · ${track ? trackLabel(t, track, "audio", i18n.language) : "—"}`,
|
||
);
|
||
},
|
||
subtitle: () => {
|
||
const ord = cycleSubtitle();
|
||
if (ord === undefined) return;
|
||
const track = ord == null ? null : detail?.subtitle_streams.find((x) => x.ord === ord);
|
||
const label =
|
||
ord == null
|
||
? t("plex.player.subOff")
|
||
: track
|
||
? trackLabel(t, track, "subtitle", i18n.language)
|
||
: "—";
|
||
flashOsd(`${t("plex.player.subtitles")} · ${label}`);
|
||
},
|
||
info: () => togglePanel("info"),
|
||
stats: () => setStatsOpen((v) => !v),
|
||
back: (ev) => {
|
||
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
|
||
// Back button (shared cascade).
|
||
// Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY
|
||
// and its focused close button is not a typing target, so the key reaches us regardless.
|
||
// Two wrong answers so far: deferring to nothing at all (the first bug — the cheat sheet
|
||
// took ⌫ and the player stopped underneath it), then deferring to `anyLayerAbove` outright
|
||
// (the first fix, the second bug — the player's OWN gear/tracks popovers push layers too, so
|
||
// ⌫ stopped closing them), then naming just the sheet and palette (the second fix, the third
|
||
// bug — the app-wide ErrorDialog is neither, so ⌫ tore the player down under it).
|
||
// The rule that holds: the player's own panels answer first, because `back`'s cascade is
|
||
// what closes them; anything else layered above us takes the key.
|
||
const ownPanelOpen = panelRef.current !== "none" || statsOpenRef.current;
|
||
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
|
||
return;
|
||
ev.preventDefault();
|
||
back("key");
|
||
},
|
||
escape: (ev) => {
|
||
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
|
||
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
|
||
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the
|
||
// consumed-event flag first (UX-8): a layer's `document` listener runs before this `window`
|
||
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
|
||
if (escapeHandled(ev)) return;
|
||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||
back("escape");
|
||
},
|
||
});
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [
|
||
togglePlay,
|
||
toggleFs,
|
||
seekTo,
|
||
go,
|
||
nudgeVolume,
|
||
toggleMute,
|
||
cycleAudio,
|
||
cycleSubtitle,
|
||
flashOsd,
|
||
t,
|
||
i18n.language,
|
||
detail,
|
||
prevId,
|
||
nextId,
|
||
onClose,
|
||
wake,
|
||
controlsShown,
|
||
back,
|
||
togglePanel,
|
||
engine,
|
||
seekStepRef,
|
||
fineSeekStepRef,
|
||
panelRef,
|
||
statsOpenRef,
|
||
]);
|
||
|
||
// Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
|
||
// (0..1), not pixels: the bar sits inside the transform:scale(1.25) subtree, so a px `left` would be
|
||
// re-scaled and drift right of the cursor (only 0 stayed correct). A % `left` is relative to the
|
||
// bar's own width and scales cleanly.
|
||
const [hover, setHover] = useState<{ frac: number; t: number } | null>(null);
|
||
// Seekbar scrubbing: the absolute time under the dragging finger. While non-null the head/thumb
|
||
// preview this position; the real seek fires once on release (avoids restarting the session on
|
||
// every out-of-buffer step of the drag). A plain click is a down+up at one spot → seeks there.
|
||
const [scrub, setScrub] = useState<number | null>(null);
|
||
// The volume slider shows its level while the pointer is on it.
|
||
const [volHover, setVolHover] = useState(false);
|
||
|
||
const pct = duration > 0 ? (abs / duration) * 100 : 0;
|
||
const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;
|
||
// While scrubbing the head follows the finger (preview); otherwise it tracks playback.
|
||
const headPct = scrub != null && duration > 0 ? (scrub / duration) * 100 : pct;
|
||
// The tracks playing now, for the stats overlay.
|
||
const currentAudio = detail?.audio_streams.find((x) => x.ord === (audioOrd ?? 0));
|
||
const currentSub = detail?.subtitle_streams.find((x) => x.ord === subOrd);
|
||
|
||
// Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal,
|
||
// BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the
|
||
// moment that scroller gets a mask/filter/transform/contain it becomes a stacking context, and
|
||
// this z-overlay is then scoped inside it — the rail, the side panel and the header paint straight
|
||
// over the player. That is exactly what the scroller's edge-fade did before this moved out.
|
||
return createPortal(
|
||
<div
|
||
ref={wrapRef}
|
||
className="fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none"
|
||
onPointerDown={(e) => {
|
||
pointerRef.current = e.pointerType;
|
||
}}
|
||
// A pointer MOVE wakes the bar; a touch has none of its own, and the mouse events a browser
|
||
// fakes after a tap would show the bar the tap just hid.
|
||
onPointerMove={(e) => {
|
||
if (e.pointerType !== "touch") wake();
|
||
}}
|
||
onClick={wake}
|
||
onWheel={onWheel}
|
||
// Windowed (pseudo-fullscreen): everything is 25% larger for lean-back / HTPC legibility. Laid
|
||
// out at 80%×80% and scaled ×1.25 from the top-left so it fills the viewport exactly (no
|
||
// overflow, controls stay on-screen). The size is a share of the fixed box's own containing block,
|
||
// the layout viewport, not `dvh`: in an iPad browser built on its own web view (Brave) `dvh` did
|
||
// not follow the toolbar, the player came out a few px short and the page showed under it. We use `transform: scale` rather than `zoom` deliberately:
|
||
// under `zoom`, event clientX stays in CSS pixels while getBoundingClientRect returns zoomed
|
||
// pixels, so the seek/volume/hover math (clientX − rect.left)/rect.width breaks; with `transform`
|
||
// both are in the same viewport space, so that ratio stays correct. Skip buttons ride this too.
|
||
//
|
||
// In NATIVE fullscreen we must NOT carry the ×1.25 transform: with the element at 80%×80%, 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 (the whole containing block) 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: "100%", height: "100%" }
|
||
: {
|
||
width: "80%",
|
||
height: "80%",
|
||
transform: "scale(1.25)",
|
||
transformOrigin: "top left",
|
||
}),
|
||
}}
|
||
>
|
||
<video
|
||
ref={(el) => {
|
||
videoRef.current = el;
|
||
setVideoEl(el);
|
||
// So Brave iOS's background play keeps it going when the app is left (lib/browserBackgroundPlay).
|
||
exposeVideoToBackgroundPlay(el);
|
||
}}
|
||
// Fill the wrapper (which is sized to the viewport / screen) and letterbox by aspect via
|
||
// object-contain. NOT `w-auto h-auto max-*`: that caps the video at its intrinsic resolution
|
||
// (a 720p stream → 1280×720) and never scales it UP, so in fullscreen it sat tiny and centred.
|
||
// `touch-manipulation`: a double-tap is the skip, never the browser's zoom.
|
||
className="w-full h-full object-contain touch-manipulation"
|
||
onClick={onPictureClick}
|
||
playsInline
|
||
>
|
||
{/* Text subtitles as a native track (same-origin → sent with the session cookie), kept
|
||
`hidden`: the browser fetches and parses the VTT, PlexSubtitles below draws the cues. */}
|
||
{selectedSub && subShowable && (
|
||
<track
|
||
// One track = the selected sub only (no multi-track overlap). The key (see usePlexTracks)
|
||
// is what remounts it for a fresh VTT fetch: a seek-restart, an offset change, a widened
|
||
// track, or a failed load.
|
||
key={subTrackKey}
|
||
kind="subtitles"
|
||
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset, subGen)}
|
||
srcLang={selectedSub.language ?? undefined}
|
||
label={subtitleName(selectedSub)}
|
||
{...subTrackListeners}
|
||
default
|
||
/>
|
||
)}
|
||
</video>
|
||
<PlexSubtitles
|
||
video={videoEl}
|
||
pos={prefs.subPos}
|
||
size={prefs.subSize}
|
||
color={prefs.subColor}
|
||
background={prefs.subBg}
|
||
shadowPx={prefs.subShadow}
|
||
shadowColor={prefs.subShadowColor}
|
||
avoid={uiVisible}
|
||
avoidRef={bottomBarRef}
|
||
/>
|
||
|
||
{/* Loading / buffering / error overlay. `!ready` = initial session load; `buffering` = a
|
||
mid-playback stall or in-flight seek (see the state); `loadError` surfaces a fatal failure so
|
||
it can't hide behind a running video. Spinner on load/stall, error text otherwise. */}
|
||
{(!ready || buffering || loadError) && (
|
||
<div
|
||
role="status"
|
||
aria-live="polite"
|
||
className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none"
|
||
>
|
||
{loadError ? (
|
||
<span className="max-w-md text-danger">{loadError}</span>
|
||
) : (
|
||
<div className="flex flex-col items-center gap-3 text-white/80">
|
||
<Loader2 className="w-10 h-10 animate-spin" aria-hidden />
|
||
{/* Announce the stall to assistive tech. Initial load shows visible text; a mid-playback
|
||
rebuffer/seek (ready, no error) has no visible label, so give it an sr-only one via the
|
||
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
|
||
{!ready ? (
|
||
<span>
|
||
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
|
||
</span>
|
||
) : buffering ? (
|
||
<span className="sr-only">{t("plex.player.buffering")}</span>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Subtitle-extraction notice. Pulling a track out of a big MKV is a full read of the container
|
||
(154 s for a 17 GB film, measured), so it happens in the background — around the playhead
|
||
first. Until then the <track> is deliberately NOT mounted, and this says why instead of
|
||
leaving the viewer to conclude that switching subtitles "does nothing" (bug hunt 2026-09,
|
||
item 8). A failure is said even over a mounted window (see `subNotice`). It sits one rung
|
||
above the track-switch OSD so the two never overlap. Both notices are `z-base`, under the
|
||
settings and tracks popovers (`z-menu`): an open menu is never drawn over. */}
|
||
<div
|
||
className="pointer-events-none absolute inset-x-0 bottom-36 z-base flex justify-center px-4"
|
||
aria-live="polite"
|
||
>
|
||
{selectedSub && subNotice && (
|
||
<div className="flex max-w-full items-center gap-2 truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
|
||
{subNotice !== "preparing" ? (
|
||
<span className="text-danger">{t(`plex.player.subUnavailable.${subNotice}`)}</span>
|
||
) : (
|
||
<>
|
||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
|
||
<span>{t("plex.player.subPreparing", { label: subtitleName(selectedSub) })}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Keyboard track-switch OSD. Sits ABOVE the control bar's own space so it reads even while the
|
||
bar is showing, and inside the player element so it survives fullscreen. `aria-live` because
|
||
the whole point is confirming something you cannot otherwise see.
|
||
The REGION is always mounted and only the bubble is conditional: a polite region has to
|
||
pre-exist in the accessibility tree for a later insertion to be announced, so one created
|
||
together with its own first message is unreliably read — and since `osd` clears after 2.2s,
|
||
conditioning the wrapper re-created it for every switch, not just the first.
|
||
Toaster.tsx carries the same rule and the same shape. */}
|
||
<div
|
||
className="pointer-events-none absolute inset-x-0 bottom-24 z-base flex justify-center px-4"
|
||
aria-live="polite"
|
||
>
|
||
{osd && (
|
||
<div className="max-w-full truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
|
||
{osd}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Each layer the viewer opens over the video claims its OWN history entry while it is up, so
|
||
the browser/mouse Back closes the one opened last and the next Back the one under it — one
|
||
shared entry was spent on the first layer, and the second Back left the player with the rest
|
||
still up. The auto-skip countdown and the up-next card open BY THEMSELVES and claim none:
|
||
Chrome's Back button skips an entry pushed without a user gesture, and one pushed while the
|
||
shortcuts sheet was open sat above the sheet's, so Back closed the hidden layer. */}
|
||
{statsOpen && <BackClose onBack={closeStats} />}
|
||
{panel !== "none" && panel !== "upNext" && <BackClose onBack={closeAnyPanel} />}
|
||
|
||
{/* Top bar */}
|
||
<div
|
||
{...holdWhileHovered}
|
||
className={`absolute top-0 inset-x-0 p-4 bg-linear-to-b from-black/70 to-transparent transition-opacity ${
|
||
uiVisible
|
||
? `opacity-100${tapShield ? " pointer-events-none" : ""}`
|
||
: "opacity-0 pointer-events-none"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-3 text-white">
|
||
<IconButton
|
||
variant="overlay"
|
||
size="player"
|
||
round
|
||
onClick={() => back("arrow")}
|
||
label={t("plex.player.back")}
|
||
>
|
||
<ArrowLeft />
|
||
</IconButton>
|
||
<div className="min-w-0">
|
||
<div className="font-semibold truncate">
|
||
{detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title}
|
||
</div>
|
||
{detail?.kind === "episode" && (
|
||
<div className="text-xs text-white/70 truncate">{episodeLine(detail)}</div>
|
||
)}
|
||
</div>
|
||
{/* Live wall clock — handy on a lean-back / HTPC screen (toggle + style in the Clock tab). */}
|
||
{prefs.clockShow &&
|
||
(() => {
|
||
const c = clockParts(now, prefs);
|
||
return (
|
||
<div
|
||
className="ml-auto flex items-center gap-1.5 tabular-nums shrink-0"
|
||
style={{ color: prefs.clockColor, fontSize: `${prefs.clockSize}%` }}
|
||
>
|
||
<Clock className="h-4 w-4 opacity-70" />
|
||
<span>
|
||
{c.time}
|
||
{c.date ? ` · ${c.date}` : ""}
|
||
</span>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Skip intro / credits (with the auto-skip countdown fill, if armed) */}
|
||
{activeMarker && (
|
||
<Button
|
||
variant="media"
|
||
onClick={() => doSkip(activeMarker)}
|
||
className={`absolute right-6 bottom-28 overflow-hidden ${
|
||
uiVisible ? "opacity-100" : "opacity-80"
|
||
}`}
|
||
>
|
||
{skipProgress != null && (
|
||
<>
|
||
{/* draining fill across the whole button… */}
|
||
<span
|
||
className="absolute inset-y-0 left-0 bg-accent/50"
|
||
style={{ width: `${skipProgress * 100}%` }}
|
||
/>
|
||
{/* …plus a crisp bottom bar so the countdown is unmistakable */}
|
||
<span
|
||
className="absolute bottom-0 left-0 h-1 bg-accent"
|
||
style={{ width: `${skipProgress * 100}%` }}
|
||
/>
|
||
</>
|
||
)}
|
||
<span className="relative">
|
||
{activeMarker.type === "intro"
|
||
? t("plex.player.skipIntro")
|
||
: t("plex.player.skipCredits")}
|
||
</span>
|
||
</Button>
|
||
)}
|
||
|
||
{/* Bottom controls */}
|
||
<div
|
||
ref={bottomBarRef}
|
||
{...holdWhileHovered}
|
||
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-3 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
|
||
uiVisible
|
||
? `opacity-100${tapShield ? " pointer-events-none" : ""}`
|
||
: "opacity-0 pointer-events-none"
|
||
}`}
|
||
>
|
||
{/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on
|
||
release so a long drag doesn't restart the stream on every step. The track stays thin
|
||
inside a 2.75rem hit area (C3): a finger could not find the 6px bar, and `touch-none`
|
||
makes a drag scrub instead of panning the page. */}
|
||
<div
|
||
className="group relative flex h-11 cursor-pointer touch-none items-center"
|
||
onPointerDown={(e) => {
|
||
const el = e.currentTarget as HTMLElement;
|
||
try {
|
||
el.setPointerCapture(e.pointerId);
|
||
} catch {
|
||
/* capture unavailable — dragging still works via buttons state */
|
||
}
|
||
const r = el.getBoundingClientRect();
|
||
const x = Math.max(0, Math.min(e.clientX - r.left, r.width));
|
||
setScrub((x / r.width) * duration);
|
||
}}
|
||
onPointerMove={(e) => {
|
||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||
const frac = Math.max(0, Math.min((e.clientX - r.left) / r.width, 1));
|
||
const tt = frac * duration;
|
||
setHover({ frac, t: tt });
|
||
if (e.buttons & 1) setScrub(tt); // dragging with the primary button held
|
||
}}
|
||
onPointerUp={(e) => {
|
||
if (scrub != null) {
|
||
seekTo(scrub);
|
||
setScrub(null);
|
||
}
|
||
// A finger never "leaves": its time tip goes with it.
|
||
if (e.pointerType === "touch") setHover(null);
|
||
}}
|
||
onPointerCancel={() => {
|
||
setScrub(null);
|
||
setHover(null);
|
||
}}
|
||
onMouseLeave={() => setHover(null)}
|
||
>
|
||
{/* Hover timestamp tooltip — follows the cursor along the bar. */}
|
||
{hover && duration > 0 && (
|
||
<div
|
||
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white"
|
||
style={{ left: `${hover.frac * 100}%` }}
|
||
>
|
||
{fmt(hover.t)}
|
||
</div>
|
||
)}
|
||
<div className="relative h-1.5 w-full rounded-full bg-white/25">
|
||
<div
|
||
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
|
||
style={{ width: `${bufPct}%` }}
|
||
/>
|
||
{/* intro/credit marker regions */}
|
||
{detail?.markers.map((m, i) => (
|
||
<div
|
||
key={i}
|
||
className="absolute inset-y-0 bg-warn/50"
|
||
style={{
|
||
left: `${(m.start_s / duration) * 100}%`,
|
||
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
|
||
}}
|
||
/>
|
||
))}
|
||
<div
|
||
className="absolute inset-y-0 left-0 bg-accent rounded-full"
|
||
style={{ width: `${headPct}%` }}
|
||
/>
|
||
{/* The head: on hover for a mouse, always on touch (C3), where nothing hovers. */}
|
||
<div
|
||
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
|
||
scrub != null
|
||
? "opacity-100"
|
||
: "opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||
}`}
|
||
style={{ left: `${headPct}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* The C5 row, one line on a portrait tablet: play/pause, Back, Next (only with a next
|
||
item), mute, the time, then audio & subtitles, the fixed ⋯ and fullscreen. No Stop
|
||
(closing is stopping), no Restart (Back is, and the card's), no ±10 s (← / → and the
|
||
touch double-tap are). */}
|
||
<div className="flex items-center gap-3 text-white">
|
||
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
|
||
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
||
</Ctrl>
|
||
<Ctrl
|
||
label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")}
|
||
onClick={backOrPrev}
|
||
>
|
||
<SkipBack className="w-5 h-5" />
|
||
</Ctrl>
|
||
{showNext && (
|
||
<Ctrl
|
||
label={t("plex.player.next")}
|
||
onClick={() => nextId && go(nextId)}
|
||
disabled={!nextId}
|
||
>
|
||
<SkipForward className="w-5 h-5" />
|
||
</Ctrl>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
|
||
{muted || volume === 0 ? (
|
||
<VolumeX className="w-5 h-5" />
|
||
) : (
|
||
<Volume2 className="w-5 h-5" />
|
||
)}
|
||
</Ctrl>
|
||
{/* The browser's own range, like every other slider (DESIGN.md A6): its hover, focus,
|
||
keyboard and screen-reader support come with it — the hand-drawn bar it replaces had
|
||
none of them. The level shows above it while hovered. A pointer drag hands the focus
|
||
back so Space and the arrows keep driving the player, not the slider. */}
|
||
{/* Not on touch: a tablet's volume is its buttons (iPadOS ignores a page's), and the
|
||
row has to fit a portrait screen. */}
|
||
<div
|
||
className="relative w-24 [@media(hover:none)]:hidden"
|
||
onPointerEnter={() => setVolHover(true)}
|
||
onPointerLeave={() => setVolHover(false)}
|
||
>
|
||
<Slider
|
||
min={0}
|
||
max={1}
|
||
step={0.01}
|
||
value={muted ? 0 : volume}
|
||
aria-label={t("plex.player.volume")}
|
||
onChange={(v) => setVol(v, v === 0)}
|
||
onPointerUp={(e) => e.currentTarget.blur()}
|
||
/>
|
||
{volHover && (
|
||
<div className="pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white">
|
||
{Math.round((muted ? 0 : volume) * 100)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
|
||
{fmt(abs)} / {fmt(duration)}
|
||
{/* Projected finish time (wall clock now + time remaining), in the clock's own format —
|
||
pushes later while paused, since the remaining time then rides the advancing clock. */}
|
||
{duration > 0 && (
|
||
<span className="ml-1.5 text-white/45">
|
||
(
|
||
{
|
||
clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs)
|
||
.time
|
||
}
|
||
)
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="ml-auto flex items-center gap-2">
|
||
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. Picking
|
||
a track keeps it open: the change is seen where it was made, and the other group is
|
||
one arrow away. */}
|
||
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
|
||
<>
|
||
<IconButton
|
||
variant="overlay"
|
||
size="player"
|
||
round
|
||
ref={setTracksReference}
|
||
{...tracksPop.getReferenceProps()}
|
||
aria-expanded={panel === "tracks"}
|
||
label={t("plex.player.tracks")}
|
||
>
|
||
<Languages />
|
||
</IconButton>
|
||
<PopoverSurface
|
||
popover={tracksPop}
|
||
ariaLabel={t("plex.player.tracks")}
|
||
className={`${PLAYER_MENU} w-56`}
|
||
>
|
||
{/* Portaled, but a React child of the player: a click or a wheel here must not
|
||
reach the stage's play-toggle and volume handlers. */}
|
||
<div
|
||
ref={tracksFadeRef}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onWheel={(e) => e.stopPropagation()}
|
||
style={{ ...tracksFadeStyle, maxHeight: "min(60dvh, var(--fit-max-h))" }}
|
||
className="overflow-auto no-scrollbar p-2"
|
||
>
|
||
{detail.audio_streams.length > 1 && (
|
||
<div role="group" aria-label={t("plex.player.audio")}>
|
||
<PanelHead>{t("plex.player.audio")}</PanelHead>
|
||
{detail.audio_streams.map((a) => (
|
||
<PopoverItem
|
||
key={a.ord}
|
||
tone="media"
|
||
role="menuitemradio"
|
||
checked={(audioOrd ?? 0) === a.ord}
|
||
label={audioName(a)}
|
||
onSelect={() => changeAudio(a.ord)}
|
||
className={`${TRACK_ROW} ${
|
||
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
|
||
}`}
|
||
>
|
||
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
|
||
<span className="truncate">{audioName(a)}</span>
|
||
</PopoverItem>
|
||
))}
|
||
</div>
|
||
)}
|
||
{textSubs.length > 0 && (
|
||
<div role="group" aria-label={t("plex.player.subtitles")}>
|
||
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
|
||
<PopoverItem
|
||
tone="media"
|
||
role="menuitemradio"
|
||
checked={subOrd == null}
|
||
label={t("plex.player.subOff")}
|
||
onSelect={() => changeSubtitle(null)}
|
||
className={`${TRACK_ROW} ${subOrd == null ? "text-accent" : "text-white"}`}
|
||
>
|
||
<MenuCheck kind="one" on={subOrd == null} />
|
||
<span className="truncate">{t("plex.player.subOff")}</span>
|
||
</PopoverItem>
|
||
{textSubs.map((sub) => (
|
||
<PopoverItem
|
||
key={sub.ord}
|
||
tone="media"
|
||
role="menuitemradio"
|
||
checked={subOrd === sub.ord}
|
||
label={subtitleName(sub)}
|
||
onSelect={() => changeSubtitle(sub.ord)}
|
||
className={`${TRACK_ROW} ${
|
||
subOrd === sub.ord ? "text-accent" : "text-white"
|
||
}`}
|
||
>
|
||
<MenuCheck kind="one" on={subOrd === sub.ord} />
|
||
<span className="truncate">{subtitleName(sub)}</span>
|
||
</PopoverItem>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</PopoverSurface>
|
||
</>
|
||
)}
|
||
|
||
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
|
||
so the ⋯ also closes it. */}
|
||
<div className="relative">
|
||
<IconButton
|
||
variant="overlay"
|
||
size="player"
|
||
round
|
||
ref={moreTriggerRef}
|
||
{...morePop.getReferenceProps()}
|
||
aria-expanded={panel === "more" || panel === "settings"}
|
||
label={t("plex.player.more")}
|
||
>
|
||
<MoreHorizontal />
|
||
</IconButton>
|
||
<PopoverSurface
|
||
popover={morePop}
|
||
ariaLabel={t("plex.player.more")}
|
||
className={`${PLAYER_MENU} w-60 p-1.5`}
|
||
>
|
||
<div onClick={(e) => e.stopPropagation()} className="flex flex-col">
|
||
{moreItems.map((it) => (
|
||
<PopoverItem
|
||
key={it.id}
|
||
tone="media"
|
||
label={it.label}
|
||
// Closes first, so the focus is back on the ⋯ before the tool opens: a dialog
|
||
// opened from here returns the focus to where it found it.
|
||
onSelect={() => closeThenAct(() => closePanel("more"), it.run)}
|
||
className={`${PLAYER_ROW} flex min-h-9 items-center gap-2.5 text-white`}
|
||
>
|
||
{it.icon}
|
||
{it.label}
|
||
</PopoverItem>
|
||
))}
|
||
</div>
|
||
</PopoverSurface>
|
||
{panel === "settings" && (
|
||
<PlexPlayerSettings
|
||
menuRef={menuRef}
|
||
returnFocusTo={menuTriggerRef}
|
||
settingsTabs={settingsTabs}
|
||
activeTab={activeTab}
|
||
setSettingsTab={setSettingsTab}
|
||
prefs={prefs}
|
||
patchPrefs={patchPrefs}
|
||
changeAudioOffset={changeAudioOffset}
|
||
now={now}
|
||
/>
|
||
)}
|
||
</div>
|
||
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): the button
|
||
did nothing there, and the windowed player already fills the window. */}
|
||
{canFullscreenElements() && (
|
||
<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}
|
||
engine={engine}
|
||
fragMeterRef={fragMeterRef}
|
||
audioLabel={currentAudio ? audioName(currentAudio) : undefined}
|
||
subLabel={
|
||
subOrd == null
|
||
? t("plex.player.subOff")
|
||
: currentSub
|
||
? subtitleName(currentSub)
|
||
: undefined
|
||
}
|
||
playable={detail?.playable ?? ""}
|
||
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. */}
|
||
{panel === "upNext" && nextId && (
|
||
<UpNextCard
|
||
nextId={nextId}
|
||
onAdvance={advanceUpNext}
|
||
onCancel={() => closePanel("upNext")}
|
||
/>
|
||
)}
|
||
|
||
{/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
|
||
{panel === "info" && detail && (
|
||
<div
|
||
className="absolute inset-0 z-menu grid place-items-center overflow-y-auto bg-black/70 p-4"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
closePanel("info");
|
||
}}
|
||
>
|
||
<div
|
||
className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-media-chrome/95 shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<Suspense fallback={null}>
|
||
<PlexInfo
|
||
detail={detail}
|
||
variant="overlay"
|
||
onClose={() => closePanel("info")}
|
||
// A collection sibling plays right here, as Next would.
|
||
onPlayItem={(other, fromZero) => {
|
||
closePanel("info");
|
||
go(other, undefined, fromZero);
|
||
}}
|
||
/>
|
||
</Suspense>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* The app's Toaster is underneath this full-screen player (and outside it in native
|
||
fullscreen), so toasts raised while watching show here instead. See ui/Toaster. */}
|
||
<Toaster overlay order={overlayOrder} />
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|
||
|
||
// Renders nothing; while mounted it claims one history entry so the browser/mouse Back button runs
|
||
// `onBack` (close the panel / cancel the skip) instead of navigating out of the player.
|
||
function BackClose({ onBack }: { onBack: () => void }) {
|
||
useBackToClose(onBack);
|
||
return null;
|
||
}
|