@boyernick/standard-ui-react 0.1.1-canary.2 → 0.1.1-canary.22
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 +1 -1
- package/src/alert-dialog.tsx +1 -1
- package/src/autocomplete.tsx +12 -11
- package/src/badge.tsx +73 -9
- package/src/brand.tsx +2 -2
- package/src/breadcrumb.tsx +38 -20
- package/src/button.tsx +2 -2
- package/src/calendar.tsx +22 -11
- package/src/card.tsx +57 -14
- package/src/carousel.tsx +163 -6
- package/src/checkbox.tsx +35 -13
- package/src/collapsible.tsx +4 -2
- package/src/combobox.tsx +13 -12
- package/src/command.tsx +38 -14
- package/src/context-menu.tsx +6 -3
- package/src/date-picker.tsx +174 -0
- package/src/dialog.tsx +33 -10
- package/src/drawer.tsx +1 -1
- package/src/empty.tsx +4 -4
- package/src/field.tsx +1 -1
- package/src/filter-group.tsx +139 -0
- package/src/icons.tsx +39 -0
- package/src/illustrations.tsx +219 -141
- package/src/image-modal.tsx +440 -49
- package/src/index.ts +188 -0
- package/src/input.tsx +1 -1
- package/src/kbd.tsx +49 -0
- package/src/lib/motion.ts +128 -18
- package/src/lib/popup.ts +45 -0
- package/src/menu.tsx +7 -4
- package/src/menubar.tsx +5 -0
- package/src/meter.tsx +1 -1
- package/src/minimap.tsx +267 -0
- package/src/navigation-menu.tsx +336 -67
- package/src/number-field.tsx +327 -61
- package/src/pagination.tsx +258 -42
- package/src/popover.tsx +3 -1
- package/src/preview-card.tsx +3 -1
- package/src/progress.tsx +6 -1
- package/src/questionnaire.tsx +279 -0
- package/src/scroll-area.tsx +1 -1
- package/src/select.tsx +8 -6
- package/src/sidebar.tsx +109 -28
- package/src/slider.tsx +64 -6
- package/src/sounds.tsx +6 -10
- package/src/spinner.tsx +105 -32
- package/src/switch.tsx +2 -2
- package/src/table.tsx +82 -15
- package/src/tabs.tsx +184 -38
- package/src/text-animate.tsx +71 -28
- package/src/textarea.tsx +1 -1
- package/src/timeline.tsx +321 -0
- package/src/toast.tsx +156 -23
- package/src/tooltip.tsx +43 -3
- package/src/video-player.tsx +296 -120
package/src/video-player.tsx
CHANGED
|
@@ -8,7 +8,12 @@ import {
|
|
|
8
8
|
useState,
|
|
9
9
|
type ComponentProps,
|
|
10
10
|
} from "react"
|
|
11
|
-
import {
|
|
11
|
+
import { IconExclamationTriangle } from "@central-icons-react/round-filled-radius-2-stroke-2/IconExclamationTriangle"
|
|
12
|
+
import { IconFullScreen } from "@central-icons-react/round-filled-radius-2-stroke-2/IconFullScreen"
|
|
13
|
+
import { IconPause } from "@central-icons-react/round-filled-radius-2-stroke-2/IconPause"
|
|
14
|
+
import { IconPlay } from "@central-icons-react/round-filled-radius-2-stroke-2/IconPlay"
|
|
15
|
+
import { IconVolumeFull } from "@central-icons-react/round-filled-radius-2-stroke-2/IconVolumeFull"
|
|
16
|
+
import { IconVolumeOff } from "@central-icons-react/round-filled-radius-2-stroke-2/IconVolumeOff"
|
|
12
17
|
import { cn } from "./lib/cn"
|
|
13
18
|
|
|
14
19
|
const formatTime = (seconds: number) => {
|
|
@@ -18,6 +23,13 @@ const formatTime = (seconds: number) => {
|
|
|
18
23
|
return `${mins}:${secs.toString().padStart(2, "0")}`
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
// The media surface is black in both themes, so everything sitting on it is
|
|
27
|
+
// light regardless of the theme — semantic foreground tokens would invert and
|
|
28
|
+
// disappear against the video. This is the same reasoning as the dialog
|
|
29
|
+
// backdrops, and the opposite of a keycap, which sits on a themed surface.
|
|
30
|
+
const overlayControl =
|
|
31
|
+
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-white outline-none transition-[background-color,transform] duration-[var(--duration-sm)] ease-enter hover:bg-white/15 active:scale-95 focus-visible:bg-white/15 focus-visible:ring-2 focus-visible:ring-white/70 motion-reduce:transition-none motion-reduce:active:scale-100"
|
|
32
|
+
|
|
21
33
|
export type VideoPlayerProps = ComponentProps<"div"> & {
|
|
22
34
|
src: string
|
|
23
35
|
poster?: string
|
|
@@ -34,17 +46,27 @@ export const VideoPlayer = ({
|
|
|
34
46
|
"aria-label": ariaLabel,
|
|
35
47
|
...props
|
|
36
48
|
}: VideoPlayerProps) => {
|
|
49
|
+
const playerRef = useRef<HTMLDivElement>(null)
|
|
37
50
|
const videoRef = useRef<HTMLVideoElement>(null)
|
|
51
|
+
const seekPreviewRef = useRef<HTMLSpanElement>(null)
|
|
38
52
|
const seekId = useId()
|
|
39
53
|
const [playing, setPlaying] = useState(false)
|
|
40
54
|
const [muted, setMuted] = useState(false)
|
|
55
|
+
const [loading, setLoading] = useState(true)
|
|
56
|
+
const [buffering, setBuffering] = useState(false)
|
|
57
|
+
const [ended, setEnded] = useState(false)
|
|
58
|
+
const [failed, setFailed] = useState(false)
|
|
59
|
+
const [fullscreen, setFullscreen] = useState(false)
|
|
41
60
|
const [current, setCurrent] = useState(0)
|
|
42
61
|
const [duration, setDuration] = useState(0)
|
|
62
|
+
const [buffered, setBuffered] = useState(0)
|
|
43
63
|
|
|
44
64
|
const handleTogglePlay = useCallback(() => {
|
|
45
65
|
const video = videoRef.current
|
|
46
66
|
if (!video) return
|
|
47
67
|
if (video.paused) {
|
|
68
|
+
if (video.ended) video.currentTime = 0
|
|
69
|
+
setEnded(false)
|
|
48
70
|
void video.play()
|
|
49
71
|
} else {
|
|
50
72
|
video.pause()
|
|
@@ -63,179 +85,333 @@ export const VideoPlayer = ({
|
|
|
63
85
|
if (!video) return
|
|
64
86
|
video.currentTime = value
|
|
65
87
|
setCurrent(value)
|
|
88
|
+
setEnded(false)
|
|
89
|
+
}, [])
|
|
90
|
+
|
|
91
|
+
const showSeekPreview = useCallback(
|
|
92
|
+
(value: number) => {
|
|
93
|
+
const preview = seekPreviewRef.current
|
|
94
|
+
if (!preview || duration <= 0) return
|
|
95
|
+
const boundedValue = Math.min(Math.max(value, 0), duration)
|
|
96
|
+
preview.textContent = formatTime(boundedValue)
|
|
97
|
+
preview.style.left = `${(boundedValue / duration) * 100}%`
|
|
98
|
+
preview.dataset.visible = ""
|
|
99
|
+
},
|
|
100
|
+
[duration],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const hideSeekPreview = useCallback(() => {
|
|
104
|
+
seekPreviewRef.current?.removeAttribute("data-visible")
|
|
66
105
|
}, [])
|
|
67
106
|
|
|
68
107
|
const handleFullscreen = useCallback(() => {
|
|
69
|
-
const
|
|
70
|
-
if (!
|
|
108
|
+
const player = playerRef.current
|
|
109
|
+
if (!player) return
|
|
71
110
|
if (document.fullscreenElement) {
|
|
72
111
|
void document.exitFullscreen()
|
|
73
112
|
return
|
|
74
113
|
}
|
|
75
|
-
void
|
|
114
|
+
void player.requestFullscreen?.()
|
|
76
115
|
}, [])
|
|
77
116
|
|
|
78
117
|
useEffect(() => {
|
|
79
118
|
const video = videoRef.current
|
|
80
119
|
if (!video) return
|
|
81
120
|
|
|
82
|
-
const
|
|
121
|
+
const readBuffered = () => {
|
|
122
|
+
if (video.buffered.length === 0) return
|
|
123
|
+
setBuffered(video.buffered.end(video.buffered.length - 1))
|
|
124
|
+
}
|
|
125
|
+
const handleLoadStart = () => {
|
|
126
|
+
setLoading(true)
|
|
127
|
+
setBuffering(false)
|
|
128
|
+
setFailed(false)
|
|
129
|
+
}
|
|
130
|
+
const handleReady = () => {
|
|
131
|
+
setLoading(false)
|
|
132
|
+
setBuffering(false)
|
|
133
|
+
setFailed(false)
|
|
134
|
+
}
|
|
135
|
+
const handlePlay = () => {
|
|
136
|
+
setPlaying(true)
|
|
137
|
+
setEnded(false)
|
|
138
|
+
setLoading(false)
|
|
139
|
+
setBuffering(false)
|
|
140
|
+
}
|
|
83
141
|
const handlePause = () => setPlaying(false)
|
|
84
|
-
const
|
|
142
|
+
const handleWaiting = () => {
|
|
143
|
+
if (!video.paused) setBuffering(true)
|
|
144
|
+
}
|
|
145
|
+
const handleTime = () => {
|
|
146
|
+
setCurrent(video.currentTime)
|
|
147
|
+
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
|
148
|
+
setBuffering(false)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
85
151
|
const handleMeta = () => setDuration(video.duration || 0)
|
|
152
|
+
const handleVolume = () => setMuted(video.muted)
|
|
153
|
+
const handleEnded = () => {
|
|
154
|
+
setPlaying(false)
|
|
155
|
+
setBuffering(false)
|
|
156
|
+
setEnded(true)
|
|
157
|
+
}
|
|
158
|
+
const handleError = () => {
|
|
159
|
+
setPlaying(false)
|
|
160
|
+
setLoading(false)
|
|
161
|
+
setBuffering(false)
|
|
162
|
+
setFailed(true)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A cached video can be ready before this effect runs, in which case
|
|
166
|
+
// `loadedmetadata` has already fired and the readout would sit at 0:00
|
|
167
|
+
// forever. Seed from the element, then keep up with the events.
|
|
168
|
+
handleMeta()
|
|
169
|
+
handleTime()
|
|
170
|
+
handleVolume()
|
|
171
|
+
readBuffered()
|
|
172
|
+
setPlaying(!video.paused)
|
|
173
|
+
setLoading(video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA)
|
|
174
|
+
setFailed(Boolean(video.error))
|
|
86
175
|
|
|
176
|
+
video.addEventListener("loadstart", handleLoadStart)
|
|
177
|
+
video.addEventListener("loadeddata", handleReady)
|
|
178
|
+
video.addEventListener("canplay", handleReady)
|
|
87
179
|
video.addEventListener("play", handlePlay)
|
|
180
|
+
video.addEventListener("playing", handlePlay)
|
|
88
181
|
video.addEventListener("pause", handlePause)
|
|
182
|
+
video.addEventListener("waiting", handleWaiting)
|
|
183
|
+
video.addEventListener("stalled", handleWaiting)
|
|
89
184
|
video.addEventListener("timeupdate", handleTime)
|
|
90
185
|
video.addEventListener("loadedmetadata", handleMeta)
|
|
186
|
+
video.addEventListener("durationchange", handleMeta)
|
|
187
|
+
video.addEventListener("volumechange", handleVolume)
|
|
188
|
+
video.addEventListener("progress", readBuffered)
|
|
189
|
+
video.addEventListener("ended", handleEnded)
|
|
190
|
+
video.addEventListener("error", handleError)
|
|
91
191
|
|
|
92
192
|
return () => {
|
|
193
|
+
video.removeEventListener("loadstart", handleLoadStart)
|
|
194
|
+
video.removeEventListener("loadeddata", handleReady)
|
|
195
|
+
video.removeEventListener("canplay", handleReady)
|
|
93
196
|
video.removeEventListener("play", handlePlay)
|
|
197
|
+
video.removeEventListener("playing", handlePlay)
|
|
94
198
|
video.removeEventListener("pause", handlePause)
|
|
199
|
+
video.removeEventListener("waiting", handleWaiting)
|
|
200
|
+
video.removeEventListener("stalled", handleWaiting)
|
|
95
201
|
video.removeEventListener("timeupdate", handleTime)
|
|
96
202
|
video.removeEventListener("loadedmetadata", handleMeta)
|
|
203
|
+
video.removeEventListener("durationchange", handleMeta)
|
|
204
|
+
video.removeEventListener("volumechange", handleVolume)
|
|
205
|
+
video.removeEventListener("progress", readBuffered)
|
|
206
|
+
video.removeEventListener("ended", handleEnded)
|
|
207
|
+
video.removeEventListener("error", handleError)
|
|
208
|
+
}
|
|
209
|
+
}, [])
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
const handleFullscreenChange = () => {
|
|
213
|
+
setFullscreen(document.fullscreenElement === playerRef.current)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
handleFullscreenChange()
|
|
217
|
+
document.addEventListener("fullscreenchange", handleFullscreenChange)
|
|
218
|
+
return () => {
|
|
219
|
+
document.removeEventListener("fullscreenchange", handleFullscreenChange)
|
|
97
220
|
}
|
|
98
221
|
}, [])
|
|
99
222
|
|
|
100
|
-
const
|
|
223
|
+
const played = duration > 0 ? (current / duration) * 100 : 0
|
|
224
|
+
const loaded = duration > 0 ? (buffered / duration) * 100 : 0
|
|
101
225
|
|
|
102
226
|
return (
|
|
103
227
|
<div
|
|
228
|
+
ref={playerRef}
|
|
104
229
|
data-slot="video-player"
|
|
230
|
+
data-playing={playing || undefined}
|
|
231
|
+
data-loading={loading || undefined}
|
|
232
|
+
data-buffering={buffering || undefined}
|
|
233
|
+
data-ended={ended || undefined}
|
|
234
|
+
data-error={failed || undefined}
|
|
235
|
+
data-fullscreen={fullscreen || undefined}
|
|
236
|
+
// `group` drives the chrome: it hides while playing and comes back on
|
|
237
|
+
// hover or when anything inside takes focus.
|
|
105
238
|
className={cn(
|
|
106
|
-
"overflow-hidden rounded-xl
|
|
239
|
+
"group relative isolate overflow-hidden rounded-xl bg-black shadow-md 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
240
|
className,
|
|
108
241
|
)}
|
|
242
|
+
aria-busy={loading || buffering || undefined}
|
|
109
243
|
{...props}
|
|
110
244
|
>
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
245
|
+
<video
|
|
246
|
+
ref={videoRef}
|
|
247
|
+
src={src}
|
|
248
|
+
poster={poster}
|
|
249
|
+
preload="auto"
|
|
250
|
+
playsInline
|
|
251
|
+
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"
|
|
252
|
+
aria-label={ariaLabel ?? title ?? "Video"}
|
|
253
|
+
onClick={handleTogglePlay}
|
|
254
|
+
/>
|
|
255
|
+
|
|
256
|
+
{/* These centred states stay inert so the video remains the click target. */}
|
|
257
|
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
258
|
+
{failed ? (
|
|
259
|
+
<div
|
|
260
|
+
role="alert"
|
|
261
|
+
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"
|
|
262
|
+
>
|
|
263
|
+
<IconExclamationTriangle size={22} className="size-5.5" aria-hidden />
|
|
264
|
+
<span className="text-sm-strong">Video unavailable</span>
|
|
265
|
+
<span className="text-xs text-white/70">
|
|
266
|
+
Check the source and try again.
|
|
125
267
|
</span>
|
|
126
268
|
</div>
|
|
269
|
+
) : loading || buffering ? (
|
|
270
|
+
<div
|
|
271
|
+
role="status"
|
|
272
|
+
className="flex size-14 items-center justify-center rounded-full border border-white/15 bg-black/45 text-white shadow-lg backdrop-blur-md"
|
|
273
|
+
>
|
|
274
|
+
<span className="size-6 animate-spin rounded-full border-2 border-white/30 border-t-white motion-reduce:animate-none" />
|
|
275
|
+
<span className="sr-only">
|
|
276
|
+
{buffering ? "Buffering video" : "Loading video"}
|
|
277
|
+
</span>
|
|
278
|
+
</div>
|
|
279
|
+
) : !playing ? (
|
|
280
|
+
<div
|
|
281
|
+
className={cn(
|
|
282
|
+
"flex items-center justify-center rounded-full border border-white/20 bg-black/45 text-white shadow-lg backdrop-blur-md",
|
|
283
|
+
ended ? "h-12 gap-2 px-4" : "size-14",
|
|
284
|
+
)}
|
|
285
|
+
>
|
|
286
|
+
<IconPlay
|
|
287
|
+
size={ended ? 18 : 24}
|
|
288
|
+
className={cn(ended ? "size-4.5" : "ml-0.5 size-6")}
|
|
289
|
+
aria-hidden
|
|
290
|
+
/>
|
|
291
|
+
{ended ? <span className="text-sm-strong">Replay</span> : null}
|
|
292
|
+
</div>
|
|
127
293
|
) : null}
|
|
128
294
|
</div>
|
|
129
295
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
296
|
+
{!failed && !loading ? (
|
|
297
|
+
<div
|
|
298
|
+
className={cn(
|
|
299
|
+
"absolute inset-x-0 bottom-0 flex translate-y-0 flex-col bg-gradient-to-t from-black/85 via-black/55 to-transparent px-4 pt-14 pb-3 transition-[opacity,transform] duration-[var(--duration-md)] ease-enter group-data-[fullscreen]:px-6 group-data-[fullscreen]:pb-6 motion-reduce:transition-none",
|
|
300
|
+
"group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100",
|
|
301
|
+
// Nothing hovers on a touch screen, so the chrome would never come
|
|
302
|
+
// back once playback started. Keep it visible there instead.
|
|
303
|
+
"[@media(hover:none)]:translate-y-0 [@media(hover:none)]:opacity-100",
|
|
304
|
+
playing ? "translate-y-2 opacity-0" : "opacity-100",
|
|
305
|
+
)}
|
|
306
|
+
>
|
|
307
|
+
{title ? (
|
|
308
|
+
<p className="text-sm-strong mb-2 truncate text-white">{title}</p>
|
|
309
|
+
) : null}
|
|
134
310
|
|
|
135
|
-
<div className="flex items-center gap-3">
|
|
136
311
|
<label className="sr-only" htmlFor={seekId}>
|
|
137
312
|
Seek
|
|
138
313
|
</label>
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
314
|
+
<div className="group/seek relative flex h-5 items-center">
|
|
315
|
+
<span
|
|
316
|
+
ref={seekPreviewRef}
|
|
317
|
+
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"
|
|
318
|
+
/>
|
|
319
|
+
{/* Track, buffered fill and played fill sit under the input, which
|
|
320
|
+
stays transparent so its thumb is the only thing it paints. */}
|
|
321
|
+
<span
|
|
322
|
+
aria-hidden
|
|
323
|
+
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"
|
|
324
|
+
/>
|
|
325
|
+
<span
|
|
326
|
+
aria-hidden
|
|
327
|
+
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"
|
|
328
|
+
style={{ width: `${loaded}%` }}
|
|
329
|
+
/>
|
|
330
|
+
<span
|
|
331
|
+
aria-hidden
|
|
332
|
+
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"
|
|
333
|
+
style={{ width: `${played}%` }}
|
|
334
|
+
/>
|
|
335
|
+
<input
|
|
336
|
+
id={seekId}
|
|
337
|
+
type="range"
|
|
338
|
+
min={0}
|
|
339
|
+
max={duration || 0}
|
|
340
|
+
step={0.01}
|
|
341
|
+
value={current}
|
|
342
|
+
onChange={(event) => {
|
|
343
|
+
const value = Number(event.target.value)
|
|
344
|
+
handleSeek(value)
|
|
345
|
+
showSeekPreview(value)
|
|
346
|
+
}}
|
|
347
|
+
onPointerMove={(event) => {
|
|
348
|
+
if (duration <= 0) return
|
|
349
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
350
|
+
const ratio = Math.min(
|
|
351
|
+
Math.max((event.clientX - rect.left) / rect.width, 0),
|
|
352
|
+
1,
|
|
353
|
+
)
|
|
354
|
+
showSeekPreview(ratio * duration)
|
|
355
|
+
}}
|
|
356
|
+
onPointerLeave={(event) => {
|
|
357
|
+
if (document.activeElement !== event.currentTarget) {
|
|
358
|
+
hideSeekPreview()
|
|
359
|
+
}
|
|
360
|
+
}}
|
|
361
|
+
onFocus={() => showSeekPreview(current)}
|
|
362
|
+
onBlur={hideSeekPreview}
|
|
363
|
+
aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
|
|
364
|
+
className={cn(
|
|
365
|
+
"relative m-0 h-5 w-full cursor-pointer appearance-none bg-transparent outline-none",
|
|
366
|
+
// Both vendor thumbs, or Firefox falls back to a default one.
|
|
367
|
+
"[&::-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",
|
|
368
|
+
"[&::-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",
|
|
369
|
+
"focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-white/70",
|
|
370
|
+
"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",
|
|
371
|
+
)}
|
|
372
|
+
/>
|
|
373
|
+
</div>
|
|
154
374
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
variant="ghost"
|
|
170
|
-
size="sm"
|
|
171
|
-
iconOnly
|
|
172
|
-
rounded
|
|
173
|
-
aria-label={muted ? "Unmute" : "Mute"}
|
|
174
|
-
onClick={handleToggleMute}
|
|
175
|
-
>
|
|
176
|
-
{muted ? <MuteIcon /> : <VolumeIcon />}
|
|
177
|
-
</Button>
|
|
178
|
-
<span className="text-xs font-mono text-fg-tertiary">
|
|
179
|
-
{formatTime(current)} / {formatTime(duration)}
|
|
180
|
-
</span>
|
|
181
|
-
<div className="ml-auto">
|
|
182
|
-
<Button
|
|
375
|
+
<div className="mt-0.5 flex items-center gap-2">
|
|
376
|
+
<button
|
|
377
|
+
type="button"
|
|
378
|
+
className={overlayControl}
|
|
379
|
+
aria-label={ended ? "Replay" : playing ? "Pause" : "Play"}
|
|
380
|
+
onClick={handleTogglePlay}
|
|
381
|
+
>
|
|
382
|
+
{playing ? (
|
|
383
|
+
<IconPause size={18} className="size-4.5" aria-hidden />
|
|
384
|
+
) : (
|
|
385
|
+
<IconPlay size={18} className="size-4.5" aria-hidden />
|
|
386
|
+
)}
|
|
387
|
+
</button>
|
|
388
|
+
<button
|
|
183
389
|
type="button"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
390
|
+
className={overlayControl}
|
|
391
|
+
aria-label={muted ? "Unmute" : "Mute"}
|
|
392
|
+
onClick={handleToggleMute}
|
|
393
|
+
>
|
|
394
|
+
{muted ? (
|
|
395
|
+
<IconVolumeOff size={18} className="size-4.5" aria-hidden />
|
|
396
|
+
) : (
|
|
397
|
+
<IconVolumeFull size={18} className="size-4.5" aria-hidden />
|
|
398
|
+
)}
|
|
399
|
+
</button>
|
|
400
|
+
<span className="text-xs text-white/85 tabular-nums">
|
|
401
|
+
{formatTime(current)} <span className="text-white/45">/</span>{" "}
|
|
402
|
+
{formatTime(duration)}
|
|
403
|
+
</span>
|
|
404
|
+
<button
|
|
405
|
+
type="button"
|
|
406
|
+
className={cn(overlayControl, "ml-auto")}
|
|
188
407
|
aria-label="Fullscreen"
|
|
189
408
|
onClick={handleFullscreen}
|
|
190
409
|
>
|
|
191
|
-
<
|
|
192
|
-
</
|
|
410
|
+
<IconFullScreen size={18} className="size-4.5" aria-hidden />
|
|
411
|
+
</button>
|
|
193
412
|
</div>
|
|
194
413
|
</div>
|
|
195
|
-
|
|
414
|
+
) : null}
|
|
196
415
|
</div>
|
|
197
416
|
)
|
|
198
417
|
}
|
|
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
|
-
)
|