@boyernick/standard-ui-react 0.1.1-canary.9 → 0.2.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/package.json +5 -3
- package/src/accordion.tsx +5 -2
- package/src/alert-dialog.tsx +1 -1
- package/src/attachment.tsx +5 -2
- package/src/autocomplete.tsx +4 -3
- package/src/block-editor.tsx +1747 -0
- package/src/brand.tsx +2 -2
- package/src/breadcrumb.tsx +14 -7
- package/src/button.tsx +22 -11
- package/src/calendar.tsx +6 -3
- package/src/carousel.tsx +220 -53
- package/src/checkbox.tsx +3 -2
- package/src/code-block.tsx +66 -5
- package/src/collapsible.tsx +4 -2
- package/src/combobox.tsx +4 -3
- package/src/command.tsx +27 -12
- package/src/dialog.tsx +33 -10
- package/src/empty.tsx +4 -4
- package/src/field.tsx +4 -3
- package/src/filter-group.tsx +143 -0
- package/src/icons.tsx +28 -1
- package/src/illustrations.tsx +219 -141
- package/src/index.ts +238 -18
- package/src/input.tsx +8 -5
- package/src/kbd.tsx +10 -4
- package/src/lib/focus.ts +41 -0
- package/src/lib/popup.ts +1 -1
- package/src/lifeline/company-icon.tsx +71 -0
- package/src/lifeline/index.ts +42 -0
- package/src/lifeline/lifeline-data.ts +97 -0
- package/src/lifeline/lifeline-desktop.tsx +204 -0
- package/src/lifeline/lifeline-event.tsx +108 -0
- package/src/lifeline/lifeline-fireworks.tsx +302 -0
- package/src/lifeline/lifeline-hover-image.tsx +269 -0
- package/src/lifeline/lifeline-icons.tsx +39 -0
- package/src/lifeline/lifeline-intro-timing.ts +105 -0
- package/src/lifeline/lifeline-labels.tsx +24 -0
- package/src/lifeline/lifeline-layout.ts +1 -0
- package/src/lifeline/lifeline-legend.tsx +31 -0
- package/src/lifeline/lifeline-lightbox.tsx +309 -0
- package/src/lifeline/lifeline-marker.tsx +183 -0
- package/src/lifeline/lifeline-people.tsx +99 -0
- package/src/lifeline/lifeline-photos.tsx +366 -0
- package/src/lifeline/lifeline-shell.tsx +135 -0
- package/src/lifeline/lifeline-utils.ts +83 -0
- package/src/lifeline/lifeline-vertical.tsx +482 -0
- package/src/lifeline/lifeline.tsx +69 -0
- package/src/lifeline/types.ts +112 -0
- package/src/lifeline/use-lifeline-intro.ts +130 -0
- package/src/lifeline/use-lifeline-scroll.ts +1011 -0
- package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
- package/src/menubar.tsx +9 -2
- package/src/minimap.tsx +296 -0
- package/src/modal.tsx +608 -0
- package/src/navigation-menu.tsx +338 -67
- package/src/number-field.tsx +336 -61
- package/src/otp-field.tsx +4 -3
- package/src/pagination.tsx +262 -42
- package/src/password-protection.tsx +345 -0
- package/src/popover.tsx +1 -1
- package/src/progress.tsx +5 -0
- package/src/questionnaire.tsx +284 -0
- package/src/radio.tsx +4 -2
- package/src/scroll-area.tsx +4 -1
- package/src/select.tsx +5 -2
- package/src/sidebar.tsx +113 -28
- package/src/slider.tsx +66 -6
- package/src/sounds.tsx +6 -10
- package/src/spinner.tsx +105 -32
- package/src/switch.tsx +4 -1
- package/src/table.tsx +82 -15
- package/src/tabs.tsx +189 -38
- package/src/text-animate.tsx +71 -28
- package/src/textarea.tsx +6 -1
- package/src/timeline.tsx +378 -0
- package/src/toast.tsx +214 -35
- package/src/toggle.tsx +4 -2
- package/src/toolbar.tsx +12 -7
- package/src/tooltip.tsx +43 -3
- package/src/video-player.tsx +396 -127
- package/src/image-modal.tsx +0 -110
- package/src/markdown-editor.tsx +0 -302
package/src/video-player.tsx
CHANGED
|
@@ -6,11 +6,27 @@ import {
|
|
|
6
6
|
useId,
|
|
7
7
|
useRef,
|
|
8
8
|
useState,
|
|
9
|
+
useSyncExternalStore,
|
|
9
10
|
type ComponentProps,
|
|
10
11
|
} from "react"
|
|
11
|
-
import {
|
|
12
|
+
import { IconExclamationTriangle } from "@central-icons-react/round-filled-radius-2-stroke-2/IconExclamationTriangle"
|
|
13
|
+
import { IconFullScreen } from "@central-icons-react/round-filled-radius-2-stroke-2/IconFullScreen"
|
|
14
|
+
import { IconPause } from "@central-icons-react/round-filled-radius-2-stroke-2/IconPause"
|
|
15
|
+
import { IconPictureInPicture } from "@central-icons-react/round-filled-radius-2-stroke-2/IconPictureInPicture"
|
|
16
|
+
import { IconPlay } from "@central-icons-react/round-filled-radius-2-stroke-2/IconPlay"
|
|
17
|
+
import { IconVolumeFull } from "@central-icons-react/round-filled-radius-2-stroke-2/IconVolumeFull"
|
|
18
|
+
import { IconVolumeOff } from "@central-icons-react/round-filled-radius-2-stroke-2/IconVolumeOff"
|
|
12
19
|
import { cn } from "./lib/cn"
|
|
13
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Picture-in-Picture support is a static browser capability, not React state.
|
|
23
|
+
* Reading it through an external store keeps the server snapshot `false` so
|
|
24
|
+
* hydration matches, without a post-mount setState that re-renders the player.
|
|
25
|
+
*/
|
|
26
|
+
const subscribePipSupport = () => () => {}
|
|
27
|
+
const getPipSupported = () => document.pictureInPictureEnabled
|
|
28
|
+
const getPipSupportedOnServer = () => false
|
|
29
|
+
|
|
14
30
|
const formatTime = (seconds: number) => {
|
|
15
31
|
if (!Number.isFinite(seconds) || seconds < 0) return "0:00"
|
|
16
32
|
const mins = Math.floor(seconds / 60)
|
|
@@ -18,6 +34,13 @@ const formatTime = (seconds: number) => {
|
|
|
18
34
|
return `${mins}:${secs.toString().padStart(2, "0")}`
|
|
19
35
|
}
|
|
20
36
|
|
|
37
|
+
// The media surface is black in both themes, so everything sitting on it is
|
|
38
|
+
// light regardless of the theme — semantic foreground tokens would invert and
|
|
39
|
+
// disappear against the video. Buttons follow Linear’s glass chrome: circular,
|
|
40
|
+
// muted until hover, no filled chip behind the icon at rest.
|
|
41
|
+
const overlayControl =
|
|
42
|
+
"inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-white/65 outline-none transition-[background-color,color,transform] duration-[var(--duration-sm)] ease-enter hover:bg-white/12 hover:text-white active:scale-95 focus-visible:bg-white/12 focus-visible:text-white focus-visible:ring-2 focus-visible:ring-white/50 motion-reduce:transition-none motion-reduce:active:scale-100"
|
|
43
|
+
|
|
21
44
|
export type VideoPlayerProps = ComponentProps<"div"> & {
|
|
22
45
|
src: string
|
|
23
46
|
poster?: string
|
|
@@ -34,17 +57,33 @@ export const VideoPlayer = ({
|
|
|
34
57
|
"aria-label": ariaLabel,
|
|
35
58
|
...props
|
|
36
59
|
}: VideoPlayerProps) => {
|
|
60
|
+
const playerRef = useRef<HTMLDivElement>(null)
|
|
37
61
|
const videoRef = useRef<HTMLVideoElement>(null)
|
|
62
|
+
const seekPreviewRef = useRef<HTMLSpanElement>(null)
|
|
38
63
|
const seekId = useId()
|
|
39
64
|
const [playing, setPlaying] = useState(false)
|
|
40
65
|
const [muted, setMuted] = useState(false)
|
|
66
|
+
const [loading, setLoading] = useState(true)
|
|
67
|
+
const [buffering, setBuffering] = useState(false)
|
|
68
|
+
const [ended, setEnded] = useState(false)
|
|
69
|
+
const [failed, setFailed] = useState(false)
|
|
70
|
+
const [fullscreen, setFullscreen] = useState(false)
|
|
71
|
+
const pipSupported = useSyncExternalStore(
|
|
72
|
+
subscribePipSupport,
|
|
73
|
+
getPipSupported,
|
|
74
|
+
getPipSupportedOnServer,
|
|
75
|
+
)
|
|
76
|
+
const [pipActive, setPipActive] = useState(false)
|
|
41
77
|
const [current, setCurrent] = useState(0)
|
|
42
78
|
const [duration, setDuration] = useState(0)
|
|
79
|
+
const [buffered, setBuffered] = useState(0)
|
|
43
80
|
|
|
44
81
|
const handleTogglePlay = useCallback(() => {
|
|
45
82
|
const video = videoRef.current
|
|
46
83
|
if (!video) return
|
|
47
84
|
if (video.paused) {
|
|
85
|
+
if (video.ended) video.currentTime = 0
|
|
86
|
+
setEnded(false)
|
|
48
87
|
void video.play()
|
|
49
88
|
} else {
|
|
50
89
|
video.pause()
|
|
@@ -63,179 +102,409 @@ export const VideoPlayer = ({
|
|
|
63
102
|
if (!video) return
|
|
64
103
|
video.currentTime = value
|
|
65
104
|
setCurrent(value)
|
|
105
|
+
setEnded(false)
|
|
106
|
+
}, [])
|
|
107
|
+
|
|
108
|
+
const showSeekPreview = useCallback(
|
|
109
|
+
(value: number) => {
|
|
110
|
+
const preview = seekPreviewRef.current
|
|
111
|
+
if (!preview || duration <= 0) return
|
|
112
|
+
const boundedValue = Math.min(Math.max(value, 0), duration)
|
|
113
|
+
preview.textContent = formatTime(boundedValue)
|
|
114
|
+
preview.style.left = `${(boundedValue / duration) * 100}%`
|
|
115
|
+
preview.dataset.visible = ""
|
|
116
|
+
},
|
|
117
|
+
[duration],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const hideSeekPreview = useCallback(() => {
|
|
121
|
+
seekPreviewRef.current?.removeAttribute("data-visible")
|
|
66
122
|
}, [])
|
|
67
123
|
|
|
68
124
|
const handleFullscreen = useCallback(() => {
|
|
69
|
-
const
|
|
70
|
-
if (!
|
|
125
|
+
const player = playerRef.current
|
|
126
|
+
if (!player) return
|
|
71
127
|
if (document.fullscreenElement) {
|
|
72
128
|
void document.exitFullscreen()
|
|
73
129
|
return
|
|
74
130
|
}
|
|
75
|
-
void
|
|
131
|
+
void player.requestFullscreen?.()
|
|
132
|
+
}, [])
|
|
133
|
+
|
|
134
|
+
const handlePictureInPicture = useCallback(() => {
|
|
135
|
+
const video = videoRef.current
|
|
136
|
+
if (!video || !document.pictureInPictureEnabled) return
|
|
137
|
+
if (document.pictureInPictureElement) {
|
|
138
|
+
void document.exitPictureInPicture()
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
void video.requestPictureInPicture()
|
|
76
142
|
}, [])
|
|
77
143
|
|
|
78
144
|
useEffect(() => {
|
|
79
145
|
const video = videoRef.current
|
|
80
146
|
if (!video) return
|
|
81
147
|
|
|
82
|
-
const
|
|
148
|
+
const readBuffered = () => {
|
|
149
|
+
if (video.buffered.length === 0) return
|
|
150
|
+
setBuffered(video.buffered.end(video.buffered.length - 1))
|
|
151
|
+
}
|
|
152
|
+
const handleLoadStart = () => {
|
|
153
|
+
setLoading(true)
|
|
154
|
+
setBuffering(false)
|
|
155
|
+
setFailed(false)
|
|
156
|
+
}
|
|
157
|
+
const handleReady = () => {
|
|
158
|
+
setLoading(false)
|
|
159
|
+
setBuffering(false)
|
|
160
|
+
setFailed(false)
|
|
161
|
+
}
|
|
162
|
+
const handlePlay = () => {
|
|
163
|
+
setPlaying(true)
|
|
164
|
+
setEnded(false)
|
|
165
|
+
setLoading(false)
|
|
166
|
+
setBuffering(false)
|
|
167
|
+
}
|
|
83
168
|
const handlePause = () => setPlaying(false)
|
|
84
|
-
const
|
|
169
|
+
const handleWaiting = () => {
|
|
170
|
+
if (!video.paused) setBuffering(true)
|
|
171
|
+
}
|
|
172
|
+
const handleTime = () => {
|
|
173
|
+
setCurrent(video.currentTime)
|
|
174
|
+
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
|
175
|
+
setBuffering(false)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
85
178
|
const handleMeta = () => setDuration(video.duration || 0)
|
|
179
|
+
const handleVolume = () => setMuted(video.muted)
|
|
180
|
+
const handleEnded = () => {
|
|
181
|
+
setPlaying(false)
|
|
182
|
+
setBuffering(false)
|
|
183
|
+
setEnded(true)
|
|
184
|
+
}
|
|
185
|
+
const handleError = () => {
|
|
186
|
+
setPlaying(false)
|
|
187
|
+
setLoading(false)
|
|
188
|
+
setBuffering(false)
|
|
189
|
+
setFailed(true)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A cached video can be ready before this effect runs, in which case
|
|
193
|
+
// `loadedmetadata` has already fired and the readout would sit at 0:00
|
|
194
|
+
// forever. Seed from the element, then keep up with the events.
|
|
195
|
+
handleMeta()
|
|
196
|
+
handleTime()
|
|
197
|
+
handleVolume()
|
|
198
|
+
readBuffered()
|
|
199
|
+
setPlaying(!video.paused)
|
|
200
|
+
setLoading(video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA)
|
|
201
|
+
setFailed(Boolean(video.error))
|
|
86
202
|
|
|
203
|
+
video.addEventListener("loadstart", handleLoadStart)
|
|
204
|
+
video.addEventListener("loadeddata", handleReady)
|
|
205
|
+
video.addEventListener("canplay", handleReady)
|
|
87
206
|
video.addEventListener("play", handlePlay)
|
|
207
|
+
video.addEventListener("playing", handlePlay)
|
|
88
208
|
video.addEventListener("pause", handlePause)
|
|
209
|
+
video.addEventListener("waiting", handleWaiting)
|
|
210
|
+
video.addEventListener("stalled", handleWaiting)
|
|
89
211
|
video.addEventListener("timeupdate", handleTime)
|
|
90
212
|
video.addEventListener("loadedmetadata", handleMeta)
|
|
213
|
+
video.addEventListener("durationchange", handleMeta)
|
|
214
|
+
video.addEventListener("volumechange", handleVolume)
|
|
215
|
+
video.addEventListener("progress", readBuffered)
|
|
216
|
+
video.addEventListener("ended", handleEnded)
|
|
217
|
+
video.addEventListener("error", handleError)
|
|
91
218
|
|
|
92
219
|
return () => {
|
|
220
|
+
video.removeEventListener("loadstart", handleLoadStart)
|
|
221
|
+
video.removeEventListener("loadeddata", handleReady)
|
|
222
|
+
video.removeEventListener("canplay", handleReady)
|
|
93
223
|
video.removeEventListener("play", handlePlay)
|
|
224
|
+
video.removeEventListener("playing", handlePlay)
|
|
94
225
|
video.removeEventListener("pause", handlePause)
|
|
226
|
+
video.removeEventListener("waiting", handleWaiting)
|
|
227
|
+
video.removeEventListener("stalled", handleWaiting)
|
|
95
228
|
video.removeEventListener("timeupdate", handleTime)
|
|
96
229
|
video.removeEventListener("loadedmetadata", handleMeta)
|
|
230
|
+
video.removeEventListener("durationchange", handleMeta)
|
|
231
|
+
video.removeEventListener("volumechange", handleVolume)
|
|
232
|
+
video.removeEventListener("progress", readBuffered)
|
|
233
|
+
video.removeEventListener("ended", handleEnded)
|
|
234
|
+
video.removeEventListener("error", handleError)
|
|
235
|
+
}
|
|
236
|
+
}, [])
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
const handleFullscreenChange = () => {
|
|
240
|
+
setFullscreen(document.fullscreenElement === playerRef.current)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
handleFullscreenChange()
|
|
244
|
+
document.addEventListener("fullscreenchange", handleFullscreenChange)
|
|
245
|
+
return () => {
|
|
246
|
+
document.removeEventListener("fullscreenchange", handleFullscreenChange)
|
|
247
|
+
}
|
|
248
|
+
}, [])
|
|
249
|
+
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
const video = videoRef.current
|
|
252
|
+
if (!video) return
|
|
253
|
+
|
|
254
|
+
const handleEnterPip = () => setPipActive(true)
|
|
255
|
+
const handleLeavePip = () => setPipActive(false)
|
|
256
|
+
video.addEventListener("enterpictureinpicture", handleEnterPip)
|
|
257
|
+
video.addEventListener("leavepictureinpicture", handleLeavePip)
|
|
258
|
+
return () => {
|
|
259
|
+
video.removeEventListener("enterpictureinpicture", handleEnterPip)
|
|
260
|
+
video.removeEventListener("leavepictureinpicture", handleLeavePip)
|
|
97
261
|
}
|
|
98
262
|
}, [])
|
|
99
263
|
|
|
100
|
-
const
|
|
264
|
+
const played = duration > 0 ? (current / duration) * 100 : 0
|
|
265
|
+
const loaded = duration > 0 ? (buffered / duration) * 100 : 0
|
|
266
|
+
const remaining = Math.max(duration - current, 0)
|
|
101
267
|
|
|
102
268
|
return (
|
|
103
269
|
<div
|
|
270
|
+
ref={playerRef}
|
|
104
271
|
data-slot="video-player"
|
|
272
|
+
data-playing={playing || undefined}
|
|
273
|
+
data-loading={loading || undefined}
|
|
274
|
+
data-buffering={buffering || undefined}
|
|
275
|
+
data-ended={ended || undefined}
|
|
276
|
+
data-error={failed || undefined}
|
|
277
|
+
data-fullscreen={fullscreen || undefined}
|
|
278
|
+
// `group` drives the chrome: it hides while playing and comes back on
|
|
279
|
+
// hover or when anything inside takes focus.
|
|
105
280
|
className={cn(
|
|
106
|
-
"overflow-hidden rounded-xl
|
|
281
|
+
"group relative isolate overflow-hidden rounded-xl bg-black shadow-lg ring-1 ring-border-primary data-[fullscreen]:h-screen data-[fullscreen]:w-screen data-[fullscreen]:rounded-none data-[fullscreen]:ring-0 fullscreen:h-screen fullscreen:w-screen fullscreen:rounded-none fullscreen:ring-0",
|
|
107
282
|
className,
|
|
108
283
|
)}
|
|
284
|
+
aria-busy={loading || buffering || undefined}
|
|
109
285
|
{...props}
|
|
110
286
|
>
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
287
|
+
<video
|
|
288
|
+
ref={videoRef}
|
|
289
|
+
src={src}
|
|
290
|
+
poster={poster}
|
|
291
|
+
preload="auto"
|
|
292
|
+
playsInline
|
|
293
|
+
className="aspect-video w-full cursor-pointer object-cover group-data-[fullscreen]:h-full group-data-[fullscreen]:w-full group-data-[fullscreen]:aspect-auto group-data-[fullscreen]:object-cover"
|
|
294
|
+
aria-label={ariaLabel ?? title ?? "Video"}
|
|
295
|
+
onClick={handleTogglePlay}
|
|
296
|
+
/>
|
|
297
|
+
|
|
298
|
+
{/* These centred states stay inert so the video remains the click target. */}
|
|
299
|
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
300
|
+
{failed ? (
|
|
301
|
+
<div
|
|
302
|
+
role="alert"
|
|
303
|
+
className="mx-6 flex max-w-sm flex-col items-center gap-2 rounded-xl border border-white/15 bg-black/65 px-5 py-4 text-center text-white shadow-lg backdrop-blur-md"
|
|
304
|
+
>
|
|
305
|
+
<IconExclamationTriangle
|
|
306
|
+
size={22}
|
|
307
|
+
mode="raw"
|
|
308
|
+
className="size-5.5"
|
|
309
|
+
aria-hidden
|
|
310
|
+
/>
|
|
311
|
+
<span className="text-sm-strong">Video unavailable</span>
|
|
312
|
+
<span className="text-xs text-white/70">
|
|
313
|
+
Check the source and try again.
|
|
314
|
+
</span>
|
|
315
|
+
</div>
|
|
316
|
+
) : loading || buffering ? (
|
|
317
|
+
<div
|
|
318
|
+
role="status"
|
|
319
|
+
className="flex size-14 items-center justify-center rounded-full border border-white/15 bg-black/45 text-white shadow-lg backdrop-blur-md"
|
|
320
|
+
>
|
|
321
|
+
<span className="size-6 animate-spin rounded-full border-2 border-white/30 border-t-white motion-reduce:animate-none" />
|
|
322
|
+
<span className="sr-only">
|
|
323
|
+
{buffering ? "Buffering video" : "Loading video"}
|
|
125
324
|
</span>
|
|
126
325
|
</div>
|
|
326
|
+
) : !playing ? (
|
|
327
|
+
<div
|
|
328
|
+
className={cn(
|
|
329
|
+
"flex items-center justify-center rounded-full border border-white/20 bg-black/45 text-white shadow-lg backdrop-blur-md",
|
|
330
|
+
ended ? "h-8 gap-1 px-2.5" : "size-11",
|
|
331
|
+
)}
|
|
332
|
+
>
|
|
333
|
+
<IconPlay
|
|
334
|
+
size={ended ? 12 : 18}
|
|
335
|
+
mode="raw"
|
|
336
|
+
className={cn(ended ? "size-3" : "ml-0.5 size-4.5")}
|
|
337
|
+
aria-hidden
|
|
338
|
+
/>
|
|
339
|
+
{ended ? <span className="text-xs-strong">Replay</span> : null}
|
|
340
|
+
</div>
|
|
127
341
|
) : null}
|
|
128
342
|
</div>
|
|
129
343
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
344
|
+
{!failed && !loading ? (
|
|
345
|
+
<div
|
|
346
|
+
className={cn(
|
|
347
|
+
"absolute inset-x-0 bottom-0 flex translate-y-0 flex-col bg-gradient-to-t from-black/80 via-black/40 to-transparent px-2.5 pt-14 pb-2.5 transition-[opacity,transform] duration-[var(--duration-md)] ease-enter group-data-[fullscreen]:px-5 group-data-[fullscreen]:pb-5 motion-reduce:transition-none",
|
|
348
|
+
"group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100",
|
|
349
|
+
// Nothing hovers on a touch screen, so the chrome would never come
|
|
350
|
+
// back once playback started. Keep it visible there instead.
|
|
351
|
+
"[@media(hover:none)]:translate-y-0 [@media(hover:none)]:opacity-100",
|
|
352
|
+
playing ? "translate-y-2 opacity-0" : "opacity-100",
|
|
353
|
+
)}
|
|
354
|
+
>
|
|
355
|
+
{title ? (
|
|
356
|
+
<p className="text-sm-strong mb-2 truncate px-1 text-white">{title}</p>
|
|
357
|
+
) : null}
|
|
134
358
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
359
|
+
<div className="flex items-center gap-1.5">
|
|
360
|
+
<div className="flex shrink-0 items-center">
|
|
361
|
+
<button
|
|
362
|
+
type="button"
|
|
363
|
+
className={overlayControl}
|
|
364
|
+
aria-label={ended ? "Replay" : playing ? "Pause" : "Play"}
|
|
365
|
+
onClick={handleTogglePlay}
|
|
366
|
+
>
|
|
367
|
+
{playing ? (
|
|
368
|
+
<IconPause size={14} mode="raw" className="size-3.5" aria-hidden />
|
|
369
|
+
) : (
|
|
370
|
+
<IconPlay size={14} mode="raw" className="size-3.5" aria-hidden />
|
|
371
|
+
)}
|
|
372
|
+
</button>
|
|
373
|
+
<button
|
|
374
|
+
type="button"
|
|
375
|
+
className={overlayControl}
|
|
376
|
+
aria-label={muted ? "Unmute" : "Mute"}
|
|
377
|
+
onClick={handleToggleMute}
|
|
378
|
+
>
|
|
379
|
+
{muted ? (
|
|
380
|
+
<IconVolumeOff
|
|
381
|
+
size={14}
|
|
382
|
+
mode="raw"
|
|
383
|
+
className="size-3.5"
|
|
384
|
+
aria-hidden
|
|
385
|
+
/>
|
|
386
|
+
) : (
|
|
387
|
+
<IconVolumeFull
|
|
388
|
+
size={14}
|
|
389
|
+
mode="raw"
|
|
390
|
+
className="size-3.5"
|
|
391
|
+
aria-hidden
|
|
392
|
+
/>
|
|
393
|
+
)}
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
154
396
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
397
|
+
<span className="text-xs min-w-9 shrink-0 tabular-nums text-white/85">
|
|
398
|
+
{formatTime(current)}
|
|
399
|
+
</span>
|
|
400
|
+
|
|
401
|
+
<label className="sr-only" htmlFor={seekId}>
|
|
402
|
+
Seek
|
|
403
|
+
</label>
|
|
404
|
+
<div className="group/seek relative flex h-5 min-w-0 flex-1 items-center">
|
|
405
|
+
<span
|
|
406
|
+
ref={seekPreviewRef}
|
|
407
|
+
className="text-xs pointer-events-none absolute -top-8 z-10 -translate-x-1/2 rounded-md bg-black/80 px-1.5 py-1 text-white opacity-0 shadow-sm transition-opacity duration-[var(--duration-sm)] data-[visible]:opacity-100 motion-reduce:transition-none"
|
|
408
|
+
/>
|
|
409
|
+
{/* Track, buffered fill and played fill sit under the input, which
|
|
410
|
+
stays transparent so its thumb is the only thing it paints. */}
|
|
411
|
+
<span
|
|
412
|
+
aria-hidden
|
|
413
|
+
className="pointer-events-none absolute inset-x-0 h-0.75 rounded-full bg-white/25 transition-[height] duration-[var(--duration-sm)] group-hover/seek:h-1.5 group-focus-within/seek:h-1.5 motion-reduce:transition-none"
|
|
414
|
+
/>
|
|
415
|
+
<span
|
|
416
|
+
aria-hidden
|
|
417
|
+
className="pointer-events-none absolute left-0 h-0.75 rounded-full bg-white/35 transition-[height] duration-[var(--duration-sm)] group-hover/seek:h-1.5 group-focus-within/seek:h-1.5 motion-reduce:transition-none"
|
|
418
|
+
style={{ width: `${loaded}%` }}
|
|
419
|
+
/>
|
|
420
|
+
<span
|
|
421
|
+
aria-hidden
|
|
422
|
+
className="pointer-events-none absolute left-0 h-0.75 rounded-full bg-white transition-[height] duration-[var(--duration-sm)] group-hover/seek:h-1.5 group-focus-within/seek:h-1.5 motion-reduce:transition-none"
|
|
423
|
+
style={{ width: `${played}%` }}
|
|
424
|
+
/>
|
|
425
|
+
<input
|
|
426
|
+
id={seekId}
|
|
427
|
+
type="range"
|
|
428
|
+
min={0}
|
|
429
|
+
max={duration || 0}
|
|
430
|
+
step={0.01}
|
|
431
|
+
value={current}
|
|
432
|
+
onChange={(event) => {
|
|
433
|
+
const value = Number(event.target.value)
|
|
434
|
+
handleSeek(value)
|
|
435
|
+
showSeekPreview(value)
|
|
436
|
+
}}
|
|
437
|
+
onPointerMove={(event) => {
|
|
438
|
+
if (duration <= 0) return
|
|
439
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
440
|
+
const ratio = Math.min(
|
|
441
|
+
Math.max((event.clientX - rect.left) / rect.width, 0),
|
|
442
|
+
1,
|
|
443
|
+
)
|
|
444
|
+
showSeekPreview(ratio * duration)
|
|
445
|
+
}}
|
|
446
|
+
onPointerLeave={(event) => {
|
|
447
|
+
if (document.activeElement !== event.currentTarget) {
|
|
448
|
+
hideSeekPreview()
|
|
449
|
+
}
|
|
450
|
+
}}
|
|
451
|
+
onFocus={() => showSeekPreview(current)}
|
|
452
|
+
onBlur={hideSeekPreview}
|
|
453
|
+
aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
|
|
454
|
+
className={cn(
|
|
455
|
+
"relative m-0 h-5 w-full cursor-pointer appearance-none bg-transparent outline-none",
|
|
456
|
+
// Both vendor thumbs, or Firefox falls back to a default one.
|
|
457
|
+
"[&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:scale-75 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:opacity-0 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-[opacity,transform] [&:hover::-webkit-slider-thumb]:scale-100 [&:hover::-webkit-slider-thumb]:opacity-100",
|
|
458
|
+
"[&::-moz-range-thumb]:size-3.5 [&::-moz-range-thumb]:scale-75 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:opacity-0 [&::-moz-range-thumb]:transition-[opacity,transform] [&:hover::-moz-range-thumb]:scale-100 [&:hover::-moz-range-thumb]:opacity-100",
|
|
459
|
+
"focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-white/70",
|
|
460
|
+
"focus-visible:[&::-webkit-slider-thumb]:scale-100 focus-visible:[&::-webkit-slider-thumb]:opacity-100 focus-visible:[&::-moz-range-thumb]:scale-100 focus-visible:[&::-moz-range-thumb]:opacity-100",
|
|
461
|
+
)}
|
|
462
|
+
/>
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
<span className="text-xs min-w-10 shrink-0 text-right tabular-nums text-white/85">
|
|
466
|
+
-{formatTime(remaining)}
|
|
467
|
+
</span>
|
|
468
|
+
|
|
469
|
+
<div className="flex shrink-0 items-center">
|
|
470
|
+
{pipSupported ? (
|
|
471
|
+
<button
|
|
472
|
+
type="button"
|
|
473
|
+
className={overlayControl}
|
|
474
|
+
aria-label={
|
|
475
|
+
pipActive
|
|
476
|
+
? "Exit picture in picture"
|
|
477
|
+
: "Picture in picture"
|
|
478
|
+
}
|
|
479
|
+
aria-pressed={pipActive}
|
|
480
|
+
onClick={handlePictureInPicture}
|
|
481
|
+
>
|
|
482
|
+
<IconPictureInPicture
|
|
483
|
+
size={14}
|
|
484
|
+
mode="raw"
|
|
485
|
+
className="size-3.5"
|
|
486
|
+
aria-hidden
|
|
487
|
+
/>
|
|
488
|
+
</button>
|
|
489
|
+
) : null}
|
|
490
|
+
<button
|
|
491
|
+
type="button"
|
|
492
|
+
className={overlayControl}
|
|
493
|
+
aria-label={fullscreen ? "Exit fullscreen" : "Fullscreen"}
|
|
494
|
+
aria-pressed={fullscreen}
|
|
495
|
+
onClick={handleFullscreen}
|
|
496
|
+
>
|
|
497
|
+
<IconFullScreen
|
|
498
|
+
size={14}
|
|
499
|
+
mode="raw"
|
|
500
|
+
className="size-3.5"
|
|
501
|
+
aria-hidden
|
|
502
|
+
/>
|
|
503
|
+
</button>
|
|
504
|
+
</div>
|
|
193
505
|
</div>
|
|
194
506
|
</div>
|
|
195
|
-
|
|
507
|
+
) : null}
|
|
196
508
|
</div>
|
|
197
509
|
)
|
|
198
510
|
}
|
|
199
|
-
|
|
200
|
-
const PlayIcon = () => (
|
|
201
|
-
<svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
|
|
202
|
-
<path d="M9 7.5v9l8-4.5-8-4.5Z" fill="currentColor" />
|
|
203
|
-
</svg>
|
|
204
|
-
)
|
|
205
|
-
|
|
206
|
-
const PauseIcon = () => (
|
|
207
|
-
<svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
|
|
208
|
-
<path d="M8 7h3v10H8V7Zm5 0h3v10h-3V7Z" fill="currentColor" />
|
|
209
|
-
</svg>
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
const VolumeIcon = () => (
|
|
213
|
-
<svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
|
|
214
|
-
<path
|
|
215
|
-
d="M4 10v4h3l4 3V7L7 10H4Zm11.5 2a2.5 2.5 0 0 0-1.5-2.3v4.6A2.5 2.5 0 0 0 15.5 12Zm0-5.5v1.6a4 4 0 0 1 0 7.8v1.6a5.5 5.5 0 0 0 0-11Z"
|
|
216
|
-
fill="currentColor"
|
|
217
|
-
/>
|
|
218
|
-
</svg>
|
|
219
|
-
)
|
|
220
|
-
|
|
221
|
-
const MuteIcon = () => (
|
|
222
|
-
<svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
|
|
223
|
-
<path
|
|
224
|
-
d="M4 10v4h3l4 3V7L7 10H4Zm11.2-.8 1.4-1.4 1.4 1.4 1.4-1.4 1.4 1.4-1.4 1.4 1.4 1.4-1.4 1.4-1.4-1.4-1.4 1.4-1.4-1.4 1.4-1.4-1.4-1.4Z"
|
|
225
|
-
fill="currentColor"
|
|
226
|
-
/>
|
|
227
|
-
</svg>
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
const FullscreenIcon = () => (
|
|
231
|
-
<svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
|
|
232
|
-
<path
|
|
233
|
-
d="M7 9V7h3v2H7Zm7 0V7h3v2h-3ZM7 17v-2h3v2H7Zm7 0v-2h3v2h-3Z"
|
|
234
|
-
fill="currentColor"
|
|
235
|
-
/>
|
|
236
|
-
<path
|
|
237
|
-
d="M5 5h5v2H7v3H5V5Zm9 0h5v5h-2V7h-3V5ZM5 14h2v3h3v2H5v-5Zm12 3h-3v2h5v-5h-2v3Z"
|
|
238
|
-
fill="currentColor"
|
|
239
|
-
/>
|
|
240
|
-
</svg>
|
|
241
|
-
)
|