@varialkit/video 0.1.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/docs.md ADDED
@@ -0,0 +1,59 @@
1
+ # Video
2
+
3
+ The `Video` component wraps native HTML `<video>` playback with Solara styling and a custom control system.
4
+
5
+ ## Usage
6
+
7
+ ```tsx
8
+ import { Video } from "@solara/video";
9
+
10
+ export function Example() {
11
+ return (
12
+ <Video
13
+ src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
14
+ poster="https://interactive-examples.mdn.mozilla.net/media/examples/friday.jpg"
15
+ controlsType="control-bar"
16
+ caption="Demo clip"
17
+ />
18
+ );
19
+ }
20
+ ```
21
+
22
+ ## Control Types
23
+
24
+ - `controlsType="control-bar"`: full-width custom bar with icon buttons for playback, sound, and fullscreen, plus scrubber/time/volume.
25
+ - `controlsType="quick"`: floating action-only bar with icon-based `Play`/`Pause`/`Restart`.
26
+ - `controlsType="minimal"`: floating action-only bar with icon-based `Play`/`Pause`/`Restart`.
27
+ - `controlsType="none"`: hides all custom controls.
28
+
29
+ Playback icon states: `media_play_filled_16` -> `media_pause_16` -> `arrow_line_rotate_left_16`.
30
+ Sound toggle states: `media_sound_filled_16` and `media_sound_off_filled_16`.
31
+ Fullscreen toggle icon: `view_box_inside_16`.
32
+
33
+ ## Props
34
+
35
+ | Prop | Type | Default | Description |
36
+ | --- | --- | --- | --- |
37
+ | `showControls` | `boolean` | `true` | Legacy visibility switch for custom controls. |
38
+ | `controlsType` | `"control-bar" \| "quick" \| "minimal" \| "none"` | derived | Primary control type selector. |
39
+ | `showScrubber` | `boolean` | `true` | Shows seek slider in `control-bar` mode. |
40
+ | `showTimeDisplay` | `boolean` | `true` | Shows elapsed/total time in `control-bar` mode. |
41
+ | `showVolumeControl` | `boolean` | `true` | Shows sound icon toggle and volume slider in `control-bar` mode. |
42
+ | `showFullscreenControl` | `boolean` | `true` | Shows fullscreen icon control in `control-bar` mode. |
43
+ | `controlBarClassName` | `string` | — | Class hook for custom control-bar styling. |
44
+ | `tapToTogglePlayback` | `boolean` | `true` | Enables tapping/clicking the video surface to toggle play/pause. |
45
+ | `controlsAutoHideDelay` | `number` | `1400` | Milliseconds before auto-hiding the `control-bar` after inactivity. |
46
+ | `sources` | `VideoSource[]` | — | Optional `<source>` definitions for multiple formats. |
47
+ | `aspectRatio` | `"auto" \| "16:9" \| "4:3" \| "1:1" \| "21:9" \| number` | `"16:9"` | Controls wrapper ratio. |
48
+ | `fit` | `"contain" \| "cover" \| "fill" \| "none" \| "scale-down"` | `"cover"` | Controls media fitting in frame. |
49
+ | `caption` | `ReactNode` | — | Optional caption content shown below the player. |
50
+ | `showCaption` | `boolean` | `true` | Toggles caption visibility. |
51
+ | `rounded` | `boolean` | `true` | Applies rounded corners to the frame. |
52
+ | `shadow` | `boolean` | `false` | Applies a subtle elevation shadow. |
53
+ | `videoClassName` | `string` | — | Additional class for the `<video>` node. |
54
+
55
+ `Video` also supports native video attributes such as `src`, `poster`, `autoPlay`, `loop`, `muted`, `playsInline`,
56
+ `preload`, and event handlers.
57
+
58
+ Legacy note: `controlBarMode` is still supported for backward compatibility, but new usage should prefer
59
+ `controlsType` only.
package/examples.tsx ADDED
@@ -0,0 +1,169 @@
1
+ import React from "react";
2
+ import { Video } from "./src/Video";
3
+ import type { VideoProps } from "./src/Video.types";
4
+
5
+ const sampleVideo = "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4";
6
+ const samplePoster = "https://interactive-examples.mdn.mozilla.net/media/examples/friday.jpg";
7
+
8
+ export const stories = {
9
+ playground: {
10
+ title: "Playground",
11
+ description: "Adjust player controls, ratio, and playback settings.",
12
+ render: (props: VideoProps & { captionText?: string }) => {
13
+ const { captionText, ...videoProps } = props;
14
+ return <Video {...videoProps} caption={captionText} />;
15
+ },
16
+ controls: [
17
+ { name: "src", type: "text" },
18
+ { name: "poster", type: "text" },
19
+ {
20
+ name: "aspectRatio",
21
+ type: "select",
22
+ options: ["auto", "16:9", "4:3", "1:1", "21:9"],
23
+ },
24
+ {
25
+ name: "controlsType",
26
+ type: "select",
27
+ options: ["control-bar", "quick", "minimal", "none"],
28
+ },
29
+ {
30
+ name: "fit",
31
+ type: "select",
32
+ options: ["contain", "cover", "fill", "none", "scale-down"],
33
+ },
34
+ { name: "showControls", type: "boolean", label: "Show controls" },
35
+ { name: "showScrubber", type: "boolean", label: "Show scrubber" },
36
+ { name: "showTimeDisplay", type: "boolean", label: "Show time" },
37
+ { name: "showVolumeControl", type: "boolean", label: "Show volume controls" },
38
+ { name: "showFullscreenControl", type: "boolean", label: "Show fullscreen control" },
39
+ { name: "tapToTogglePlayback", type: "boolean", label: "Tap video toggles play" },
40
+ { name: "controlsAutoHideDelay", type: "number", label: "Controls auto-hide delay (ms)" },
41
+ { name: "autoPlay", type: "boolean", label: "Autoplay" },
42
+ { name: "muted", type: "boolean", label: "Muted" },
43
+ { name: "loop", type: "boolean", label: "Loop" },
44
+ { name: "playsInline", type: "boolean", label: "Plays inline" },
45
+ { name: "showCaption", type: "boolean", label: "Show caption" },
46
+ { name: "captionText", type: "text", label: "Caption" },
47
+ { name: "rounded", type: "boolean", label: "Rounded" },
48
+ { name: "shadow", type: "boolean", label: "Shadow" },
49
+ {
50
+ name: "preload",
51
+ type: "select",
52
+ options: ["none", "metadata", "auto"],
53
+ },
54
+ ],
55
+ initialProps: {
56
+ src: sampleVideo,
57
+ poster: samplePoster,
58
+ aspectRatio: "16:9",
59
+ controlsType: "control-bar",
60
+ fit: "cover",
61
+ showControls: true,
62
+ showScrubber: true,
63
+ showTimeDisplay: true,
64
+ showVolumeControl: true,
65
+ showFullscreenControl: true,
66
+ tapToTogglePlayback: true,
67
+ controlsAutoHideDelay: 1400,
68
+ autoPlay: false,
69
+ muted: true,
70
+ loop: false,
71
+ playsInline: true,
72
+ showCaption: true,
73
+ captionText: "A calm flower field clip used for video demos.",
74
+ rounded: true,
75
+ shadow: false,
76
+ preload: "metadata",
77
+ },
78
+ },
79
+ ratios: {
80
+ title: "Aspect Ratios",
81
+ description: "Built-in ratio presets for common media frames.",
82
+ showProps: false,
83
+ render: () => (
84
+ <div style={{ display: "grid", gap: "1rem" }}>
85
+ <Video src={sampleVideo} aspectRatio="16:9" caption="16:9 widescreen" />
86
+ <Video src={sampleVideo} aspectRatio="4:3" caption="4:3 standard" />
87
+ <Video src={sampleVideo} aspectRatio="1:1" caption="1:1 square" />
88
+ </div>
89
+ ),
90
+ code: `import { Video } from "@solara/video";
91
+
92
+ export function Example() {
93
+ const src = "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4";
94
+
95
+ return (
96
+ <div style={{ display: "grid", gap: "1rem" }}>
97
+ <Video src={src} aspectRatio="16:9" caption="16:9 widescreen" />
98
+ <Video src={src} aspectRatio="4:3" caption="4:3 standard" />
99
+ <Video src={src} aspectRatio="1:1" caption="1:1 square" />
100
+ </div>
101
+ );
102
+ }
103
+ `,
104
+ },
105
+ minimal: {
106
+ title: "Minimal Player",
107
+ description: "A floating action-only control bar with icon-based play, pause, and restart.",
108
+ showProps: false,
109
+ render: () => (
110
+ <Video
111
+ src={sampleVideo}
112
+ poster={samplePoster}
113
+ controlsType="minimal"
114
+ muted
115
+ showCaption={false}
116
+ shadow
117
+ />
118
+ ),
119
+ code: `import { Video } from "@solara/video";
120
+
121
+ export function Example() {
122
+ return (
123
+ <Video
124
+ src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
125
+ poster="https://interactive-examples.mdn.mozilla.net/media/examples/friday.jpg"
126
+ controlsType="minimal"
127
+ muted
128
+ showCaption={false}
129
+ shadow
130
+ />
131
+ );
132
+ }
133
+ `,
134
+ },
135
+ noControls: {
136
+ title: "No Controls",
137
+ description: "Hides custom controls entirely for ambient or decorative playback.",
138
+ showProps: false,
139
+ render: () => (
140
+ <Video
141
+ src={sampleVideo}
142
+ poster={samplePoster}
143
+ controlsType="none"
144
+ muted
145
+ loop
146
+ autoPlay
147
+ showCaption={false}
148
+ shadow
149
+ />
150
+ ),
151
+ code: `import { Video } from "@solara/video";
152
+
153
+ export function Example() {
154
+ return (
155
+ <Video
156
+ src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
157
+ poster="https://interactive-examples.mdn.mozilla.net/media/examples/friday.jpg"
158
+ controlsType="none"
159
+ muted
160
+ loop
161
+ autoPlay
162
+ showCaption={false}
163
+ shadow
164
+ />
165
+ );
166
+ }
167
+ `,
168
+ },
169
+ };
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@varialkit/video",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./examples": "./examples.tsx"
10
+ },
11
+ "dependencies": {
12
+ "@varialkit/icons": "0.1.0"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "docs.md",
17
+ "examples.tsx"
18
+ ],
19
+ "peerDependencies": {
20
+ "react": "^19.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/react": "19.0.10",
24
+ "react": "19.0.0"
25
+ }
26
+ }
package/src/Video.scss ADDED
@@ -0,0 +1,317 @@
1
+ .solara-video {
2
+ --video-border: var(--color-divider-secondary);
3
+ --video-background: var(--color-surface-200);
4
+ --video-caption: var(--color-text-secondary);
5
+ --video-radius: var(--radius-3);
6
+ --video-controls-surface: rgba(6, 10, 16, 0.76);
7
+ --video-controls-surface-soft: rgba(6, 10, 16, 0.45);
8
+ --video-controls-foreground: var(--color-text-inverse);
9
+
10
+ display: grid;
11
+ gap: calc(var(--space-2) * var(--spacing-multiplier));
12
+ width: 100%;
13
+ margin: 0;
14
+ font-family: var(--font-body);
15
+ }
16
+
17
+ .solara-video__frame {
18
+ position: relative;
19
+ width: 100%;
20
+ overflow: hidden;
21
+ border: 1px solid var(--video-border);
22
+ border-radius: var(--video-radius);
23
+ background: var(--video-background);
24
+ aspect-ratio: 16 / 9;
25
+ }
26
+
27
+ .solara-video__element {
28
+ display: block;
29
+ width: 100%;
30
+ height: 100%;
31
+ background: var(--video-background);
32
+ }
33
+
34
+ .solara-video__controls {
35
+ position: absolute;
36
+ left: 0;
37
+ right: 0;
38
+ bottom: 0;
39
+ opacity: 0;
40
+ transform: translateY(8px);
41
+ pointer-events: none;
42
+ transition: opacity 160ms ease, transform 160ms ease;
43
+ }
44
+
45
+ .solara-video__frame:hover .solara-video__controls,
46
+ .solara-video__frame:focus-within .solara-video__controls {
47
+ opacity: 1;
48
+ transform: translateY(0);
49
+ pointer-events: auto;
50
+ }
51
+
52
+ .solara-video__controls--visible {
53
+ opacity: 1;
54
+ transform: translateY(0);
55
+ pointer-events: auto;
56
+ }
57
+
58
+ .solara-video__controls--control-bar {
59
+ display: flex;
60
+ align-items: center;
61
+ padding: calc(var(--space-2) * var(--spacing-multiplier));
62
+ background: linear-gradient(to top, var(--video-controls-surface), rgba(0, 0, 0, 0));
63
+ }
64
+
65
+ .solara-video__controls--control-bar .solara-video__controls-row {
66
+ padding: calc(var(--space-1) * var(--spacing-multiplier))
67
+ calc(var(--space-2) * var(--spacing-multiplier));
68
+ // border: 1px solid rgba(255, 255, 255, 0.32);
69
+ border-radius: var(--radius-pill);
70
+ // background: rgba(0, 0, 0, 0.52);
71
+ // box-shadow: 0 8px 18px rgba(0, 0, 0, 0.38);
72
+ // -webkit-backdrop-filter: blur(12px) saturate(1.2);
73
+ // backdrop-filter: blur(12px) saturate(1.2);
74
+ }
75
+
76
+ .solara-video__controls--minimal {
77
+ display: flex;
78
+ justify-content: flex-end;
79
+ padding: calc(var(--space-2) * var(--spacing-multiplier));
80
+ background: linear-gradient(to top, var(--video-controls-surface-soft), rgba(0, 0, 0, 0));
81
+ }
82
+
83
+ .solara-video__controls-row {
84
+ display: flex;
85
+ align-items: center;
86
+ gap: calc(var(--space-2) * var(--spacing-multiplier));
87
+ width: 100%;
88
+ flex-wrap: nowrap;
89
+ }
90
+
91
+ .solara-video__controls-group {
92
+ display: flex;
93
+ align-items: center;
94
+ gap: calc(var(--space-2) * var(--spacing-multiplier));
95
+ min-width: 0;
96
+ }
97
+
98
+ .solara-video__controls-group--end {
99
+ margin-left: auto;
100
+ }
101
+
102
+ .solara-video__control-button {
103
+ appearance: none;
104
+ border: 1px solid rgba(255, 255, 255, 0.38);
105
+ border-radius: var(--radius-pill);
106
+ padding: calc(var(--space-1) * var(--spacing-multiplier))
107
+ calc(var(--space-3) * var(--spacing-multiplier));
108
+ font-size: var(--font-size-caption-scaled);
109
+ line-height: var(--line-height-caption-scaled);
110
+ font-weight: var(--font-weight-semi-bold);
111
+ color: var(--video-controls-foreground);
112
+ background: rgba(0, 0, 0, 0.62);
113
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25), 0 6px 14px rgba(0, 0, 0, 0.35);
114
+ -webkit-backdrop-filter: blur(8px) saturate(1.1);
115
+ backdrop-filter: blur(8px) saturate(1.1);
116
+ cursor: pointer;
117
+ white-space: nowrap;
118
+ }
119
+
120
+ .solara-video__control-button--secondary {
121
+ background: rgba(0, 0, 0, 0.7);
122
+ }
123
+
124
+ .solara-video__control-button--icon {
125
+ display: inline-flex;
126
+ align-items: center;
127
+ justify-content: center;
128
+ width: 2rem;
129
+ height: 2rem;
130
+ padding: 0;
131
+ border-radius: 999px;
132
+ color: #fff;
133
+ }
134
+
135
+ .solara-video__control-icon {
136
+ display: block;
137
+ color: #fff;
138
+ filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.9)) drop-shadow(0 0 4px rgba(0, 0, 0, 0.7));
139
+ }
140
+
141
+ .solara-video__control-icon [fill]:not([fill="none"]) {
142
+ fill: #fff !important;
143
+ }
144
+
145
+ .solara-video__control-icon [stroke]:not([stroke="none"]) {
146
+ stroke: #fff !important;
147
+ }
148
+
149
+ .solara-video__control-button:hover {
150
+ background: rgba(0, 0, 0, 0.78);
151
+ border-color: rgba(255, 255, 255, 0.55);
152
+ }
153
+
154
+ .solara-video__control-button:active {
155
+ background: rgba(0, 0, 0, 0.85);
156
+ }
157
+
158
+ .solara-video__controls--control-bar .solara-video__control-button {
159
+ border-color: transparent;
160
+ background: transparent;
161
+ box-shadow: none;
162
+ -webkit-backdrop-filter: none;
163
+ backdrop-filter: none;
164
+ }
165
+
166
+ .solara-video__controls--control-bar .solara-video__control-button:hover {
167
+ background: rgba(255, 255, 255, 0.16);
168
+ border-color: transparent;
169
+ }
170
+
171
+ .solara-video__controls--control-bar .solara-video__control-button:active {
172
+ background: rgba(255, 255, 255, 0.24);
173
+ }
174
+
175
+ .solara-video__control-button:focus-visible,
176
+ .solara-video__scrubber:focus-visible,
177
+ .solara-video__volume:focus-visible {
178
+ box-shadow: 0 0 0 3px var(--color-focus-halo);
179
+ outline-offset: 2px;
180
+ }
181
+
182
+ .solara-video__scrubber,
183
+ .solara-video__volume {
184
+ appearance: none;
185
+ -webkit-appearance: none;
186
+ cursor: pointer;
187
+ }
188
+
189
+ .solara-video__scrubber-wrap {
190
+ position: relative;
191
+ flex: 1 1 auto;
192
+ min-width: 7rem;
193
+ --solara-video-progress: 0%;
194
+ --solara-video-tooltip-position: 0%;
195
+ }
196
+
197
+ .solara-video__scrubber {
198
+ width: 100%;
199
+ height: 0.5rem;
200
+ border: 0;
201
+ border-radius: 999px;
202
+ background: linear-gradient(
203
+ to right,
204
+ var(--color-accent-primary) 0%,
205
+ var(--color-accent-primary) var(--solara-video-progress),
206
+ rgba(255, 255, 255, 0.34) var(--solara-video-progress),
207
+ rgba(255, 255, 255, 0.34) 100%
208
+ );
209
+ }
210
+
211
+ .solara-video__scrubber::-webkit-slider-runnable-track {
212
+ -webkit-appearance: none;
213
+ height: 0.5rem;
214
+ border-radius: 999px;
215
+ background: transparent;
216
+ }
217
+
218
+ .solara-video__scrubber::-webkit-slider-thumb {
219
+ -webkit-appearance: none;
220
+ appearance: none;
221
+ margin-top: -0.25rem;
222
+ width: 1rem;
223
+ height: 1rem;
224
+ border: 2px solid rgba(0, 0, 0, 0.2);
225
+ border-radius: 999px;
226
+ background: var(--color-text-inverse);
227
+ }
228
+
229
+ .solara-video__scrubber::-moz-range-track {
230
+ height: 0.5rem;
231
+ border: 0;
232
+ border-radius: 999px;
233
+ background: rgba(255, 255, 255, 0.28);
234
+ }
235
+
236
+ .solara-video__scrubber::-moz-range-progress {
237
+ height: 0.5rem;
238
+ border-radius: 999px;
239
+ background: var(--color-accent-primary);
240
+ }
241
+
242
+ .solara-video__scrubber::-moz-range-thumb {
243
+ width: 1rem;
244
+ height: 1rem;
245
+ border: 2px solid rgba(0, 0, 0, 0.2);
246
+ border-radius: 999px;
247
+ background: var(--color-text-inverse);
248
+ }
249
+
250
+ .solara-video__scrubber-tooltip {
251
+ position: absolute;
252
+ left: var(--solara-video-tooltip-position);
253
+ bottom: calc(100% + 0.6rem);
254
+ transform: translateX(-50%);
255
+ padding: 0.15rem 0.4rem;
256
+ border-radius: var(--radius-2);
257
+ font-size: 0.7rem;
258
+ line-height: 1.1;
259
+ color: var(--color-text-inverse);
260
+ background: rgba(0, 0, 0, 0.82);
261
+ white-space: nowrap;
262
+ pointer-events: none;
263
+ }
264
+
265
+ .solara-video__volume {
266
+ width: min(7rem, 30vw);
267
+ height: 0.4rem;
268
+ accent-color: var(--color-accent-primary);
269
+ }
270
+
271
+ .solara-video__time {
272
+ color: #fff;
273
+ font-size: var(--font-size-caption-scaled);
274
+ line-height: var(--line-height-caption-scaled);
275
+ white-space: nowrap;
276
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.95), 0 0 6px rgba(0, 0, 0, 0.6);
277
+ }
278
+
279
+ .solara-video__caption {
280
+ margin: 0;
281
+ font-size: var(--font-size-caption-scaled);
282
+ line-height: var(--line-height-caption-scaled);
283
+ color: var(--video-caption);
284
+ }
285
+
286
+ .solara-video--rounded {
287
+ --video-radius: var(--radius-3);
288
+ }
289
+
290
+ .solara-video--square {
291
+ --video-radius: var(--radius-0);
292
+ }
293
+
294
+ .solara-video--shadow .solara-video__frame {
295
+ box-shadow: var(--elevation-2);
296
+ }
297
+
298
+ @media (max-width: 640px) {
299
+ .solara-video__controls-row {
300
+ gap: calc(var(--space-1) * var(--spacing-multiplier));
301
+ }
302
+
303
+ .solara-video__control-button {
304
+ padding: calc(var(--space-1) * var(--spacing-multiplier))
305
+ calc(var(--space-2) * var(--spacing-multiplier));
306
+ }
307
+
308
+ .solara-video__control-button--icon {
309
+ width: 1.875rem;
310
+ height: 1.875rem;
311
+ padding: 0;
312
+ }
313
+
314
+ .solara-video__volume {
315
+ width: min(5rem, 24vw);
316
+ }
317
+ }
package/src/Video.tsx ADDED
@@ -0,0 +1,606 @@
1
+ import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Icon } from "@solara/icons";
3
+ import type { VideoAspectRatio, VideoProps } from "./Video.types";
4
+ import "./Video.scss";
5
+
6
+ const aspectRatioMap: Record<Exclude<VideoAspectRatio, number>, string | null> = {
7
+ auto: null,
8
+ "16:9": "16 / 9",
9
+ "4:3": "4 / 3",
10
+ "1:1": "1 / 1",
11
+ "21:9": "21 / 9",
12
+ };
13
+
14
+ const resolveAspectRatio = (value: VideoAspectRatio | undefined) => {
15
+ if (value === undefined) return aspectRatioMap["16:9"];
16
+ if (typeof value === "number") return value > 0 ? `${value}` : aspectRatioMap["16:9"];
17
+ return aspectRatioMap[value];
18
+ };
19
+
20
+ const formatTime = (seconds: number) => {
21
+ if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
22
+
23
+ const total = Math.floor(seconds);
24
+ const hours = Math.floor(total / 3600);
25
+ const minutes = Math.floor((total % 3600) / 60);
26
+ const remainder = total % 60;
27
+
28
+ if (hours > 0) {
29
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${remainder
30
+ .toString()
31
+ .padStart(2, "0")}`;
32
+ }
33
+
34
+ return `${minutes}:${remainder.toString().padStart(2, "0")}`;
35
+ };
36
+
37
+ /**
38
+ * Video component built on native `<video>` with a custom, scalable control system.
39
+ *
40
+ * Architecture notes:
41
+ * - The media element remains the source of truth for playback and volume.
42
+ * - React state mirrors key media values so custom controls can render instantly.
43
+ * - `controlsType` is the top-level selector for `control-bar`, `minimal`, or `none`.
44
+ * - `controlBarMode` and `showControls` remain as compatibility inputs and map into `controlsType`.
45
+ */
46
+ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
47
+ (
48
+ {
49
+ showControls = true,
50
+ controlsType,
51
+ controlBarMode = "default",
52
+ tapToTogglePlayback = true,
53
+ controlsAutoHideDelay = 1400,
54
+ showScrubber = true,
55
+ showTimeDisplay = true,
56
+ showVolumeControl = true,
57
+ showFullscreenControl = true,
58
+ sources,
59
+ aspectRatio = "16:9",
60
+ fit = "cover",
61
+ caption,
62
+ showCaption = true,
63
+ rounded = true,
64
+ shadow = false,
65
+ className,
66
+ videoClassName,
67
+ videoStyle,
68
+ controlBarClassName,
69
+ style,
70
+ poster,
71
+ children,
72
+ onPlay,
73
+ onPause,
74
+ onEnded,
75
+ onClick,
76
+ onLoadedMetadata,
77
+ onTimeUpdate,
78
+ onVolumeChange,
79
+ ...videoProps
80
+ },
81
+ ref
82
+ ) => {
83
+ const resolvedAspectRatio = resolveAspectRatio(aspectRatio);
84
+ const frameRef = useRef<HTMLDivElement | null>(null);
85
+ const internalVideoRef = useRef<HTMLVideoElement | null>(null);
86
+ const controlsHideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
87
+ const previousVolumeRef = useRef(1);
88
+
89
+ // Playback state drives button labels and progress UI.
90
+ const [isPlaying, setIsPlaying] = useState(false);
91
+ const [hasEnded, setHasEnded] = useState(false);
92
+ const [duration, setDuration] = useState(0);
93
+ const [currentTime, setCurrentTime] = useState(0);
94
+ const [isScrubbing, setIsScrubbing] = useState(false);
95
+ const [scrubPreviewTime, setScrubPreviewTime] = useState<number | null>(null);
96
+ const [isHoveringScrubber, setIsHoveringScrubber] = useState(false);
97
+ const [hoverPreviewTime, setHoverPreviewTime] = useState<number | null>(null);
98
+ const [isFrameHovered, setIsFrameHovered] = useState(false);
99
+ const [areControlsVisible, setAreControlsVisible] = useState(true);
100
+
101
+ // Audio/fullscreen state is isolated so each concern scales independently.
102
+ const [volume, setVolume] = useState(1);
103
+ const [isMuted, setIsMuted] = useState(false);
104
+ const [isFullscreen, setIsFullscreen] = useState(false);
105
+
106
+ const setVideoRef = useCallback(
107
+ (node: HTMLVideoElement | null) => {
108
+ internalVideoRef.current = node;
109
+
110
+ if (typeof ref === "function") {
111
+ ref(node);
112
+ return;
113
+ }
114
+
115
+ if (ref) {
116
+ ref.current = node;
117
+ }
118
+ },
119
+ [ref]
120
+ );
121
+
122
+ useEffect(() => {
123
+ const video = internalVideoRef.current;
124
+ if (!video) return;
125
+
126
+ // Sync initial values from the underlying media element.
127
+ setIsPlaying(!video.paused && !video.ended);
128
+ setHasEnded(video.ended);
129
+ setDuration(Number.isFinite(video.duration) ? video.duration : 0);
130
+ setCurrentTime(Number.isFinite(video.currentTime) ? video.currentTime : 0);
131
+ setVolume(video.volume);
132
+ setIsMuted(video.muted);
133
+ }, []);
134
+
135
+ useEffect(() => {
136
+ const onFullscreenChange = () => {
137
+ const frame = frameRef.current;
138
+ const activeElement = document.fullscreenElement;
139
+ setIsFullscreen(Boolean(frame && activeElement && frame.contains(activeElement)));
140
+ };
141
+
142
+ document.addEventListener("fullscreenchange", onFullscreenChange);
143
+ return () => {
144
+ document.removeEventListener("fullscreenchange", onFullscreenChange);
145
+ };
146
+ }, []);
147
+
148
+ const togglePlayback = useCallback(() => {
149
+ const video = internalVideoRef.current;
150
+ if (!video) return;
151
+
152
+ if (video.ended || hasEnded) {
153
+ video.currentTime = 0;
154
+ void video.play();
155
+ return;
156
+ }
157
+
158
+ if (!video.paused) {
159
+ video.pause();
160
+ return;
161
+ }
162
+
163
+ void video.play();
164
+ }, [hasEnded]);
165
+
166
+ const seekTo = useCallback((nextTime: number) => {
167
+ const video = internalVideoRef.current;
168
+ if (!video || !Number.isFinite(nextTime)) return;
169
+ video.currentTime = Math.max(0, Math.min(nextTime, duration || nextTime));
170
+ }, [duration]);
171
+
172
+ const getScrubberTimeFromPointer = useCallback(
173
+ (event: React.PointerEvent<HTMLInputElement>) => {
174
+ const activeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
175
+ if (!activeDuration) return 0;
176
+
177
+ const rect = event.currentTarget.getBoundingClientRect();
178
+ const relativeX = event.clientX - rect.left;
179
+ const ratio = Math.max(0, Math.min(relativeX / rect.width, 1));
180
+ return ratio * activeDuration;
181
+ },
182
+ [duration]
183
+ );
184
+
185
+ const setVideoVolume = useCallback((nextVolume: number) => {
186
+ const video = internalVideoRef.current;
187
+ if (!video) return;
188
+
189
+ const clamped = Math.max(0, Math.min(nextVolume, 1));
190
+ video.volume = clamped;
191
+ video.muted = clamped === 0;
192
+
193
+ if (clamped > 0) {
194
+ previousVolumeRef.current = clamped;
195
+ }
196
+ }, []);
197
+
198
+ const toggleMute = useCallback(() => {
199
+ const video = internalVideoRef.current;
200
+ if (!video) return;
201
+
202
+ if (video.muted || video.volume === 0) {
203
+ video.muted = false;
204
+ if (video.volume === 0) {
205
+ video.volume = previousVolumeRef.current > 0 ? previousVolumeRef.current : 1;
206
+ }
207
+ return;
208
+ }
209
+
210
+ previousVolumeRef.current = video.volume > 0 ? video.volume : previousVolumeRef.current;
211
+ video.muted = true;
212
+ }, []);
213
+
214
+ const toggleFullscreen = useCallback(async () => {
215
+ const frame = frameRef.current;
216
+ if (!frame) return;
217
+
218
+ if (!document.fullscreenElement) {
219
+ await frame.requestFullscreen();
220
+ return;
221
+ }
222
+
223
+ await document.exitFullscreen();
224
+ }, []);
225
+
226
+ const wrapperClasses = [
227
+ "solara-video",
228
+ rounded ? "solara-video--rounded" : "solara-video--square",
229
+ shadow ? "solara-video--shadow" : null,
230
+ className,
231
+ ]
232
+ .filter(Boolean)
233
+ .join(" ");
234
+
235
+ const videoClasses = ["solara-video__element", videoClassName]
236
+ .filter(Boolean)
237
+ .join(" ");
238
+
239
+ // Controls API precedence:
240
+ // 1) `controlsType` is the modern source of truth and should be used in new code.
241
+ // 2) `showControls={false}` remains a compatibility escape hatch to force `none`.
242
+ // 3) `controlBarMode` remains supported for older consumers and maps into controlsType semantics.
243
+ const resolvedControlsType = useMemo(() => {
244
+ if (controlsType) return controlsType;
245
+ if (!showControls) return "none";
246
+ return controlBarMode === "quick" ? "quick" : "control-bar";
247
+ }, [controlBarMode, controlsType, showControls]);
248
+
249
+ const clearControlsHideTimer = useCallback(() => {
250
+ if (!controlsHideTimerRef.current) return;
251
+ clearTimeout(controlsHideTimerRef.current);
252
+ controlsHideTimerRef.current = null;
253
+ }, []);
254
+
255
+ const scheduleControlsHide = useCallback(() => {
256
+ // Auto-hide is only for the full control-bar experience.
257
+ if (resolvedControlsType !== "control-bar") return;
258
+ clearControlsHideTimer();
259
+ controlsHideTimerRef.current = setTimeout(() => {
260
+ setAreControlsVisible(false);
261
+ }, Math.max(0, controlsAutoHideDelay));
262
+ }, [clearControlsHideTimer, controlsAutoHideDelay, resolvedControlsType]);
263
+
264
+ const revealControls = useCallback(() => {
265
+ setAreControlsVisible(true);
266
+ clearControlsHideTimer();
267
+
268
+ // On touch devices there may be no hover state, so we still auto-hide after inactivity.
269
+ if (
270
+ resolvedControlsType === "control-bar" &&
271
+ !isFrameHovered &&
272
+ !isScrubbing &&
273
+ !isHoveringScrubber
274
+ ) {
275
+ controlsHideTimerRef.current = setTimeout(() => {
276
+ setAreControlsVisible(false);
277
+ }, Math.max(0, controlsAutoHideDelay));
278
+ }
279
+ }, [
280
+ clearControlsHideTimer,
281
+ controlsAutoHideDelay,
282
+ isFrameHovered,
283
+ isHoveringScrubber,
284
+ isScrubbing,
285
+ resolvedControlsType,
286
+ ]);
287
+
288
+ useEffect(() => {
289
+ // On unmount we clean timers to avoid delayed state updates.
290
+ return () => {
291
+ clearControlsHideTimer();
292
+ };
293
+ }, [clearControlsHideTimer]);
294
+
295
+ useEffect(() => {
296
+ if (resolvedControlsType !== "control-bar") {
297
+ setAreControlsVisible(true);
298
+ clearControlsHideTimer();
299
+ return;
300
+ }
301
+
302
+ if (isFrameHovered || isScrubbing || isHoveringScrubber) {
303
+ revealControls();
304
+ return;
305
+ }
306
+
307
+ scheduleControlsHide();
308
+ }, [
309
+ clearControlsHideTimer,
310
+ isFrameHovered,
311
+ isHoveringScrubber,
312
+ isScrubbing,
313
+ resolvedControlsType,
314
+ scheduleControlsHide,
315
+ revealControls,
316
+ ]);
317
+
318
+ const controlsClasses = [
319
+ "solara-video__controls",
320
+ areControlsVisible ? "solara-video__controls--visible" : null,
321
+ resolvedControlsType === "minimal" || resolvedControlsType === "quick"
322
+ ? "solara-video__controls--minimal"
323
+ : "solara-video__controls--control-bar",
324
+ controlBarClassName,
325
+ ]
326
+ .filter(Boolean)
327
+ .join(" ");
328
+
329
+ const controlButtonLabel = hasEnded ? "Restart" : isPlaying ? "Pause" : "Play";
330
+ const controlButtonIconName = hasEnded
331
+ ? "arrow_line_rotate_left_16"
332
+ : isPlaying
333
+ ? "media_pause_16"
334
+ : "media_play_filled_16";
335
+ const volumeButtonLabel = isMuted || volume === 0 ? "Unmute video" : "Mute video";
336
+ const volumeButtonIconName =
337
+ isMuted || volume === 0 ? "media_sound_off_filled_16" : "media_sound_filled_16";
338
+ const fullscreenButtonLabel = isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
339
+ const resolvedDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
340
+ const safeCurrentTime = Math.min(currentTime, resolvedDuration || currentTime);
341
+ const scrubberValue =
342
+ isScrubbing && scrubPreviewTime !== null ? scrubPreviewTime : safeCurrentTime;
343
+ const tooltipTime =
344
+ isScrubbing && scrubPreviewTime !== null
345
+ ? scrubPreviewTime
346
+ : isHoveringScrubber && hoverPreviewTime !== null
347
+ ? hoverPreviewTime
348
+ : safeCurrentTime;
349
+
350
+ const scrubberPercent = useMemo(() => {
351
+ if (!resolvedDuration) return 0;
352
+ return Math.max(0, Math.min((scrubberValue / resolvedDuration) * 100, 100));
353
+ }, [resolvedDuration, scrubberValue]);
354
+
355
+ const tooltipPercent = useMemo(() => {
356
+ if (!resolvedDuration) return 0;
357
+ return Math.max(0, Math.min((tooltipTime / resolvedDuration) * 100, 100));
358
+ }, [resolvedDuration, tooltipTime]);
359
+
360
+ return (
361
+ <figure className={wrapperClasses} style={style}>
362
+ <div
363
+ ref={frameRef}
364
+ className="solara-video__frame"
365
+ style={resolvedAspectRatio ? { aspectRatio: resolvedAspectRatio } : undefined}
366
+ onPointerEnter={() => {
367
+ setIsFrameHovered(true);
368
+ revealControls();
369
+ }}
370
+ onPointerMove={() => {
371
+ if (resolvedControlsType === "control-bar") {
372
+ revealControls();
373
+ }
374
+ }}
375
+ onPointerLeave={() => {
376
+ setIsFrameHovered(false);
377
+ }}
378
+ onFocusCapture={() => {
379
+ revealControls();
380
+ }}
381
+ onBlurCapture={(event) => {
382
+ const nextFocused = event.relatedTarget as Node | null;
383
+ if (!frameRef.current?.contains(nextFocused)) {
384
+ scheduleControlsHide();
385
+ }
386
+ }}
387
+ >
388
+ <video
389
+ {...videoProps}
390
+ ref={setVideoRef}
391
+ className={videoClasses}
392
+ controls={false}
393
+ poster={poster}
394
+ style={{ objectFit: fit, ...videoStyle }}
395
+ onLoadedMetadata={(event) => {
396
+ const video = event.currentTarget;
397
+ setDuration(Number.isFinite(video.duration) ? video.duration : 0);
398
+ onLoadedMetadata?.(event);
399
+ }}
400
+ onClick={(event) => {
401
+ // Surface taps/clicks can control playback while still honoring consumer callbacks.
402
+ if (tapToTogglePlayback) {
403
+ togglePlayback();
404
+ }
405
+ revealControls();
406
+ onClick?.(event);
407
+ }}
408
+ onTimeUpdate={(event) => {
409
+ const video = event.currentTarget;
410
+ setCurrentTime(video.currentTime);
411
+ onTimeUpdate?.(event);
412
+ }}
413
+ onVolumeChange={(event) => {
414
+ const video = event.currentTarget;
415
+ setVolume(video.volume);
416
+ setIsMuted(video.muted);
417
+ onVolumeChange?.(event);
418
+ }}
419
+ onPlay={(event) => {
420
+ setIsPlaying(true);
421
+ setHasEnded(false);
422
+ revealControls();
423
+ onPlay?.(event);
424
+ }}
425
+ onPause={(event) => {
426
+ // Keep ended-state visual intact when playback naturally finishes.
427
+ if (!event.currentTarget.ended) {
428
+ setHasEnded(false);
429
+ }
430
+ setIsPlaying(false);
431
+ revealControls();
432
+ onPause?.(event);
433
+ }}
434
+ onEnded={(event) => {
435
+ setIsPlaying(false);
436
+ setHasEnded(true);
437
+ revealControls();
438
+ onEnded?.(event);
439
+ }}
440
+ >
441
+ {sources?.map((source) => (
442
+ <source key={`${source.src}-${source.type ?? "auto"}-${source.media ?? "all"}`} {...source} />
443
+ ))}
444
+ {children}
445
+ </video>
446
+
447
+ {resolvedControlsType !== "none" ? (
448
+ <div className={controlsClasses}>
449
+ <div className="solara-video__controls-row">
450
+ <button
451
+ type="button"
452
+ className="solara-video__control-button solara-video__control-button--icon"
453
+ onClick={() => {
454
+ revealControls();
455
+ togglePlayback();
456
+ }}
457
+ aria-label={`${controlButtonLabel} video`}
458
+ >
459
+ <Icon
460
+ name={controlButtonIconName}
461
+ size={16}
462
+ aria-hidden
463
+ className="solara-video__control-icon"
464
+ />
465
+ </button>
466
+
467
+ {resolvedControlsType === "control-bar" && showScrubber ? (
468
+ <div
469
+ className="solara-video__scrubber-wrap"
470
+ style={
471
+ {
472
+ "--solara-video-progress": `${scrubberPercent}%`,
473
+ "--solara-video-tooltip-position": `${tooltipPercent}%`,
474
+ } as React.CSSProperties
475
+ }
476
+ >
477
+ {/* Scrubber tooltip follows hover or drag position via a dedicated % CSS variable. */}
478
+ {isScrubbing || (isHoveringScrubber && resolvedDuration > 0) ? (
479
+ <span className="solara-video__scrubber-tooltip">{formatTime(tooltipTime)}</span>
480
+ ) : null}
481
+ <input
482
+ className="solara-video__scrubber"
483
+ type="range"
484
+ min={0}
485
+ max={resolvedDuration || 0}
486
+ step={0.1}
487
+ value={scrubberValue}
488
+ disabled={!resolvedDuration}
489
+ onPointerDown={(event) => {
490
+ revealControls();
491
+ setIsScrubbing(true);
492
+ setScrubPreviewTime(Number(event.currentTarget.value));
493
+ }}
494
+ onPointerUp={() => {
495
+ setIsScrubbing(false);
496
+ setScrubPreviewTime(null);
497
+ }}
498
+ onPointerCancel={() => {
499
+ setIsScrubbing(false);
500
+ setScrubPreviewTime(null);
501
+ }}
502
+ onPointerEnter={(event) => {
503
+ setIsHoveringScrubber(true);
504
+ setHoverPreviewTime(getScrubberTimeFromPointer(event));
505
+ }}
506
+ onPointerMove={(event) => {
507
+ revealControls();
508
+ setHoverPreviewTime(getScrubberTimeFromPointer(event));
509
+ }}
510
+ onPointerLeave={() => {
511
+ setIsHoveringScrubber(false);
512
+ setHoverPreviewTime(null);
513
+ }}
514
+ onBlur={() => {
515
+ setIsScrubbing(false);
516
+ setScrubPreviewTime(null);
517
+ setIsHoveringScrubber(false);
518
+ setHoverPreviewTime(null);
519
+ }}
520
+ onInput={(event) => {
521
+ revealControls();
522
+ const nextTime = Number(event.currentTarget.value);
523
+ setScrubPreviewTime(nextTime);
524
+ seekTo(nextTime);
525
+ }}
526
+ onChange={(event) => {
527
+ const nextTime = Number(event.currentTarget.value);
528
+ seekTo(nextTime);
529
+ }}
530
+ aria-label="Seek video position"
531
+ />
532
+ </div>
533
+ ) : null}
534
+
535
+ {resolvedControlsType === "control-bar" && showTimeDisplay ? (
536
+ <span className="solara-video__time" aria-live="off">
537
+ {formatTime(currentTime)} / {formatTime(resolvedDuration)}
538
+ </span>
539
+ ) : null}
540
+
541
+ {resolvedControlsType === "control-bar" && showVolumeControl ? (
542
+ <>
543
+ <button
544
+ type="button"
545
+ className="solara-video__control-button solara-video__control-button--secondary solara-video__control-button--icon"
546
+ onClick={() => {
547
+ revealControls();
548
+ toggleMute();
549
+ }}
550
+ aria-label={volumeButtonLabel}
551
+ >
552
+ <Icon
553
+ name={volumeButtonIconName}
554
+ size={16}
555
+ aria-hidden
556
+ className="solara-video__control-icon"
557
+ />
558
+ </button>
559
+
560
+ <input
561
+ className="solara-video__volume"
562
+ type="range"
563
+ min={0}
564
+ max={1}
565
+ step={0.05}
566
+ value={isMuted ? 0 : volume}
567
+ onChange={(event) => {
568
+ revealControls();
569
+ setVideoVolume(Number(event.currentTarget.value));
570
+ }}
571
+ aria-label="Adjust volume"
572
+ />
573
+ </>
574
+ ) : null}
575
+
576
+ {resolvedControlsType === "control-bar" && showFullscreenControl ? (
577
+ <button
578
+ type="button"
579
+ className="solara-video__control-button solara-video__control-button--secondary solara-video__control-button--icon"
580
+ onClick={() => {
581
+ revealControls();
582
+ void toggleFullscreen();
583
+ }}
584
+ aria-label={fullscreenButtonLabel}
585
+ >
586
+ <Icon
587
+ name="view_box_inside_16"
588
+ size={16}
589
+ aria-hidden
590
+ className="solara-video__control-icon"
591
+ />
592
+ </button>
593
+ ) : null}
594
+ </div>
595
+ </div>
596
+ ) : null}
597
+ </div>
598
+ {showCaption && caption ? (
599
+ <figcaption className="solara-video__caption">{caption}</figcaption>
600
+ ) : null}
601
+ </figure>
602
+ );
603
+ }
604
+ );
605
+
606
+ Video.displayName = "Video";
@@ -0,0 +1,59 @@
1
+ import type React from "react";
2
+
3
+ export type VideoAspectRatio = "auto" | "16:9" | "4:3" | "1:1" | "21:9" | number;
4
+ export type VideoFit = "contain" | "cover" | "fill" | "none" | "scale-down";
5
+ export type VideoPreload = "none" | "metadata" | "auto";
6
+ export type VideoControlBarMode = "default" | "quick";
7
+ export type VideoControlsType = "control-bar" | "quick" | "minimal" | "none";
8
+
9
+ export type VideoSource = {
10
+ /** Source URL for a video format. */
11
+ src: string;
12
+ /** MIME type for this source, such as `video/mp4`. */
13
+ type?: string;
14
+ /** Optional media condition for responsive source selection. */
15
+ media?: string;
16
+ };
17
+
18
+ export type VideoProps = Omit<React.VideoHTMLAttributes<HTMLVideoElement>, "controls"> & {
19
+ /** Controls whether custom overlay controls are visible. */
20
+ showControls?: boolean;
21
+ /** Preferred controls presentation: full control-bar, quick/minimal icon action button, or none. */
22
+ controlsType?: VideoControlsType;
23
+ /** @deprecated Use `controlsType` instead. */
24
+ controlBarMode?: VideoControlBarMode;
25
+ /** Shows a seek bar in the default control bar mode. */
26
+ showScrubber?: boolean;
27
+ /** Shows current time and duration in the default control bar mode. */
28
+ showTimeDisplay?: boolean;
29
+ /** Shows sound toggle icon + volume controls in the default control bar mode. */
30
+ showVolumeControl?: boolean;
31
+ /** Shows fullscreen toggle icon in the default control bar mode. */
32
+ showFullscreenControl?: boolean;
33
+ /** Optional list of source definitions. If provided, they render as nested `<source>` tags. */
34
+ sources?: VideoSource[];
35
+ /** Preferred presentation ratio for the player wrapper. */
36
+ aspectRatio?: VideoAspectRatio;
37
+ /** How the video should scale inside the frame. */
38
+ fit?: VideoFit;
39
+ /** Optional caption text or node shown beneath the player. */
40
+ caption?: React.ReactNode;
41
+ /** Toggles caption visibility when caption content is provided. */
42
+ showCaption?: boolean;
43
+ /** Renders a rounded frame around the video. */
44
+ rounded?: boolean;
45
+ /** Applies a subtle elevation shadow to the frame. */
46
+ shadow?: boolean;
47
+ /** Optional class name for the outer wrapper element. */
48
+ className?: string;
49
+ /** Optional class name for the inner `<video>` element. */
50
+ videoClassName?: string;
51
+ /** Optional style object for the inner `<video>` element. */
52
+ videoStyle?: React.CSSProperties;
53
+ /** Optional class name for the custom control bar container. */
54
+ controlBarClassName?: string;
55
+ /** Enables tapping/clicking the video surface to toggle play and pause. */
56
+ tapToTogglePlayback?: boolean;
57
+ /** Delay in milliseconds before auto-hiding the default control bar after interaction ends. */
58
+ controlsAutoHideDelay?: number;
59
+ };
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { Video } from "./Video";
2
+ export type {
3
+ VideoProps,
4
+ VideoSource,
5
+ VideoAspectRatio,
6
+ VideoFit,
7
+ VideoPreload,
8
+ VideoControlBarMode,
9
+ VideoControlsType,
10
+ } from "./Video.types";