Files
siftlode/frontend/src/components/PlexPlayer.tsx
T
peter 59b27467ab feat(player): the OS media controls, the basic keys and resume for the library and shared-link players
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.
2026-10-06 23:55:41 +02:00

1697 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}