@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,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, type ReactNode } from "react";
|
|
4
|
+
import type { ContextMenuPoint } from "@/core/context-menu";
|
|
5
|
+
import { ContextMenu, useContextMenuContext, type ContextMenuItem, type ContextMenuNode } from "@/react/context-menu";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The menu a right-click on the player opens, the way YouTube's does.
|
|
9
|
+
*
|
|
10
|
+
* It IS the context menu component. Writing a second popup here would mean a second keyboard
|
|
11
|
+
* model, a second set of roles, a second thing to fix - and the `fe-context-menu-hand-rolled`
|
|
12
|
+
* guardrail exists to say so. The rows come from the player, because only the player knows
|
|
13
|
+
* what is playing.
|
|
14
|
+
*
|
|
15
|
+
* Its own chunk, mounted the first time a pointer reaches the player rather than on render: a
|
|
16
|
+
* page of embedded videos nobody right-clicks downloads none of it, and a pointer arrives long
|
|
17
|
+
* before the second button does. A press that beats the chunk falls through to the browser's
|
|
18
|
+
* own menu, which is a better answer than swallowing it.
|
|
19
|
+
*
|
|
20
|
+
* There is no trigger part. The player opens the menu itself, at the point of the press, so
|
|
21
|
+
* what this renders into the tree is nothing at all - the panel is portalled.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type OpenVideoMenu = (point: ContextMenuPoint) => boolean;
|
|
25
|
+
|
|
26
|
+
export interface VideoMenuProps {
|
|
27
|
+
rows: readonly ContextMenuNode[];
|
|
28
|
+
title?: string;
|
|
29
|
+
onSelect: (item: ContextMenuItem) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Called with the opener once the menu is live, and with null when it goes away.
|
|
32
|
+
*
|
|
33
|
+
* The player opens the menu itself, at the point of the press, so this is how the two
|
|
34
|
+
* halves meet - and it is also what tells the player that its menu is READY. Until it
|
|
35
|
+
* arrives a right-click is left to the browser rather than swallowed.
|
|
36
|
+
*/
|
|
37
|
+
onReady: (open: OpenVideoMenu | null) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function VideoMenu({ rows, title, onSelect, onReady }: VideoMenuProps): ReactNode {
|
|
41
|
+
return (
|
|
42
|
+
<ContextMenu.Root
|
|
43
|
+
items={rows}
|
|
44
|
+
title={title}
|
|
45
|
+
// Nothing in a player is writable and nothing in it is selected: Copy, Cut and
|
|
46
|
+
// Paste built from the press would be three rows that never do anything.
|
|
47
|
+
clipboard={false}
|
|
48
|
+
onSelect={onSelect}
|
|
49
|
+
>
|
|
50
|
+
<Publish onReady={onReady} />
|
|
51
|
+
<ContextMenu.Content />
|
|
52
|
+
</ContextMenu.Root>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Hands the opener up to the player, which is the only part that knows where the press was. */
|
|
57
|
+
function Publish({ onReady }: { onReady: (open: OpenVideoMenu | null) => void; }): ReactNode {
|
|
58
|
+
const menu = useContextMenuContext("Video.Menu");
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
onReady(menu.open);
|
|
62
|
+
return () => onReady(null);
|
|
63
|
+
}, [menu.open, onReady]);
|
|
64
|
+
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as player from "@/core/player";
|
|
4
|
+
import { useCallback, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One horizontal rail, used by both the scrubber and the volume.
|
|
8
|
+
*
|
|
9
|
+
* They are the same control: a fraction set by a press, dragged with the pointer captured so
|
|
10
|
+
* it keeps following outside the box, and driven by the arrows for a keyboard. Writing it
|
|
11
|
+
* twice would mean fixing the drag twice.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface RailProps {
|
|
15
|
+
/** Where the filled part ends, 0 to 1. */
|
|
16
|
+
value: number;
|
|
17
|
+
/** A second, dimmer fill behind it - what is buffered. Omit for a plain rail. */
|
|
18
|
+
secondary?: number;
|
|
19
|
+
onChange: (fraction: number) => void;
|
|
20
|
+
/** Fired once the drag ends, for a caller that only wants the final value. */
|
|
21
|
+
onCommit?: (fraction: number) => void;
|
|
22
|
+
label: string;
|
|
23
|
+
/** What a screen reader reads instead of a bare percentage. */
|
|
24
|
+
valueText?: string;
|
|
25
|
+
step?: number;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Rail({ value, secondary, onChange, onCommit, label, valueText, step = 0.05 }: RailProps): ReactNode {
|
|
30
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
31
|
+
const [dragging, setDragging] = useState(false);
|
|
32
|
+
|
|
33
|
+
const fractionAt = useCallback((clientX: number): number => {
|
|
34
|
+
const box = ref.current?.getBoundingClientRect();
|
|
35
|
+
return box ? player.fractionAt(box, clientX) : 0;
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const onPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
|
39
|
+
if (event.button !== 0 && event.pointerType === "mouse") return;
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
event.currentTarget.focus();
|
|
42
|
+
setDragging(true);
|
|
43
|
+
try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* a pointer already gone */ }
|
|
44
|
+
onChange(fractionAt(event.clientX));
|
|
45
|
+
}, [fractionAt, onChange]);
|
|
46
|
+
|
|
47
|
+
const onPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
|
48
|
+
if (!dragging) return;
|
|
49
|
+
onChange(fractionAt(event.clientX));
|
|
50
|
+
}, [dragging, fractionAt, onChange]);
|
|
51
|
+
|
|
52
|
+
const end = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
|
53
|
+
if (!dragging) return;
|
|
54
|
+
setDragging(false);
|
|
55
|
+
onCommit?.(fractionAt(event.clientX));
|
|
56
|
+
}, [dragging, fractionAt, onCommit]);
|
|
57
|
+
|
|
58
|
+
const onKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
|
59
|
+
const moves: Record<string, number> = {
|
|
60
|
+
ArrowLeft: -step, ArrowDown: -step, ArrowRight: step, ArrowUp: step
|
|
61
|
+
};
|
|
62
|
+
const by = moves[event.key];
|
|
63
|
+
if (by === undefined && event.key !== "Home" && event.key !== "End") return;
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
// Stopped here as well: the player's own shortcuts answer the same arrows, and a rail
|
|
66
|
+
// that lets them through would seek the video while somebody sets the volume.
|
|
67
|
+
event.stopPropagation();
|
|
68
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? 1 : Math.min(Math.max(value + (by ?? 0), 0), 1);
|
|
69
|
+
onChange(next);
|
|
70
|
+
onCommit?.(next);
|
|
71
|
+
}, [step, value, onChange, onCommit]);
|
|
72
|
+
|
|
73
|
+
const percent = Math.round(value * 100);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
ref={ref}
|
|
78
|
+
data-enigma-video-rail=""
|
|
79
|
+
role="slider"
|
|
80
|
+
tabIndex={0}
|
|
81
|
+
aria-label={label}
|
|
82
|
+
aria-valuemin={0}
|
|
83
|
+
aria-valuemax={100}
|
|
84
|
+
aria-valuenow={percent}
|
|
85
|
+
aria-valuetext={valueText ?? `${percent}%`}
|
|
86
|
+
onPointerDown={onPointerDown}
|
|
87
|
+
onPointerMove={onPointerMove}
|
|
88
|
+
onPointerUp={end}
|
|
89
|
+
onPointerCancel={end}
|
|
90
|
+
onKeyDown={onKeyDown}
|
|
91
|
+
>
|
|
92
|
+
<div data-enigma-video-track="">
|
|
93
|
+
{secondary !== undefined && <span data-enigma-video-buffer="" style={{ width: `${Math.round(secondary * 100)}%` }} />}
|
|
94
|
+
<span data-enigma-video-played="" style={{ width: `${percent}%` }} />
|
|
95
|
+
</div>
|
|
96
|
+
<span data-enigma-video-knob="" style={{ left: `${percent}%` }} />
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The player's look, shaped after Plyr's: a bar over a gradient, a thin rail that grows under
|
|
3
|
+
* the pointer, a round play button in the middle while paused.
|
|
4
|
+
*
|
|
5
|
+
* It ships a theme for the reason the lightbox and the colour picker do: an unstyled player is
|
|
6
|
+
* not a plain player, it is a column of buttons under a video. `styles={false}` takes it away,
|
|
7
|
+
* and every colour and distance is a custom property, so a project usually only overrides the
|
|
8
|
+
* accent.
|
|
9
|
+
*
|
|
10
|
+
* `scripts/sync-recipes.mjs` generates `recipes/video/styles.css` from this module and CI
|
|
11
|
+
* fails when the two drift.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const VIDEO_STYLES = `
|
|
15
|
+
:root {
|
|
16
|
+
--enigma-video-accent: #3b82f6;
|
|
17
|
+
--enigma-video-bg: #000000;
|
|
18
|
+
--enigma-video-text: #ffffff;
|
|
19
|
+
--enigma-video-muted: rgba(255, 255, 255, 0.72);
|
|
20
|
+
--enigma-video-rail: rgba(255, 255, 255, 0.28);
|
|
21
|
+
--enigma-video-buffer: rgba(255, 255, 255, 0.45);
|
|
22
|
+
--enigma-video-panel-bg: rgba(23, 23, 23, 0.96);
|
|
23
|
+
--enigma-video-radius: 0.5rem;
|
|
24
|
+
--enigma-video-control-size: 2rem;
|
|
25
|
+
/* The icons are sized HERE and not in em of the bar's text: 1.25em of a 13px label is a
|
|
26
|
+
16px icon, and the same markup in a project with larger type silently grew a different
|
|
27
|
+
player. */
|
|
28
|
+
--enigma-video-icon-size: 1.125rem;
|
|
29
|
+
--enigma-video-rail-height: 0.3125rem;
|
|
30
|
+
--enigma-video-rail-hover: 0.5rem;
|
|
31
|
+
--enigma-video-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
[data-enigma-video] {
|
|
35
|
+
position: relative; display: block; width: 100%; overflow: hidden;
|
|
36
|
+
background: var(--enigma-video-bg); border-radius: var(--enigma-video-radius);
|
|
37
|
+
color: var(--enigma-video-text);
|
|
38
|
+
font-size: 0.8125rem; line-height: 1;
|
|
39
|
+
}
|
|
40
|
+
[data-enigma-video] video { display: block; width: 100%; height: 100%; }
|
|
41
|
+
/* Fullscreen needs no sizing of its own: the UA stylesheet pins the fullscreen element to the
|
|
42
|
+
screen with !important, which outranks anything a page or this sheet can say, and a video
|
|
43
|
+
element already fits itself with object-fit: contain. Only the corner is ours. */
|
|
44
|
+
[data-enigma-video][data-fullscreen] { border-radius: 0; }
|
|
45
|
+
[data-enigma-video][data-hidden] { cursor: none; }
|
|
46
|
+
|
|
47
|
+
/* The big one, while nothing is playing. Plyr's shape: a filled circle over the poster,
|
|
48
|
+
large enough to be the obvious target on a phone. */
|
|
49
|
+
[data-enigma-video-big] {
|
|
50
|
+
position: absolute; inset: 0; margin: auto;
|
|
51
|
+
display: grid; place-items: center;
|
|
52
|
+
width: 3.75rem; height: 3.75rem; padding: 0;
|
|
53
|
+
color: var(--enigma-video-text); background: var(--enigma-video-accent);
|
|
54
|
+
border: 0; border-radius: 999px; box-shadow: var(--enigma-video-shadow);
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
}
|
|
57
|
+
[data-enigma-video-big]:focus-visible { outline: 2px solid var(--enigma-video-text); outline-offset: 3px; }
|
|
58
|
+
|
|
59
|
+
[data-enigma-video-overlay] { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; }
|
|
60
|
+
[data-enigma-video-overlay] > * { pointer-events: auto; }
|
|
61
|
+
|
|
62
|
+
[data-enigma-video-spinner] {
|
|
63
|
+
width: 2rem; height: 2rem;
|
|
64
|
+
border: 2px solid rgba(255, 255, 255, 0.25); border-top-color: var(--enigma-video-text);
|
|
65
|
+
border-radius: 999px; animation: enigma-video-spin 700ms linear infinite;
|
|
66
|
+
}
|
|
67
|
+
@keyframes enigma-video-spin { to { rotate: 360deg; } }
|
|
68
|
+
@media (prefers-reduced-motion: reduce) {
|
|
69
|
+
[data-enigma-video-spinner] { animation-duration: 2s; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
[data-enigma-video-controls] {
|
|
73
|
+
position: absolute; inset-inline: 0; bottom: 0;
|
|
74
|
+
display: flex; align-items: center; gap: 0.375rem;
|
|
75
|
+
padding: 2.5rem 0.625rem 0.625rem;
|
|
76
|
+
background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent);
|
|
77
|
+
transition: opacity 180ms ease, translate 180ms ease;
|
|
78
|
+
}
|
|
79
|
+
[data-enigma-video][data-hidden] [data-enigma-video-controls] { opacity: 0; translate: 0 0.5rem; pointer-events: none; }
|
|
80
|
+
@media (prefers-reduced-motion: reduce) {
|
|
81
|
+
[data-enigma-video-controls] { transition: none; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
[data-enigma-video-button] {
|
|
85
|
+
display: grid; place-items: center; flex: none;
|
|
86
|
+
width: var(--enigma-video-control-size); height: var(--enigma-video-control-size);
|
|
87
|
+
padding: 0; color: var(--enigma-video-text); background: none; border: 0; border-radius: 0.375rem;
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
}
|
|
90
|
+
[data-enigma-video-button] svg { width: var(--enigma-video-icon-size); height: var(--enigma-video-icon-size); flex: none; }
|
|
91
|
+
[data-enigma-video-big] svg { width: 1.5rem; height: 1.5rem; }
|
|
92
|
+
[data-enigma-video-button]:hover { background: rgba(255, 255, 255, 0.16); }
|
|
93
|
+
[data-enigma-video-button]:focus-visible { outline: 2px solid var(--enigma-video-text); outline-offset: 1px; }
|
|
94
|
+
[data-enigma-video-button][aria-pressed="true"] { color: var(--enigma-video-accent); }
|
|
95
|
+
|
|
96
|
+
[data-enigma-video-time] { flex: none; color: var(--enigma-video-muted); font-variant-numeric: tabular-nums; }
|
|
97
|
+
|
|
98
|
+
/* The rail. Its hit area is the row, not the 5px line: a scrubber you have to hit exactly is
|
|
99
|
+
one people miss and then seek by accident. */
|
|
100
|
+
[data-enigma-video-rail] {
|
|
101
|
+
position: relative; flex: 1; min-width: 3rem; height: 1rem;
|
|
102
|
+
display: flex; align-items: center;
|
|
103
|
+
cursor: pointer; touch-action: none;
|
|
104
|
+
}
|
|
105
|
+
[data-enigma-video-track] {
|
|
106
|
+
position: relative; width: 100%; height: var(--enigma-video-rail-height);
|
|
107
|
+
background: var(--enigma-video-rail); border-radius: 999px; overflow: hidden;
|
|
108
|
+
transition: height 120ms ease;
|
|
109
|
+
}
|
|
110
|
+
[data-enigma-video-rail]:hover [data-enigma-video-track],
|
|
111
|
+
[data-enigma-video-rail]:focus-visible [data-enigma-video-track] { height: var(--enigma-video-rail-hover); }
|
|
112
|
+
[data-enigma-video-rail]:focus-visible { outline: 2px solid var(--enigma-video-text); outline-offset: 2px; border-radius: 999px; }
|
|
113
|
+
[data-enigma-video-buffer] { position: absolute; inset-block: 0; left: 0; background: var(--enigma-video-buffer); }
|
|
114
|
+
[data-enigma-video-played] { position: absolute; inset-block: 0; left: 0; background: var(--enigma-video-accent); }
|
|
115
|
+
[data-enigma-video-knob] {
|
|
116
|
+
position: absolute; top: 50%; translate: -50% -50%;
|
|
117
|
+
width: 0.75rem; height: 0.75rem; border-radius: 999px;
|
|
118
|
+
background: var(--enigma-video-text); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
|
119
|
+
pointer-events: none;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* The volume rail folds away until the control is reached, which is what keeps a narrow
|
|
123
|
+
player from spending a third of its bar on something rarely touched. */
|
|
124
|
+
[data-enigma-video-volume] { display: flex; align-items: center; flex: none; }
|
|
125
|
+
[data-enigma-video-volume] [data-enigma-video-rail] {
|
|
126
|
+
width: 0; min-width: 0; opacity: 0; transition: width 140ms ease, opacity 140ms ease;
|
|
127
|
+
}
|
|
128
|
+
[data-enigma-video-volume]:hover [data-enigma-video-rail],
|
|
129
|
+
[data-enigma-video-volume]:focus-within [data-enigma-video-rail] { width: 4.5rem; min-width: 4.5rem; opacity: 1; }
|
|
130
|
+
|
|
131
|
+
[data-enigma-video-menu] { position: relative; flex: none; }
|
|
132
|
+
/* The panel opens upward, and the player CLIPS - it has to, or a rounded corner is not
|
|
133
|
+
rounded. So its height is bounded by the room above the button that opened it (measured, and
|
|
134
|
+
set inline) and what does not fit scrolls. Without this a speed list plus a language list is
|
|
135
|
+
a panel with its top cut off on any player under about 300px. */
|
|
136
|
+
[data-enigma-video-panel] {
|
|
137
|
+
position: absolute; right: 0; bottom: calc(100% + 0.5rem); z-index: 2;
|
|
138
|
+
box-sizing: border-box; min-width: 8.5rem; max-width: calc(100vw - 2rem); padding: 0.25rem;
|
|
139
|
+
max-height: 14rem; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin;
|
|
140
|
+
background: var(--enigma-video-panel-bg); border-radius: 0.5rem; box-shadow: var(--enigma-video-shadow);
|
|
141
|
+
}
|
|
142
|
+
[data-enigma-video-option] {
|
|
143
|
+
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
|
|
144
|
+
width: 100%; padding: 0.4375rem 0.5rem;
|
|
145
|
+
color: var(--enigma-video-text); background: none; border: 0; border-radius: 0.375rem;
|
|
146
|
+
font: inherit; text-align: left; cursor: pointer;
|
|
147
|
+
}
|
|
148
|
+
[data-enigma-video-option]:hover { background: rgba(255, 255, 255, 0.12); }
|
|
149
|
+
[data-enigma-video-option][aria-checked="true"] { color: var(--enigma-video-accent); }
|
|
150
|
+
[data-enigma-video-option]:focus-visible { outline: 2px solid var(--enigma-video-text); outline-offset: -2px; }
|
|
151
|
+
/* Sticky because the panel scrolls: the language you are choosing between should still say
|
|
152
|
+
which list it belongs to when the speeds have scrolled past it. */
|
|
153
|
+
[data-enigma-video-heading] {
|
|
154
|
+
position: sticky; top: 0; z-index: 1; margin: 0;
|
|
155
|
+
padding: 0.375rem 0.5rem; color: var(--enigma-video-muted);
|
|
156
|
+
background: var(--enigma-video-panel-bg);
|
|
157
|
+
font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.06em;
|
|
158
|
+
}
|
|
159
|
+
[data-enigma-video-option] svg { width: 0.875rem; height: 0.875rem; flex: none; }
|
|
160
|
+
[data-enigma-video-option] span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
161
|
+
`;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The prop shapes for `<Video>`.
|
|
3
|
+
*
|
|
4
|
+
* Shaped after Plyr, which is the player this one is measured against: the same control set,
|
|
5
|
+
* the same shortcuts, the same auto-hiding bar. What differs is that the controls are the
|
|
6
|
+
* component's own markup rather than a string of HTML it parses, so a project styles them
|
|
7
|
+
* with the attributes below instead of overriding a stylesheet it did not write.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
11
|
+
import type { ContextMenuItem, ContextMenuNode } from "@/react/context-menu/context";
|
|
12
|
+
|
|
13
|
+
export interface VideoSource {
|
|
14
|
+
src: string;
|
|
15
|
+
/** `video/mp4`, `video/webm`. The browser picks the first one it can play. */
|
|
16
|
+
type?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface VideoTrack {
|
|
20
|
+
src: string;
|
|
21
|
+
/** BCP 47: `en`, `es-419`. */
|
|
22
|
+
srcLang: string;
|
|
23
|
+
label: string;
|
|
24
|
+
kind?: "subtitles" | "captions" | "descriptions" | "chapters" | "metadata";
|
|
25
|
+
default?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Which controls the bar carries. Everything is on except `download`, as Plyr has it. */
|
|
29
|
+
export interface VideoControls {
|
|
30
|
+
play?: boolean;
|
|
31
|
+
progress?: boolean;
|
|
32
|
+
currentTime?: boolean;
|
|
33
|
+
duration?: boolean;
|
|
34
|
+
volume?: boolean;
|
|
35
|
+
captions?: boolean;
|
|
36
|
+
settings?: boolean;
|
|
37
|
+
pip?: boolean;
|
|
38
|
+
/** Play on a TV. Feature-detected, and absent until the browser reports a screen to cast to. */
|
|
39
|
+
cast?: boolean;
|
|
40
|
+
fullscreen?: boolean;
|
|
41
|
+
/** Off by default: a video a page shows is not always one it means to hand over. */
|
|
42
|
+
download?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface VideoLabels {
|
|
46
|
+
play?: string;
|
|
47
|
+
pause?: string;
|
|
48
|
+
mute?: string;
|
|
49
|
+
unmute?: string;
|
|
50
|
+
volume?: string;
|
|
51
|
+
seek?: string;
|
|
52
|
+
captions?: string;
|
|
53
|
+
/** The row that turns subtitles off. Default "Off". */
|
|
54
|
+
captionsOff?: string;
|
|
55
|
+
/** The heading over the language rows. Default "Subtitles". */
|
|
56
|
+
subtitles?: string;
|
|
57
|
+
settings?: string;
|
|
58
|
+
speed?: string;
|
|
59
|
+
normal?: string;
|
|
60
|
+
pip?: string;
|
|
61
|
+
cast?: string;
|
|
62
|
+
/** While it is playing on the other screen. Default "Stop casting". */
|
|
63
|
+
stopCast?: string;
|
|
64
|
+
/** The context menu's rows. */
|
|
65
|
+
loop?: string;
|
|
66
|
+
copyUrl?: string;
|
|
67
|
+
copyUrlAtTime?: string;
|
|
68
|
+
enterFullscreen?: string;
|
|
69
|
+
exitFullscreen?: string;
|
|
70
|
+
download?: string;
|
|
71
|
+
/** The player itself, as a screen reader announces the region. Default "Video player". */
|
|
72
|
+
player?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// `contextMenu` is taken off the element's own props: it is a long-dead HTML attribute
|
|
76
|
+
// naming a `<menu>` element, and this one is a menu the player draws.
|
|
77
|
+
export interface VideoProps extends Omit<ComponentPropsWithoutRef<"video">, "controls" | "children" | "src" | "contextMenu"> {
|
|
78
|
+
/** One file, or the list the browser should choose from. */
|
|
79
|
+
src?: string | readonly VideoSource[];
|
|
80
|
+
poster?: string;
|
|
81
|
+
tracks?: readonly VideoTrack[];
|
|
82
|
+
/** The control bar. `false` leaves a bare `<video>` with nothing over it. */
|
|
83
|
+
controls?: boolean | VideoControls;
|
|
84
|
+
/** Rates the settings menu offers. Defaults to Plyr's 0.5 through 2. */
|
|
85
|
+
speeds?: readonly number[];
|
|
86
|
+
/** Space, arrows, M, F, C and the digits. On by default, and never while typing. */
|
|
87
|
+
keyboard?: boolean;
|
|
88
|
+
/** A press on the picture plays and pauses. On by default. */
|
|
89
|
+
clickToPlay?: boolean;
|
|
90
|
+
/** Hide the bar while playing, until the pointer moves. On by default. */
|
|
91
|
+
autoHide?: boolean;
|
|
92
|
+
/** How long the pointer has to be still before the bar goes, in ms. Default 2600. */
|
|
93
|
+
autoHideDelay?: number;
|
|
94
|
+
/**
|
|
95
|
+
* The menu a right-click opens, shaped the way YouTube's is. ON by default.
|
|
96
|
+
*
|
|
97
|
+
* `false` gives the browser's own menu back - which is what a page wants when the point of
|
|
98
|
+
* the video is that it can be saved from the native menu.
|
|
99
|
+
*/
|
|
100
|
+
contextMenu?: boolean | VideoContextMenuOptions;
|
|
101
|
+
/** The player's stylesheet, injected once. There is no useful unstyled player. */
|
|
102
|
+
styles?: boolean;
|
|
103
|
+
labels?: VideoLabels;
|
|
104
|
+
/** The URL the download control saves. Defaults to what is playing. */
|
|
105
|
+
download?: string;
|
|
106
|
+
/** Rendered over the picture, under the controls - a title, a badge, your own overlay. */
|
|
107
|
+
children?: ReactNode;
|
|
108
|
+
/** Props for the element wrapping the video and its controls; this is what you size. */
|
|
109
|
+
wrapperProps?: ComponentPropsWithoutRef<"div">;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface VideoContextMenuOptions {
|
|
113
|
+
/** Rows of your own, after the built-in ones. */
|
|
114
|
+
items?: readonly ContextMenuNode[];
|
|
115
|
+
/** A row was invoked - yours, or one of the built-in ids. */
|
|
116
|
+
onSelect?: (item: ContextMenuItem) => void;
|
|
117
|
+
/** The two "copy the link" rows. On whenever the menu is, where there is a URL to copy. */
|
|
118
|
+
copyUrl?: boolean;
|
|
119
|
+
/** The heading over the rows, naming what the menu is acting on. */
|
|
120
|
+
title?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const CONTROL_DEFAULTS: Required<VideoControls> = {
|
|
124
|
+
play: true,
|
|
125
|
+
progress: true,
|
|
126
|
+
currentTime: true,
|
|
127
|
+
duration: true,
|
|
128
|
+
volume: true,
|
|
129
|
+
captions: true,
|
|
130
|
+
settings: true,
|
|
131
|
+
pip: true,
|
|
132
|
+
cast: true,
|
|
133
|
+
fullscreen: true,
|
|
134
|
+
download: false
|
|
135
|
+
};
|
|
File without changes
|
|
File without changes
|