Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.
The library and /watch players get the basic keys (lib/mediaKeys, the keymap's mediaPlayer rows) and
open where the viewer stopped (lib/resume): the library keeps the point on the server, per user and
library item (library_states, POST /api/downloads/{id}/progress, listed back on both listings), the
shared link in this browser. A started library row continues, its menu can start it over, and its
picture carries the cards' progress bar. The resume rule (too early / near the end = from the start)
is one function shared with the YouTube route.
The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
1697 lines
77 KiB
TypeScript
1697 lines
77 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,
|
||
subShadowCss,
|
||
type PlexPlayerPrefs,
|
||
} from "../lib/plexPlayerPrefs";
|
||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||
import { createFragMeter } from "../lib/plexStats";
|
||
import { 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 { 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 { 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);
|
||
// 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 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,
|
||
subPos: prefs.subPos,
|
||
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" && detail.season_number != null
|
||
? `S${detail.season_number}·E${detail.episode_number} — ${detail.title}`
|
||
: 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 every way of saying "back": the on-screen arrow, ⌫, Escape, and the
|
||
// mouse/browser Back (history popstate, through BackClose below). 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 shortcuts sheet is not in it: a Modal above us, with its own Escape
|
||
// layer and Back-to-close.
|
||
// - 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" | "history" | "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],
|
||
);
|
||
const backFromHistory = useCallback(() => back("history"), [back]);
|
||
|
||
// 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 80vw×80dvh and scaled ×1.25 from the top-left so it fills the viewport exactly (no
|
||
// overflow, controls stay on-screen; `dvh` so a tablet's toolbar does not push the bar off it). We use `transform: scale` rather than `zoom` deliberately:
|
||
// under `zoom`, event clientX stays in CSS pixels while getBoundingClientRect returns zoomed
|
||
// pixels, so the seek/volume/hover math (clientX − rect.left)/rect.width breaks; with `transform`
|
||
// both are in the same viewport space, so that ratio stays correct. Skip buttons ride this too.
|
||
//
|
||
// In NATIVE fullscreen we must NOT carry the ×1.25 transform: with the element at 80vw×80dvh, the
|
||
// scaled layer is composited letterboxed and the video never grows past its windowed size (the UA
|
||
// does NOT force this fixed, explicitly-sized element to fill). So in `fs` we size the wrapper to
|
||
// the full screen (100vw×100dvh) with NO transform — the flex box then centres the video and its
|
||
// `max-h-full`/`max-w-full` aspect-fit fills the available height. The % seek-bar left and the
|
||
// clientX/rect math stay correct under the identity transform.
|
||
style={{
|
||
cursor: uiVisible ? "default" : "none",
|
||
...(fs
|
||
? { width: "100vw", height: "100dvh" }
|
||
: {
|
||
width: "80vw",
|
||
height: "80dvh",
|
||
transform: "scale(1.25)",
|
||
transformOrigin: "top left",
|
||
}),
|
||
}}
|
||
>
|
||
{/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this
|
||
player owns the only <video> on screen. */}
|
||
<style>{`video::cue{font-size:${prefs.subSize}%;color:${prefs.subColor};background-color:${prefs.subBg};${subShadowCss(
|
||
prefs.subShadow,
|
||
prefs.subShadowColor,
|
||
)}}`}</style>
|
||
<video
|
||
ref={(el) => {
|
||
videoRef.current = el;
|
||
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 native tracks (same-origin → sent with the session cookie). The effect
|
||
above flips modes; the browser fetches a track's VTT the first time it's shown. */}
|
||
{selectedSub && subShowable && (
|
||
<track
|
||
// One track = the selected sub only (no multi-track overlap). The key (see usePlexTracks)
|
||
// is what remounts it for a fresh VTT fetch: a seek-restart, an offset change, a widened
|
||
// track, or a failed load.
|
||
key={subTrackKey}
|
||
kind="subtitles"
|
||
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset, subGen)}
|
||
srcLang={selectedSub.language ?? undefined}
|
||
label={subtitleName(selectedSub)}
|
||
{...subTrackListeners}
|
||
default
|
||
/>
|
||
)}
|
||
</video>
|
||
|
||
{/* Loading / buffering / error overlay. `!ready` = initial session load; `buffering` = a
|
||
mid-playback stall or in-flight seek (see the state); `loadError` surfaces a fatal failure so
|
||
it can't hide behind a running video. Spinner on load/stall, error text otherwise. */}
|
||
{(!ready || buffering || loadError) && (
|
||
<div
|
||
role="status"
|
||
aria-live="polite"
|
||
className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none"
|
||
>
|
||
{loadError ? (
|
||
<span className="max-w-md text-danger">{loadError}</span>
|
||
) : (
|
||
<div className="flex flex-col items-center gap-3 text-white/80">
|
||
<Loader2 className="w-10 h-10 animate-spin" aria-hidden />
|
||
{/* Announce the stall to assistive tech. Initial load shows visible text; a mid-playback
|
||
rebuffer/seek (ready, no error) has no visible label, so give it an sr-only one via the
|
||
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
|
||
{!ready ? (
|
||
<span>
|
||
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
|
||
</span>
|
||
) : buffering ? (
|
||
<span className="sr-only">{t("plex.player.buffering")}</span>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Subtitle-extraction notice. Pulling a track out of a big MKV is a full read of the container
|
||
(154 s for a 17 GB film, measured), so it happens in the background — around the playhead
|
||
first. Until then the <track> is deliberately NOT mounted, and this says why instead of
|
||
leaving the viewer to conclude that switching subtitles "does nothing" (bug hunt 2026-09,
|
||
item 8). A failure is said even over a mounted window (see `subNotice`). It sits one rung
|
||
above the track-switch OSD so the two never overlap. Both notices are `z-base`, under the
|
||
settings and tracks popovers (`z-menu`): an open menu is never drawn over. */}
|
||
<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>
|
||
|
||
{/* 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. */}
|
||
{(panel !== "none" || statsOpen || skipProgress != null) && (
|
||
<BackClose onBack={backFromHistory} />
|
||
)}
|
||
|
||
{/* 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">
|
||
S{detail.season_number}·E{detail.episode_number} — {detail.title}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Live wall clock — handy on a lean-back / HTPC screen (toggle + style in the Clock tab). */}
|
||
{prefs.clockShow &&
|
||
(() => {
|
||
const c = clockParts(now, prefs);
|
||
return (
|
||
<div
|
||
className="ml-auto flex items-center gap-1.5 tabular-nums shrink-0"
|
||
style={{ color: prefs.clockColor, fontSize: `${prefs.clockSize}%` }}
|
||
>
|
||
<Clock className="h-4 w-4 opacity-70" />
|
||
<span>
|
||
{c.time}
|
||
{c.date ? ` · ${c.date}` : ""}
|
||
</span>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Skip intro / credits (with the auto-skip countdown fill, if armed) */}
|
||
{activeMarker && (
|
||
<Button
|
||
variant="media"
|
||
onClick={() => doSkip(activeMarker)}
|
||
className={`absolute right-6 bottom-28 overflow-hidden ${
|
||
uiVisible ? "opacity-100" : "opacity-80"
|
||
}`}
|
||
>
|
||
{skipProgress != null && (
|
||
<>
|
||
{/* draining fill across the whole button… */}
|
||
<span
|
||
className="absolute inset-y-0 left-0 bg-accent/50"
|
||
style={{ width: `${skipProgress * 100}%` }}
|
||
/>
|
||
{/* …plus a crisp bottom bar so the countdown is unmistakable */}
|
||
<span
|
||
className="absolute bottom-0 left-0 h-1 bg-accent"
|
||
style={{ width: `${skipProgress * 100}%` }}
|
||
/>
|
||
</>
|
||
)}
|
||
<span className="relative">
|
||
{activeMarker.type === "intro"
|
||
? t("plex.player.skipIntro")
|
||
: t("plex.player.skipCredits")}
|
||
</span>
|
||
</Button>
|
||
)}
|
||
|
||
{/* Bottom controls */}
|
||
<div
|
||
{...holdWhileHovered}
|
||
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-3 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
|
||
uiVisible
|
||
? `opacity-100${tapShield ? " pointer-events-none" : ""}`
|
||
: "opacity-0 pointer-events-none"
|
||
}`}
|
||
>
|
||
{/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on
|
||
release so a long drag doesn't restart the stream on every step. The track stays thin
|
||
inside a 2.75rem hit area (C3): a finger could not find the 6px bar, and `touch-none`
|
||
makes a drag scrub instead of panning the page. */}
|
||
<div
|
||
className="group relative flex h-11 cursor-pointer touch-none items-center"
|
||
onPointerDown={(e) => {
|
||
const el = e.currentTarget as HTMLElement;
|
||
try {
|
||
el.setPointerCapture(e.pointerId);
|
||
} catch {
|
||
/* capture unavailable — dragging still works via buttons state */
|
||
}
|
||
const r = el.getBoundingClientRect();
|
||
const x = Math.max(0, Math.min(e.clientX - r.left, r.width));
|
||
setScrub((x / r.width) * duration);
|
||
}}
|
||
onPointerMove={(e) => {
|
||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||
const frac = Math.max(0, Math.min((e.clientX - r.left) / r.width, 1));
|
||
const tt = frac * duration;
|
||
setHover({ frac, t: tt });
|
||
if (e.buttons & 1) setScrub(tt); // dragging with the primary button held
|
||
}}
|
||
onPointerUp={(e) => {
|
||
if (scrub != null) {
|
||
seekTo(scrub);
|
||
setScrub(null);
|
||
}
|
||
// A finger never "leaves": its time tip goes with it.
|
||
if (e.pointerType === "touch") setHover(null);
|
||
}}
|
||
onPointerCancel={() => {
|
||
setScrub(null);
|
||
setHover(null);
|
||
}}
|
||
onMouseLeave={() => setHover(null)}
|
||
>
|
||
{/* Hover timestamp tooltip — follows the cursor along the bar. */}
|
||
{hover && duration > 0 && (
|
||
<div
|
||
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white"
|
||
style={{ left: `${hover.frac * 100}%` }}
|
||
>
|
||
{fmt(hover.t)}
|
||
</div>
|
||
)}
|
||
<div className="relative h-1.5 w-full rounded-full bg-white/25">
|
||
<div
|
||
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
|
||
style={{ width: `${bufPct}%` }}
|
||
/>
|
||
{/* intro/credit marker regions */}
|
||
{detail?.markers.map((m, i) => (
|
||
<div
|
||
key={i}
|
||
className="absolute inset-y-0 bg-warn/50"
|
||
style={{
|
||
left: `${(m.start_s / duration) * 100}%`,
|
||
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
|
||
}}
|
||
/>
|
||
))}
|
||
<div
|
||
className="absolute inset-y-0 left-0 bg-accent rounded-full"
|
||
style={{ width: `${headPct}%` }}
|
||
/>
|
||
{/* The head: on hover for a mouse, always on touch (C3), where nothing hovers. */}
|
||
<div
|
||
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
|
||
scrub != null
|
||
? "opacity-100"
|
||
: "opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||
}`}
|
||
style={{ left: `${headPct}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* The C5 row, one line on a portrait tablet: play/pause, Back, Next (only with a next
|
||
item), mute, the time, then audio & subtitles, the fixed ⋯ and fullscreen. No Stop
|
||
(closing is stopping), no Restart (Back is, and the card's), no ±10 s (← / → and the
|
||
touch double-tap are). */}
|
||
<div className="flex items-center gap-3 text-white">
|
||
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
|
||
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
||
</Ctrl>
|
||
<Ctrl
|
||
label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")}
|
||
onClick={backOrPrev}
|
||
>
|
||
<SkipBack className="w-5 h-5" />
|
||
</Ctrl>
|
||
{showNext && (
|
||
<Ctrl
|
||
label={t("plex.player.next")}
|
||
onClick={() => nextId && go(nextId)}
|
||
disabled={!nextId}
|
||
>
|
||
<SkipForward className="w-5 h-5" />
|
||
</Ctrl>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
|
||
{muted || volume === 0 ? (
|
||
<VolumeX className="w-5 h-5" />
|
||
) : (
|
||
<Volume2 className="w-5 h-5" />
|
||
)}
|
||
</Ctrl>
|
||
{/* The browser's own range, like every other slider (DESIGN.md A6): its hover, focus,
|
||
keyboard and screen-reader support come with it — the hand-drawn bar it replaces had
|
||
none of them. The level shows above it while hovered. A pointer drag hands the focus
|
||
back so Space and the arrows keep driving the player, not the slider. */}
|
||
{/* Not on touch: a tablet's volume is its buttons (iPadOS ignores a page's), and the
|
||
row has to fit a portrait screen. */}
|
||
<div
|
||
className="relative w-24 [@media(hover:none)]:hidden"
|
||
onPointerEnter={() => setVolHover(true)}
|
||
onPointerLeave={() => setVolHover(false)}
|
||
>
|
||
<Slider
|
||
min={0}
|
||
max={1}
|
||
step={0.01}
|
||
value={muted ? 0 : volume}
|
||
aria-label={t("plex.player.volume")}
|
||
onChange={(v) => setVol(v, v === 0)}
|
||
onPointerUp={(e) => e.currentTarget.blur()}
|
||
/>
|
||
{volHover && (
|
||
<div className="pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white">
|
||
{Math.round((muted ? 0 : volume) * 100)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
|
||
{fmt(abs)} / {fmt(duration)}
|
||
<span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
|
||
{/* Projected finish time (wall clock now + time remaining), in the clock's own format —
|
||
pushes later while paused, since the remaining time then rides the advancing clock. */}
|
||
{duration > 0 && (
|
||
<span className="ml-1.5 text-white/45">
|
||
(
|
||
{
|
||
clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs)
|
||
.time
|
||
}
|
||
)
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="ml-auto flex items-center gap-2">
|
||
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. Picking
|
||
a track keeps it open: the change is seen where it was made, and the other group is
|
||
one arrow away. */}
|
||
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
|
||
<>
|
||
<IconButton
|
||
variant="overlay"
|
||
size="player"
|
||
round
|
||
ref={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>
|
||
<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;
|
||
}
|