@varialkit/video 0.1.1
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 +59 -0
- package/examples.tsx +169 -0
- package/package.json +26 -0
- package/src/Video.scss +317 -0
- package/src/Video.tsx +606 -0
- package/src/Video.types.ts +59 -0
- package/src/index.ts +10 -0
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.1",
|
|
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.1"
|
|
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
|
+
};
|