@enigmax/primitives 0.24.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.
Files changed (59) hide show
  1. package/dist/chunk-4SZ5N7BA.js +121 -0
  2. package/dist/chunk-EYZ366LP.js +189 -0
  3. package/dist/chunk-ISXB5RUH.js +380 -0
  4. package/dist/chunk-JJVZYW5C.js +946 -0
  5. package/dist/chunk-KVLSOQTI.js +66 -0
  6. package/dist/chunk-QIIV2QSV.js +108 -0
  7. package/dist/{chunk-QJJ34E4G.js → chunk-RMNXVWQ4.js} +32 -15
  8. package/dist/chunk-RUEQ3TGP.js +155 -0
  9. package/dist/clipboard-menu-BvXJCSFN.d.ts +93 -0
  10. package/dist/{color-OPV3BJV6.js → color-T63FLJNH.js} +183 -120
  11. package/dist/context-BfjYGPnH.d.ts +54 -0
  12. package/dist/{clipboard-menu-B_ouitfS.d.ts → context-menu-D3FtTn7v.d.ts} +1 -91
  13. package/dist/{index-ZPvlf9vs.d.ts → index-qXOkYQCU.d.ts} +4 -0
  14. package/dist/index.d.ts +266 -2
  15. package/dist/index.js +4 -2
  16. package/dist/menu-MSQSE6U5.js +54 -0
  17. package/dist/menu-PNQRTRNV.js +33 -0
  18. package/dist/next/index.d.ts +7 -3
  19. package/dist/next/index.js +11 -7
  20. package/dist/react/context-menu.d.ts +8 -54
  21. package/dist/react/image.d.ts +109 -0
  22. package/dist/react/image.js +3 -0
  23. package/dist/react/index.d.ts +8 -4
  24. package/dist/react/index.js +11 -7
  25. package/dist/react/input.d.ts +1 -1
  26. package/dist/react/input.js +2 -2
  27. package/dist/react/video.d.ts +120 -0
  28. package/dist/react/video.js +3 -0
  29. package/dist/react-router/index.d.ts +7 -3
  30. package/dist/react-router/index.js +11 -7
  31. package/dist/viewer-73LSE6RR.js +3 -0
  32. package/package.json +15 -2
  33. package/recipes/color/styles.css +20 -0
  34. package/recipes/image/styles.css +182 -0
  35. package/recipes/video/styles.css +154 -0
  36. package/registry.json +477 -3
  37. package/src/core/image-viewer.ts +222 -0
  38. package/src/core/player.ts +303 -0
  39. package/src/index.ts +52 -0
  40. package/src/react/image/icons.tsx +55 -0
  41. package/src/react/image/index.tsx +185 -0
  42. package/src/react/image/menu.tsx +76 -0
  43. package/src/react/image/styles.ts +213 -0
  44. package/src/react/image/types.ts +113 -0
  45. package/src/react/image/viewer.tsx +571 -0
  46. package/src/react/index.ts +3 -0
  47. package/src/react/input/color-styles.ts +20 -0
  48. package/src/react/input/color-swatch.tsx +96 -0
  49. package/src/react/input/color.tsx +84 -23
  50. package/src/react/input/index.tsx +27 -4
  51. package/src/react/input/types.ts +4 -0
  52. package/src/react/video/icons.tsx +135 -0
  53. package/src/react/video/index.tsx +724 -0
  54. package/src/react/video/menu.tsx +66 -0
  55. package/src/react/video/rail.tsx +99 -0
  56. package/src/react/video/styles.ts +161 -0
  57. package/src/react/video/types.ts +135 -0
  58. package/dist/chunk-S7EE57YB.js +0 -9
  59. /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";