@enigmax/primitives 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-4SZ5N7BA.js +121 -0
- package/dist/chunk-EYZ366LP.js +189 -0
- package/dist/chunk-ISXB5RUH.js +380 -0
- package/dist/chunk-JJVZYW5C.js +946 -0
- package/dist/chunk-QIIV2QSV.js +108 -0
- package/dist/chunk-RUEQ3TGP.js +155 -0
- package/dist/clipboard-menu-BvXJCSFN.d.ts +93 -0
- package/dist/context-BfjYGPnH.d.ts +54 -0
- package/dist/{clipboard-menu-B_ouitfS.d.ts → context-menu-D3FtTn7v.d.ts} +1 -91
- package/dist/index.d.ts +266 -2
- package/dist/index.js +4 -2
- package/dist/menu-MSQSE6U5.js +54 -0
- package/dist/menu-PNQRTRNV.js +33 -0
- package/dist/next/index.d.ts +6 -2
- package/dist/next/index.js +10 -6
- package/dist/react/context-menu.d.ts +8 -54
- package/dist/react/image.d.ts +109 -0
- package/dist/react/image.js +3 -0
- package/dist/react/index.d.ts +6 -2
- package/dist/react/index.js +10 -6
- package/dist/react/input.js +1 -1
- package/dist/react/video.d.ts +120 -0
- package/dist/react/video.js +3 -0
- package/dist/react-router/index.d.ts +6 -2
- package/dist/react-router/index.js +10 -6
- package/dist/viewer-73LSE6RR.js +3 -0
- package/package.json +15 -2
- package/recipes/image/styles.css +182 -0
- package/recipes/video/styles.css +154 -0
- package/registry.json +465 -0
- package/src/core/image-viewer.ts +222 -0
- package/src/core/player.ts +303 -0
- package/src/index.ts +52 -0
- package/src/react/image/icons.tsx +55 -0
- package/src/react/image/index.tsx +185 -0
- package/src/react/image/menu.tsx +76 -0
- package/src/react/image/styles.ts +213 -0
- package/src/react/image/types.ts +113 -0
- package/src/react/image/viewer.tsx +571 -0
- package/src/react/index.ts +3 -0
- package/src/react/video/icons.tsx +135 -0
- package/src/react/video/index.tsx +724 -0
- package/src/react/video/menu.tsx +66 -0
- package/src/react/video/rail.tsx +99 -0
- package/src/react/video/styles.ts +161 -0
- package/src/react/video/types.ts +135 -0
- /package/dist/{chunk-3MGBZOAU.js → chunk-RMNXVWQ4.js} +0 -0
- /package/dist/{chunk-MSOCCQGH.js → chunk-T7I6WJSN.js} +0 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as player from "@/core/player";
|
|
4
|
+
import { Rail } from "@/react/video/rail";
|
|
5
|
+
import * as icons from "@/react/video/icons";
|
|
6
|
+
import { VIDEO_STYLES } from "@/react/video/styles";
|
|
7
|
+
import { CONTROL_DEFAULTS } from "@/react/video/types";
|
|
8
|
+
import type { OpenVideoMenu } from "@/react/video/menu";
|
|
9
|
+
import type { ContextMenuItem, ContextMenuNode } from "@/react/context-menu/context";
|
|
10
|
+
import type { VideoContextMenuOptions, VideoLabels, VideoProps, VideoSource } from "@/react/video/types";
|
|
11
|
+
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `<Video>` - a video with the controls a video is expected to have.
|
|
15
|
+
*
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <Video src="/demo.mp4" poster="/demo.jpg" />
|
|
18
|
+
* <Video src={[{ src: "/a.webm", type: "video/webm" }, { src: "/a.mp4" }]} tracks={subtitles} />
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Shaped after Plyr, which is the player this is measured against: the same control set, the
|
|
22
|
+
* same shortcuts, the same bar that fades while playing. The difference is what it is made of
|
|
23
|
+
* - React markup with `data-*` attributes rather than a template string it parses - so the
|
|
24
|
+
* look is styled the way every other primitive here is, and the state a stylesheet needs is
|
|
25
|
+
* on the elements.
|
|
26
|
+
*
|
|
27
|
+
* The `<video>` element stays the source of truth. Everything below reads it back through its
|
|
28
|
+
* own events rather than keeping a second copy of what is playing: a player that believes its
|
|
29
|
+
* own state is one that says "playing" after the browser refused to autoplay. The subtitle
|
|
30
|
+
* list and the cast target are read the same way, from `textTracks` and from the browser's
|
|
31
|
+
* own remote-playback watch.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The menu is a chunk of its own, and it is mounted the first time a pointer reaches the
|
|
36
|
+
* player rather than on render - see the note in `menu.tsx`.
|
|
37
|
+
*/
|
|
38
|
+
const VideoMenu = lazy(() => import("@/react/video/menu").then((module) => ({ default: module.VideoMenu })));
|
|
39
|
+
|
|
40
|
+
let injected = false;
|
|
41
|
+
|
|
42
|
+
function injectStyles(): void {
|
|
43
|
+
if (injected || typeof document === "undefined") return;
|
|
44
|
+
injected = true;
|
|
45
|
+
if (document.querySelector("[data-enigma-video-styles]")) return;
|
|
46
|
+
const element = document.createElement("style");
|
|
47
|
+
element.setAttribute("data-enigma-video-styles", "");
|
|
48
|
+
element.textContent = VIDEO_STYLES;
|
|
49
|
+
document.head.prepend(element);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether two subtitle lists are the same one, so a render can be skipped. */
|
|
53
|
+
function same(a: readonly player.CaptionTrack[], b: readonly player.CaptionTrack[]): boolean {
|
|
54
|
+
return a.length === b.length && a.every((track, at) => track.index === b[at]?.index && track.label === b[at]?.label);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The room the settings panel is left when the player is too short to hold all of it. */
|
|
58
|
+
const PANEL_GAP = 12;
|
|
59
|
+
const PANEL_MIN = 48;
|
|
60
|
+
|
|
61
|
+
export function Video(props: VideoProps): ReactNode {
|
|
62
|
+
const {
|
|
63
|
+
src,
|
|
64
|
+
poster,
|
|
65
|
+
tracks,
|
|
66
|
+
controls = true,
|
|
67
|
+
speeds = player.SPEEDS,
|
|
68
|
+
keyboard = true,
|
|
69
|
+
clickToPlay = true,
|
|
70
|
+
autoHide = true,
|
|
71
|
+
autoHideDelay = 2600,
|
|
72
|
+
contextMenu = true,
|
|
73
|
+
styles = true,
|
|
74
|
+
labels = {},
|
|
75
|
+
download,
|
|
76
|
+
children,
|
|
77
|
+
wrapperProps,
|
|
78
|
+
...rest
|
|
79
|
+
} = props;
|
|
80
|
+
|
|
81
|
+
useLayoutEffect(() => { if (styles) injectStyles(); }, [styles]);
|
|
82
|
+
|
|
83
|
+
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
84
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
85
|
+
const settingsRef = useRef<HTMLButtonElement | null>(null);
|
|
86
|
+
|
|
87
|
+
const [playing, setPlaying] = useState(false);
|
|
88
|
+
const [waiting, setWaiting] = useState(false);
|
|
89
|
+
const [time, setTime] = useState(0);
|
|
90
|
+
const [duration, setDuration] = useState(0);
|
|
91
|
+
const [buffered, setBuffered] = useState(0);
|
|
92
|
+
const [volume, setVolume] = useState(1);
|
|
93
|
+
const [muted, setMuted] = useState(false);
|
|
94
|
+
const [speed, setSpeed] = useState(1);
|
|
95
|
+
const [looping, setLooping] = useState(false);
|
|
96
|
+
const [captionList, setCaptionList] = useState<readonly player.CaptionTrack[]>([]);
|
|
97
|
+
const [caption, setCaption] = useState(-1);
|
|
98
|
+
/** null while the browser has no way to cast this element at all. */
|
|
99
|
+
const [remote, setRemote] = useState<player.RemoteState | null>(null);
|
|
100
|
+
const [fullscreen, setFullscreen] = useState(false);
|
|
101
|
+
const [menu, setMenu] = useState(false);
|
|
102
|
+
const [panelMax, setPanelMax] = useState<number | null>(null);
|
|
103
|
+
const [idle, setIdle] = useState(false);
|
|
104
|
+
/** The pointer is resting on the control bar, which is never a moment to take it away. */
|
|
105
|
+
const [overBar, setOverBar] = useState(false);
|
|
106
|
+
/** The context menu's chunk is mounted once a pointer has arrived - never on first render. */
|
|
107
|
+
const [armed, setArmed] = useState(false);
|
|
108
|
+
|
|
109
|
+
const show = useMemo(() => (controls === false ? null : { ...CONTROL_DEFAULTS, ...(controls === true ? {} : controls) }), [controls]);
|
|
110
|
+
/** What the `tracks` prop SAYS, as a string - see the effect that reads the element back. */
|
|
111
|
+
const trackKey = (tracks ?? []).map((track) => `${track.src}|${track.srcLang}|${track.label}`).join("\u0000");
|
|
112
|
+
const sources: readonly VideoSource[] = useMemo(() => (typeof src === "string" ? [{ src }] : src ?? []), [src]);
|
|
113
|
+
const pip = show?.pip && player.supportsPip();
|
|
114
|
+
const cast = Boolean(show?.cast && remote?.available);
|
|
115
|
+
|
|
116
|
+
const menuOptions = useMemo<VideoContextMenuOptions | null>(() => (
|
|
117
|
+
contextMenu === false ? null : contextMenu === true ? {} : contextMenu
|
|
118
|
+
), [contextMenu]);
|
|
119
|
+
|
|
120
|
+
/* -------- reading the element back -------- */
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const video = videoRef.current;
|
|
124
|
+
if (!video) return;
|
|
125
|
+
|
|
126
|
+
const sync = (): void => {
|
|
127
|
+
setPlaying(!video.paused && !video.ended);
|
|
128
|
+
setVolume(video.volume);
|
|
129
|
+
setMuted(video.muted);
|
|
130
|
+
setSpeed(video.playbackRate);
|
|
131
|
+
setLooping(video.loop);
|
|
132
|
+
};
|
|
133
|
+
const onTime = (): void => {
|
|
134
|
+
setTime(video.currentTime);
|
|
135
|
+
setBuffered(player.bufferedAhead(video.buffered, video.currentTime, video.duration));
|
|
136
|
+
};
|
|
137
|
+
const onDuration = (): void => setDuration(Number.isFinite(video.duration) ? video.duration : 0);
|
|
138
|
+
|
|
139
|
+
const events: [string, () => void][] = [
|
|
140
|
+
["play", sync], ["pause", sync], ["ended", sync], ["volumechange", sync], ["ratechange", sync],
|
|
141
|
+
["timeupdate", onTime], ["progress", onTime], ["seeked", onTime],
|
|
142
|
+
["durationchange", onDuration], ["loadedmetadata", onDuration],
|
|
143
|
+
["waiting", () => setWaiting(true)], ["playing", () => { setWaiting(false); sync(); }],
|
|
144
|
+
["canplay", () => setWaiting(false)]
|
|
145
|
+
];
|
|
146
|
+
for (const [name, handler] of events) video.addEventListener(name, handler);
|
|
147
|
+
|
|
148
|
+
// The element may already be past these by the time React mounts - a cached video with
|
|
149
|
+
// `autoPlay` fires `loadedmetadata` before the listeners exist.
|
|
150
|
+
sync();
|
|
151
|
+
onTime();
|
|
152
|
+
onDuration();
|
|
153
|
+
|
|
154
|
+
return () => { for (const [name, handler] of events) video.removeEventListener(name, handler); };
|
|
155
|
+
}, [sources]);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The subtitle list, read from the element rather than from the `tracks` prop.
|
|
159
|
+
*
|
|
160
|
+
* They are not the same list. A `<track default>` is SHOWING before anything was pressed,
|
|
161
|
+
* so a player that assumed "off" needed two presses to turn one off; and a page that adds
|
|
162
|
+
* a track of its own, or renders `<track>` children, has subtitles this component was
|
|
163
|
+
* never told about. `textTracks` is what the browser is actually drawing.
|
|
164
|
+
*/
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const list = videoRef.current?.textTracks;
|
|
167
|
+
if (!list) return;
|
|
168
|
+
|
|
169
|
+
const sync = (): void => {
|
|
170
|
+
const found = player.captionTracks(list);
|
|
171
|
+
// Only when it is a DIFFERENT list. `captionTracks` builds a new array every call,
|
|
172
|
+
// and setting one unconditionally is a render, which re-runs this effect, which
|
|
173
|
+
// sets another - a loop, and `tracks={[...]}` written inline is how everyone
|
|
174
|
+
// writes it.
|
|
175
|
+
setCaptionList((current) => (same(current, found) ? current : found));
|
|
176
|
+
setCaption(player.activeCaption(list));
|
|
177
|
+
};
|
|
178
|
+
sync();
|
|
179
|
+
for (const name of ["addtrack", "removetrack", "change"]) list.addEventListener(name, sync);
|
|
180
|
+
return () => { for (const name of ["addtrack", "removetrack", "change"]) list.removeEventListener(name, sync); };
|
|
181
|
+
// Keyed by what the tracks ARE rather than by the array they arrived in, for the same
|
|
182
|
+
// reason: a fresh literal on every render would resubscribe on every render.
|
|
183
|
+
}, [trackKey, sources]);
|
|
184
|
+
|
|
185
|
+
/** Whether there is a screen to cast to, and whether it is playing there now. */
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
const video = videoRef.current;
|
|
188
|
+
if (!video || !player.supportsRemote(video)) return;
|
|
189
|
+
setRemote({ available: false, connected: false });
|
|
190
|
+
return player.watchRemote(video, setRemote);
|
|
191
|
+
}, [sources]);
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
const onChange = (): void => setFullscreen(player.fullscreenElement() === wrapperRef.current);
|
|
195
|
+
document.addEventListener("fullscreenchange", onChange);
|
|
196
|
+
document.addEventListener("webkitfullscreenchange", onChange);
|
|
197
|
+
return () => {
|
|
198
|
+
document.removeEventListener("fullscreenchange", onChange);
|
|
199
|
+
document.removeEventListener("webkitfullscreenchange", onChange);
|
|
200
|
+
};
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
/* -------- what the controls do -------- */
|
|
204
|
+
|
|
205
|
+
const toggle = useCallback(() => {
|
|
206
|
+
const video = videoRef.current;
|
|
207
|
+
if (!video) return;
|
|
208
|
+
// `play()` rejects on its own - no gesture yet, no source, a policy - and an unhandled
|
|
209
|
+
// rejection in a console is not a state change.
|
|
210
|
+
if (video.paused) void video.play().catch(() => setPlaying(false));
|
|
211
|
+
else video.pause();
|
|
212
|
+
}, []);
|
|
213
|
+
|
|
214
|
+
const seekTo = useCallback((fraction: number) => {
|
|
215
|
+
const video = videoRef.current;
|
|
216
|
+
if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
|
|
217
|
+
video.currentTime = fraction * video.duration;
|
|
218
|
+
setTime(video.currentTime);
|
|
219
|
+
}, []);
|
|
220
|
+
|
|
221
|
+
const seekBy = useCallback((seconds: number) => {
|
|
222
|
+
const video = videoRef.current;
|
|
223
|
+
if (!video) return;
|
|
224
|
+
video.currentTime = Math.min(Math.max(video.currentTime + seconds, 0), video.duration || 0);
|
|
225
|
+
setTime(video.currentTime);
|
|
226
|
+
}, []);
|
|
227
|
+
|
|
228
|
+
const setVolumeTo = useCallback((fraction: number) => {
|
|
229
|
+
const video = videoRef.current;
|
|
230
|
+
if (!video) return;
|
|
231
|
+
video.volume = Math.min(Math.max(fraction, 0), 1);
|
|
232
|
+
// Moving the slider off zero is how somebody unmutes without hunting for the button.
|
|
233
|
+
video.muted = video.volume === 0;
|
|
234
|
+
}, []);
|
|
235
|
+
|
|
236
|
+
const toggleMute = useCallback(() => {
|
|
237
|
+
const video = videoRef.current;
|
|
238
|
+
if (!video) return;
|
|
239
|
+
video.muted = !video.muted;
|
|
240
|
+
// Unmuting a video whose volume was dragged to zero would still be silent.
|
|
241
|
+
if (!video.muted && video.volume === 0) video.volume = 0.5;
|
|
242
|
+
}, []);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Which subtitle track was on before it was turned off.
|
|
246
|
+
*
|
|
247
|
+
* Without it, C after choosing Spanish brings back English: the toggle has to come back to
|
|
248
|
+
* the language that was chosen, not to the first one in the list.
|
|
249
|
+
*/
|
|
250
|
+
const preferred = useRef<number | null>(null);
|
|
251
|
+
|
|
252
|
+
const chooseCaption = useCallback((index: number) => {
|
|
253
|
+
const list = videoRef.current?.textTracks;
|
|
254
|
+
if (!list) return;
|
|
255
|
+
if (index !== -1) preferred.current = index;
|
|
256
|
+
player.showCaption(list, index);
|
|
257
|
+
setCaption(index);
|
|
258
|
+
}, []);
|
|
259
|
+
|
|
260
|
+
const toggleCaptions = useCallback(() => {
|
|
261
|
+
const list = videoRef.current?.textTracks;
|
|
262
|
+
const available = player.captionTracks(list);
|
|
263
|
+
if (available.length === 0) return;
|
|
264
|
+
const active = player.activeCaption(list);
|
|
265
|
+
if (active !== -1) return chooseCaption(-1);
|
|
266
|
+
const back = preferred.current;
|
|
267
|
+
chooseCaption(back !== null && available.some((track) => track.index === back) ? back : available[0]!.index);
|
|
268
|
+
}, [chooseCaption]);
|
|
269
|
+
|
|
270
|
+
const setRate = useCallback((rate: number) => {
|
|
271
|
+
const video = videoRef.current;
|
|
272
|
+
if (!video) return;
|
|
273
|
+
video.playbackRate = rate;
|
|
274
|
+
setSpeed(rate);
|
|
275
|
+
}, []);
|
|
276
|
+
|
|
277
|
+
const setLoop = useCallback((on: boolean) => {
|
|
278
|
+
const video = videoRef.current;
|
|
279
|
+
if (!video) return;
|
|
280
|
+
// The element has no event for this one, so the state is set beside it rather than
|
|
281
|
+
// read back from a `loopchange` that does not exist.
|
|
282
|
+
video.loop = on;
|
|
283
|
+
setLooping(on);
|
|
284
|
+
}, []);
|
|
285
|
+
|
|
286
|
+
const goFullscreen = useCallback(() => {
|
|
287
|
+
const wrapper = wrapperRef.current;
|
|
288
|
+
if (wrapper) void player.toggleFullscreen(wrapper, videoRef.current);
|
|
289
|
+
}, []);
|
|
290
|
+
|
|
291
|
+
const goPip = useCallback(() => {
|
|
292
|
+
const video = videoRef.current;
|
|
293
|
+
if (video) void player.togglePip(video).catch(() => { /* refused, or already gone */ });
|
|
294
|
+
}, []);
|
|
295
|
+
|
|
296
|
+
const goCast = useCallback(() => {
|
|
297
|
+
const video = videoRef.current;
|
|
298
|
+
// The picker is the platform's: nothing here can enumerate the screens on a network.
|
|
299
|
+
if (video) void player.promptRemote(video).catch(() => { /* dismissed, or nothing found */ });
|
|
300
|
+
}, []);
|
|
301
|
+
|
|
302
|
+
/** What the two "copy the link" rows put on the clipboard. */
|
|
303
|
+
const linkTo = useCallback((atTime: boolean): string | null => {
|
|
304
|
+
const video = videoRef.current;
|
|
305
|
+
const raw = video?.currentSrc || (typeof src === "string" ? src : sources[0]?.src);
|
|
306
|
+
if (!raw) return null;
|
|
307
|
+
try {
|
|
308
|
+
const url = new URL(raw, typeof location === "undefined" ? undefined : location.href);
|
|
309
|
+
// A media fragment, which is what a plain file understands - the platforms that
|
|
310
|
+
// spell it `?t=` are answering their own player, not the video.
|
|
311
|
+
if (atTime) url.hash = `t=${Math.floor(video?.currentTime ?? 0)}`;
|
|
312
|
+
return url.href;
|
|
313
|
+
} catch {
|
|
314
|
+
return raw;
|
|
315
|
+
}
|
|
316
|
+
}, [src, sources]);
|
|
317
|
+
|
|
318
|
+
/* -------- the keyboard, and the bar that gets out of the way -------- */
|
|
319
|
+
|
|
320
|
+
const wake = useCallback(() => {
|
|
321
|
+
setIdle(false);
|
|
322
|
+
setArmed(true);
|
|
323
|
+
}, []);
|
|
324
|
+
|
|
325
|
+
const onKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
|
326
|
+
if (!keyboard || event.defaultPrevented || player.isTypingTarget(event.target)) return;
|
|
327
|
+
const command = player.commandFor(event.key);
|
|
328
|
+
if (!command) return;
|
|
329
|
+
event.preventDefault();
|
|
330
|
+
// The bar comes back for a key as well as for the pointer. Without it, seeking or
|
|
331
|
+
// changing the volume from the keyboard in fullscreen shows nothing at all.
|
|
332
|
+
wake();
|
|
333
|
+
|
|
334
|
+
if (command.type === "toggle") return toggle();
|
|
335
|
+
if (command.type === "seek") return seekBy(command.by);
|
|
336
|
+
if (command.type === "seekTo") return seekTo(command.fraction);
|
|
337
|
+
if (command.type === "volume") return setVolumeTo((videoRef.current?.volume ?? 0) + command.by);
|
|
338
|
+
if (command.type === "mute") return toggleMute();
|
|
339
|
+
if (command.type === "fullscreen") return goFullscreen();
|
|
340
|
+
if (command.type === "captions") return toggleCaptions();
|
|
341
|
+
if (command.type === "pip" && pip) return goPip();
|
|
342
|
+
}, [keyboard, wake, toggle, seekBy, seekTo, setVolumeTo, toggleMute, goFullscreen, toggleCaptions, goPip, pip]);
|
|
343
|
+
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
/**
|
|
346
|
+
* Only while playing, never with a menu open, and never while the pointer is ON the
|
|
347
|
+
* bar.
|
|
348
|
+
*
|
|
349
|
+
* The last one is the fullscreen complaint: the controls used to go away under the
|
|
350
|
+
* cursor that was travelling towards them, which reads as the buttons disappearing
|
|
351
|
+
* rather than as a bar getting out of the way. It is worst fullscreen, where the
|
|
352
|
+
* distance to the button is the width of a screen.
|
|
353
|
+
*/
|
|
354
|
+
if (!autoHide || !playing || menu || idle || overBar) return;
|
|
355
|
+
const timer = window.setTimeout(() => setIdle(true), autoHideDelay);
|
|
356
|
+
return () => window.clearTimeout(timer);
|
|
357
|
+
}, [autoHide, playing, menu, idle, overBar, autoHideDelay, time]);
|
|
358
|
+
|
|
359
|
+
useEffect(() => { if (!playing) setIdle(false); }, [playing]);
|
|
360
|
+
|
|
361
|
+
// The settings panel closes on a press anywhere else, the way every menu does.
|
|
362
|
+
useEffect(() => {
|
|
363
|
+
if (!menu) return;
|
|
364
|
+
const onPointerDown = (event: globalThis.PointerEvent): void => {
|
|
365
|
+
if (!wrapperRef.current?.querySelector("[data-enigma-video-menu]")?.contains(event.target as Node)) setMenu(false);
|
|
366
|
+
};
|
|
367
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
368
|
+
return () => document.removeEventListener("pointerdown", onPointerDown, true);
|
|
369
|
+
}, [menu]);
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* How tall the settings panel is allowed to be.
|
|
373
|
+
*
|
|
374
|
+
* The player clips its own overflow - it has to, or a rounded corner is not rounded - and
|
|
375
|
+
* the panel opens UPWARD from a button sitting on the bottom edge. So a panel taller than
|
|
376
|
+
* the picture is a panel with its top cut off, which is what a speed list plus a language
|
|
377
|
+
* list is on any player under about 300px. Measured rather than guessed: the room is the
|
|
378
|
+
* distance from the top of the player to the top of the button that opened it, and what
|
|
379
|
+
* does not fit scrolls.
|
|
380
|
+
*/
|
|
381
|
+
useLayoutEffect(() => {
|
|
382
|
+
if (!menu) {
|
|
383
|
+
setPanelMax(null);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const wrapper = wrapperRef.current;
|
|
387
|
+
const button = settingsRef.current;
|
|
388
|
+
if (!wrapper || !button) return;
|
|
389
|
+
const room = button.getBoundingClientRect().top - wrapper.getBoundingClientRect().top;
|
|
390
|
+
setPanelMax(Math.max(PANEL_MIN, room - PANEL_GAP));
|
|
391
|
+
}, [menu, fullscreen]);
|
|
392
|
+
|
|
393
|
+
/* -------- the menu a right-click opens -------- */
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The opener, once the menu's chunk has landed and mounted.
|
|
397
|
+
*
|
|
398
|
+
* State rather than a ref, because the player has to RENDER differently for it: the
|
|
399
|
+
* wrapper carries `data-menu` while a right-click will be answered, and until then the
|
|
400
|
+
* press is left to the browser's own menu.
|
|
401
|
+
*/
|
|
402
|
+
const [opener, setOpener] = useState<OpenVideoMenu | null>(null);
|
|
403
|
+
const onMenuReady = useCallback((open: OpenVideoMenu | null) => setOpener(() => open), []);
|
|
404
|
+
|
|
405
|
+
const text: Required<Pick<VideoLabels, "play" | "pause">> & VideoLabels = {
|
|
406
|
+
play: labels.play ?? "Play",
|
|
407
|
+
pause: labels.pause ?? "Pause",
|
|
408
|
+
...labels
|
|
409
|
+
};
|
|
410
|
+
const captionsLabel = labels.captions ?? "Captions";
|
|
411
|
+
const subtitlesLabel = labels.subtitles ?? "Subtitles";
|
|
412
|
+
const offLabel = labels.captionsOff ?? "Off";
|
|
413
|
+
const speedLabel = labels.speed ?? "Speed";
|
|
414
|
+
const normalLabel = labels.normal ?? "Normal";
|
|
415
|
+
const fullscreenLabel = fullscreen ? labels.exitFullscreen ?? "Exit fullscreen" : labels.enterFullscreen ?? "Fullscreen";
|
|
416
|
+
|
|
417
|
+
const menuRows = useMemo<ContextMenuNode[]>(() => {
|
|
418
|
+
if (!menuOptions) return [];
|
|
419
|
+
// No source, no link: the two rows would sit there copying nothing, which is what a
|
|
420
|
+
// player showing only its poster does before anybody has given it a file.
|
|
421
|
+
const link = menuOptions.copyUrl !== false && sources.length > 0;
|
|
422
|
+
|
|
423
|
+
/** Blocks, so a section that turned out empty does not leave two rules against each other. */
|
|
424
|
+
const blocks: ContextMenuNode[][] = [
|
|
425
|
+
[
|
|
426
|
+
{ id: "toggle", label: playing ? text.pause : text.play, icon: playing ? <icons.Pause /> : <icons.Play />, shortcut: "K" },
|
|
427
|
+
{ id: "loop", label: labels.loop ?? "Loop", icon: <icons.Loop />, checked: looping },
|
|
428
|
+
{
|
|
429
|
+
id: "speed",
|
|
430
|
+
label: speedLabel,
|
|
431
|
+
icon: <icons.Speed />,
|
|
432
|
+
items: speeds.map((rate) => ({
|
|
433
|
+
id: `speed:${rate}`,
|
|
434
|
+
label: rate === 1 ? normalLabel : `${rate}x`,
|
|
435
|
+
group: "speed",
|
|
436
|
+
checked: speed === rate
|
|
437
|
+
}))
|
|
438
|
+
},
|
|
439
|
+
...(captionList.length > 0 ? [{
|
|
440
|
+
id: "captions",
|
|
441
|
+
label: subtitlesLabel,
|
|
442
|
+
icon: caption === -1 ? <icons.CaptionsOff /> : <icons.Captions />,
|
|
443
|
+
items: [
|
|
444
|
+
{ id: "caption:off", label: offLabel, group: "caption", checked: caption === -1 },
|
|
445
|
+
...captionList.map((track) => ({
|
|
446
|
+
id: `caption:${track.index}`,
|
|
447
|
+
label: track.label,
|
|
448
|
+
group: "caption",
|
|
449
|
+
checked: caption === track.index
|
|
450
|
+
}))
|
|
451
|
+
]
|
|
452
|
+
} satisfies ContextMenuNode] : [])
|
|
453
|
+
],
|
|
454
|
+
link ? [
|
|
455
|
+
{ id: "copy-url", label: labels.copyUrl ?? "Copy video URL", icon: <icons.Link /> },
|
|
456
|
+
{ id: "copy-url-time", label: labels.copyUrlAtTime ?? "Copy video URL at current time", icon: <icons.Clock /> }
|
|
457
|
+
] : [],
|
|
458
|
+
[
|
|
459
|
+
...(pip ? [{ id: "pip", label: labels.pip ?? "Picture in picture", icon: <icons.Pip />, shortcut: "P" } satisfies ContextMenuNode] : []),
|
|
460
|
+
...(cast ? [{
|
|
461
|
+
id: "cast",
|
|
462
|
+
label: remote?.connected ? labels.stopCast ?? "Stop casting" : labels.cast ?? "Play on a TV",
|
|
463
|
+
icon: <icons.Cast />,
|
|
464
|
+
checked: remote?.connected
|
|
465
|
+
} satisfies ContextMenuNode] : []),
|
|
466
|
+
{ id: "fullscreen", label: fullscreenLabel, icon: fullscreen ? <icons.Collapse /> : <icons.Expand />, shortcut: "F" }
|
|
467
|
+
],
|
|
468
|
+
[...(menuOptions.items ?? [])]
|
|
469
|
+
];
|
|
470
|
+
|
|
471
|
+
return blocks.filter((block) => block.length > 0).flatMap((block, at) => (at === 0 ? block : [{ type: "separator" } as ContextMenuNode, ...block]));
|
|
472
|
+
}, [
|
|
473
|
+
menuOptions, sources, playing, text.play, text.pause, looping, labels.loop, labels.copyUrl, labels.copyUrlAtTime, labels.pip, labels.cast, labels.stopCast,
|
|
474
|
+
speedLabel, normalLabel, speeds, speed, captionList, caption, subtitlesLabel, offLabel, pip, cast, remote?.connected, fullscreen, fullscreenLabel
|
|
475
|
+
]);
|
|
476
|
+
|
|
477
|
+
const onMenuSelect = useCallback((item: ContextMenuItem) => {
|
|
478
|
+
const id = item.id;
|
|
479
|
+
if (id === "toggle") toggle();
|
|
480
|
+
else if (id === "loop") setLoop(!looping);
|
|
481
|
+
else if (id.startsWith("speed:")) setRate(Number(id.slice("speed:".length)));
|
|
482
|
+
else if (id === "caption:off") chooseCaption(-1);
|
|
483
|
+
else if (id.startsWith("caption:")) chooseCaption(Number(id.slice("caption:".length)));
|
|
484
|
+
else if (id === "copy-url" || id === "copy-url-time") {
|
|
485
|
+
const url = linkTo(id === "copy-url-time");
|
|
486
|
+
// Refused, or no clipboard at all: nothing is thrown at a console over a menu row.
|
|
487
|
+
if (url) void navigator.clipboard?.writeText(url).catch(() => { /* denied */ });
|
|
488
|
+
}
|
|
489
|
+
else if (id === "pip") goPip();
|
|
490
|
+
else if (id === "cast") goCast();
|
|
491
|
+
else if (id === "fullscreen") goFullscreen();
|
|
492
|
+
menuOptions?.onSelect?.(item);
|
|
493
|
+
}, [toggle, setLoop, looping, setRate, chooseCaption, linkTo, goPip, goCast, goFullscreen, menuOptions]);
|
|
494
|
+
|
|
495
|
+
const onContextMenu = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
|
496
|
+
// No chunk yet: the browser's own menu is a better answer than a press that does
|
|
497
|
+
// nothing, so the event is left alone rather than swallowed.
|
|
498
|
+
if (!menuOptions || !opener) return;
|
|
499
|
+
event.preventDefault();
|
|
500
|
+
opener({ x: event.clientX, y: event.clientY });
|
|
501
|
+
}, [menuOptions, opener]);
|
|
502
|
+
|
|
503
|
+
const played = player.progress(time, duration);
|
|
504
|
+
|
|
505
|
+
return (
|
|
506
|
+
<div
|
|
507
|
+
{...wrapperProps}
|
|
508
|
+
ref={wrapperRef}
|
|
509
|
+
data-enigma-video=""
|
|
510
|
+
data-playing={playing ? "" : undefined}
|
|
511
|
+
data-hidden={idle ? "" : undefined}
|
|
512
|
+
data-fullscreen={fullscreen ? "" : undefined}
|
|
513
|
+
data-casting={remote?.connected ? "" : undefined}
|
|
514
|
+
data-menu={opener ? "" : undefined}
|
|
515
|
+
role="region"
|
|
516
|
+
aria-label={labels.player ?? "Video player"}
|
|
517
|
+
tabIndex={-1}
|
|
518
|
+
onKeyDown={onKeyDown}
|
|
519
|
+
onPointerMove={wake}
|
|
520
|
+
onPointerEnter={() => setArmed(true)}
|
|
521
|
+
onPointerDown={() => setArmed(true)}
|
|
522
|
+
onPointerLeave={() => { if (playing && autoHide) setIdle(true); }}
|
|
523
|
+
onFocus={wake}
|
|
524
|
+
onContextMenu={onContextMenu}
|
|
525
|
+
>
|
|
526
|
+
<video
|
|
527
|
+
{...rest}
|
|
528
|
+
ref={videoRef}
|
|
529
|
+
poster={poster}
|
|
530
|
+
src={typeof src === "string" ? src : undefined}
|
|
531
|
+
playsInline={rest.playsInline ?? true}
|
|
532
|
+
onClick={() => { if (clickToPlay) toggle(); }}
|
|
533
|
+
>
|
|
534
|
+
{typeof src !== "string" && sources.map((source) => <source key={source.src} src={source.src} type={source.type} />)}
|
|
535
|
+
{tracks?.map((track) => (
|
|
536
|
+
<track
|
|
537
|
+
key={track.src}
|
|
538
|
+
src={track.src}
|
|
539
|
+
kind={track.kind ?? "subtitles"}
|
|
540
|
+
srcLang={track.srcLang}
|
|
541
|
+
label={track.label}
|
|
542
|
+
default={track.default}
|
|
543
|
+
/>
|
|
544
|
+
))}
|
|
545
|
+
</video>
|
|
546
|
+
|
|
547
|
+
<div data-enigma-video-overlay="">
|
|
548
|
+
{waiting && <span data-enigma-video-spinner="" role="progressbar" aria-label={labels.player ?? "Video player"} />}
|
|
549
|
+
{show?.play && !playing && !waiting && (
|
|
550
|
+
<button type="button" data-enigma-video-big="" aria-label={text.play} title={text.play} onClick={toggle}>
|
|
551
|
+
<icons.Play />
|
|
552
|
+
</button>
|
|
553
|
+
)}
|
|
554
|
+
{children}
|
|
555
|
+
</div>
|
|
556
|
+
|
|
557
|
+
{show && (
|
|
558
|
+
<div
|
|
559
|
+
data-enigma-video-controls=""
|
|
560
|
+
onPointerEnter={() => setOverBar(true)}
|
|
561
|
+
onPointerLeave={() => setOverBar(false)}
|
|
562
|
+
>
|
|
563
|
+
{show.play && (
|
|
564
|
+
<button type="button" data-enigma-video-button="" aria-label={playing ? text.pause : text.play} title={playing ? text.pause : text.play} onClick={toggle}>
|
|
565
|
+
{playing ? <icons.Pause /> : <icons.Play />}
|
|
566
|
+
</button>
|
|
567
|
+
)}
|
|
568
|
+
|
|
569
|
+
{show.currentTime && <span data-enigma-video-time="">{player.formatTime(time, duration)}</span>}
|
|
570
|
+
|
|
571
|
+
{show.progress && (
|
|
572
|
+
<Rail
|
|
573
|
+
value={played}
|
|
574
|
+
secondary={buffered}
|
|
575
|
+
label={labels.seek ?? "Seek"}
|
|
576
|
+
valueText={`${player.formatTime(time, duration)} of ${player.formatTime(duration, duration)}`}
|
|
577
|
+
onChange={seekTo}
|
|
578
|
+
/>
|
|
579
|
+
)}
|
|
580
|
+
|
|
581
|
+
{show.duration && <span data-enigma-video-time="">{player.formatTime(duration, duration)}</span>}
|
|
582
|
+
|
|
583
|
+
{show.volume && (
|
|
584
|
+
<div data-enigma-video-volume="">
|
|
585
|
+
<button type="button" data-enigma-video-button="" aria-label={muted ? labels.unmute ?? "Unmute" : labels.mute ?? "Mute"} title={muted ? labels.unmute ?? "Unmute" : labels.mute ?? "Mute"} onClick={toggleMute}>
|
|
586
|
+
{muted || volume === 0 ? <icons.Muted /> : volume < 0.5 ? <icons.VolumeLow /> : <icons.Volume />}
|
|
587
|
+
</button>
|
|
588
|
+
<Rail value={muted ? 0 : volume} label={labels.volume ?? "Volume"} onChange={setVolumeTo} />
|
|
589
|
+
</div>
|
|
590
|
+
)}
|
|
591
|
+
|
|
592
|
+
{show.captions && captionList.length > 0 && (
|
|
593
|
+
<button type="button" data-enigma-video-button="" aria-pressed={caption !== -1} aria-label={captionsLabel} title={captionsLabel} onClick={toggleCaptions}>
|
|
594
|
+
{caption === -1 ? <icons.CaptionsOff /> : <icons.Captions />}
|
|
595
|
+
</button>
|
|
596
|
+
)}
|
|
597
|
+
|
|
598
|
+
{show.settings && (
|
|
599
|
+
<div data-enigma-video-menu="">
|
|
600
|
+
<button
|
|
601
|
+
ref={settingsRef}
|
|
602
|
+
type="button"
|
|
603
|
+
data-enigma-video-button=""
|
|
604
|
+
aria-haspopup="menu"
|
|
605
|
+
aria-expanded={menu}
|
|
606
|
+
aria-label={labels.settings ?? "Settings"}
|
|
607
|
+
title={labels.settings ?? "Settings"}
|
|
608
|
+
onClick={() => setMenu((current) => !current)}
|
|
609
|
+
>
|
|
610
|
+
<icons.Settings />
|
|
611
|
+
</button>
|
|
612
|
+
{menu && (
|
|
613
|
+
<div
|
|
614
|
+
data-enigma-video-panel=""
|
|
615
|
+
role="menu"
|
|
616
|
+
aria-label={labels.settings ?? "Settings"}
|
|
617
|
+
style={panelMax === null ? undefined : { maxHeight: `${panelMax}px` }}
|
|
618
|
+
onKeyDown={(event) => { if (event.key === "Escape") setMenu(false); }}
|
|
619
|
+
>
|
|
620
|
+
<p data-enigma-video-heading="">{speedLabel}</p>
|
|
621
|
+
{speeds.map((rate) => (
|
|
622
|
+
<button
|
|
623
|
+
key={rate}
|
|
624
|
+
type="button"
|
|
625
|
+
role="menuitemradio"
|
|
626
|
+
data-enigma-video-option="speed"
|
|
627
|
+
aria-checked={speed === rate}
|
|
628
|
+
onClick={() => { setRate(rate); setMenu(false); }}
|
|
629
|
+
>
|
|
630
|
+
<span>{rate === 1 ? normalLabel : `${rate}x`}</span>
|
|
631
|
+
{speed === rate && <icons.Check />}
|
|
632
|
+
</button>
|
|
633
|
+
))}
|
|
634
|
+
|
|
635
|
+
{captionList.length > 0 && (
|
|
636
|
+
<>
|
|
637
|
+
<p data-enigma-video-heading="">{subtitlesLabel}</p>
|
|
638
|
+
<button
|
|
639
|
+
type="button"
|
|
640
|
+
role="menuitemradio"
|
|
641
|
+
data-enigma-video-option="captions"
|
|
642
|
+
aria-checked={caption === -1}
|
|
643
|
+
onClick={() => { chooseCaption(-1); setMenu(false); }}
|
|
644
|
+
>
|
|
645
|
+
<span>{offLabel}</span>
|
|
646
|
+
{caption === -1 && <icons.Check />}
|
|
647
|
+
</button>
|
|
648
|
+
{captionList.map((track) => (
|
|
649
|
+
<button
|
|
650
|
+
key={track.index}
|
|
651
|
+
type="button"
|
|
652
|
+
role="menuitemradio"
|
|
653
|
+
data-enigma-video-option="captions"
|
|
654
|
+
aria-checked={caption === track.index}
|
|
655
|
+
lang={track.language || undefined}
|
|
656
|
+
onClick={() => { chooseCaption(track.index); setMenu(false); }}
|
|
657
|
+
>
|
|
658
|
+
<span>{track.label}</span>
|
|
659
|
+
{caption === track.index && <icons.Check />}
|
|
660
|
+
</button>
|
|
661
|
+
))}
|
|
662
|
+
</>
|
|
663
|
+
)}
|
|
664
|
+
</div>
|
|
665
|
+
)}
|
|
666
|
+
</div>
|
|
667
|
+
)}
|
|
668
|
+
|
|
669
|
+
{show.download && (
|
|
670
|
+
<a
|
|
671
|
+
data-enigma-video-button=""
|
|
672
|
+
href={download ?? sources[0]?.src ?? "#"}
|
|
673
|
+
download
|
|
674
|
+
rel="noopener"
|
|
675
|
+
aria-label={labels.download ?? "Download"}
|
|
676
|
+
title={labels.download ?? "Download"}
|
|
677
|
+
>
|
|
678
|
+
<icons.Download />
|
|
679
|
+
</a>
|
|
680
|
+
)}
|
|
681
|
+
|
|
682
|
+
{cast && (
|
|
683
|
+
<button
|
|
684
|
+
type="button"
|
|
685
|
+
data-enigma-video-button=""
|
|
686
|
+
aria-pressed={remote?.connected ?? false}
|
|
687
|
+
aria-label={remote?.connected ? labels.stopCast ?? "Stop casting" : labels.cast ?? "Play on a TV"}
|
|
688
|
+
title={remote?.connected ? labels.stopCast ?? "Stop casting" : labels.cast ?? "Play on a TV"}
|
|
689
|
+
onClick={goCast}
|
|
690
|
+
>
|
|
691
|
+
<icons.Cast />
|
|
692
|
+
</button>
|
|
693
|
+
)}
|
|
694
|
+
|
|
695
|
+
{pip && (
|
|
696
|
+
<button type="button" data-enigma-video-button="" aria-label={labels.pip ?? "Picture in picture"} title={labels.pip ?? "Picture in picture"} onClick={goPip}>
|
|
697
|
+
<icons.Pip />
|
|
698
|
+
</button>
|
|
699
|
+
)}
|
|
700
|
+
|
|
701
|
+
{show.fullscreen && (
|
|
702
|
+
<button
|
|
703
|
+
type="button"
|
|
704
|
+
data-enigma-video-button=""
|
|
705
|
+
aria-label={fullscreenLabel}
|
|
706
|
+
title={fullscreenLabel}
|
|
707
|
+
onClick={goFullscreen}
|
|
708
|
+
>
|
|
709
|
+
{fullscreen ? <icons.Collapse /> : <icons.Expand />}
|
|
710
|
+
</button>
|
|
711
|
+
)}
|
|
712
|
+
</div>
|
|
713
|
+
)}
|
|
714
|
+
|
|
715
|
+
{menuOptions && armed && menuRows.length > 0 && (
|
|
716
|
+
<Suspense fallback={null}>
|
|
717
|
+
<VideoMenu rows={menuRows} title={menuOptions.title} onSelect={onMenuSelect} onReady={onMenuReady} />
|
|
718
|
+
</Suspense>
|
|
719
|
+
)}
|
|
720
|
+
</div>
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export type { VideoProps, VideoSource, VideoTrack, VideoControls, VideoLabels, VideoContextMenuOptions } from "@/react/video/types";
|