@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.
Files changed (56) hide show
  1. package/package.json +5 -3
  2. package/src/accordion.tsx +1 -1
  3. package/src/alert-dialog.tsx +1 -1
  4. package/src/autocomplete.tsx +12 -11
  5. package/src/badge.tsx +73 -9
  6. package/src/brand.tsx +2 -2
  7. package/src/breadcrumb.tsx +38 -20
  8. package/src/button.tsx +2 -2
  9. package/src/calendar.tsx +22 -11
  10. package/src/card.tsx +57 -14
  11. package/src/carousel.tsx +163 -6
  12. package/src/checkbox.tsx +35 -13
  13. package/src/collapsible.tsx +4 -2
  14. package/src/combobox.tsx +13 -12
  15. package/src/command.tsx +38 -14
  16. package/src/context-menu.tsx +6 -3
  17. package/src/date-picker.tsx +174 -0
  18. package/src/dialog.tsx +33 -10
  19. package/src/drawer.tsx +1 -1
  20. package/src/empty.tsx +4 -4
  21. package/src/field.tsx +1 -1
  22. package/src/filter-group.tsx +139 -0
  23. package/src/icons.tsx +39 -0
  24. package/src/illustrations.tsx +219 -141
  25. package/src/image-modal.tsx +440 -49
  26. package/src/index.ts +188 -0
  27. package/src/input.tsx +1 -1
  28. package/src/kbd.tsx +49 -0
  29. package/src/lib/motion.ts +128 -18
  30. package/src/lib/popup.ts +45 -0
  31. package/src/menu.tsx +7 -4
  32. package/src/menubar.tsx +5 -0
  33. package/src/meter.tsx +1 -1
  34. package/src/minimap.tsx +267 -0
  35. package/src/navigation-menu.tsx +336 -67
  36. package/src/number-field.tsx +327 -61
  37. package/src/pagination.tsx +258 -42
  38. package/src/popover.tsx +3 -1
  39. package/src/preview-card.tsx +3 -1
  40. package/src/progress.tsx +6 -1
  41. package/src/questionnaire.tsx +279 -0
  42. package/src/scroll-area.tsx +1 -1
  43. package/src/select.tsx +8 -6
  44. package/src/sidebar.tsx +109 -28
  45. package/src/slider.tsx +64 -6
  46. package/src/sounds.tsx +6 -10
  47. package/src/spinner.tsx +105 -32
  48. package/src/switch.tsx +2 -2
  49. package/src/table.tsx +82 -15
  50. package/src/tabs.tsx +184 -38
  51. package/src/text-animate.tsx +71 -28
  52. package/src/textarea.tsx +1 -1
  53. package/src/timeline.tsx +321 -0
  54. package/src/toast.tsx +156 -23
  55. package/src/tooltip.tsx +43 -3
  56. package/src/video-player.tsx +296 -120
@@ -8,7 +8,12 @@ import {
8
8
  useState,
9
9
  type ComponentProps,
10
10
  } from "react"
11
- import { Button } from "./button"
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 video = videoRef.current
70
- if (!video) return
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 video.requestFullscreen?.()
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 handlePlay = () => setPlaying(true)
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 handleTime = () => setCurrent(video.currentTime)
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 progress = duration > 0 ? (current / duration) * 100 : 0
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 border border-border-primary bg-surface",
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
- <div className="relative bg-gray-1000">
112
- <video
113
- ref={videoRef}
114
- src={src}
115
- poster={poster}
116
- playsInline
117
- className="aspect-video w-full cursor-pointer object-cover"
118
- aria-label={ariaLabel ?? title ?? "Video"}
119
- onClick={handleTogglePlay}
120
- />
121
- {!playing ? (
122
- <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
123
- <span className="flex size-12 items-center justify-center rounded-full bg-surface/90 text-fg-primary shadow-md">
124
- <PlayIcon />
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
- <div className="flex flex-col gap-3 border-t border-border-primary bg-surface p-3">
131
- {title ? (
132
- <p className="text-sm-strong truncate text-fg-primary">{title}</p>
133
- ) : null}
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
- <input
140
- id={seekId}
141
- type="range"
142
- min={0}
143
- max={duration || 0}
144
- step={0.1}
145
- value={current}
146
- onChange={(event) => handleSeek(Number(event.target.value))}
147
- className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-background-quaternary accent-[var(--brand-primary)] [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-brand-primary"
148
- style={{
149
- background: `linear-gradient(to right, var(--brand-primary) ${progress}%, var(--background-quaternary) ${progress}%)`,
150
- }}
151
- aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
152
- />
153
- </div>
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
- <div className="flex items-center gap-2">
156
- <Button
157
- type="button"
158
- variant="ghost"
159
- size="sm"
160
- iconOnly
161
- rounded
162
- aria-label={playing ? "Pause" : "Play"}
163
- onClick={handleTogglePlay}
164
- >
165
- {playing ? <PauseIcon /> : <PlayIcon />}
166
- </Button>
167
- <Button
168
- type="button"
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
- variant="ghost"
185
- size="sm"
186
- iconOnly
187
- rounded
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
- <FullscreenIcon />
192
- </Button>
410
+ <IconFullScreen size={18} className="size-4.5" aria-hidden />
411
+ </button>
193
412
  </div>
194
413
  </div>
195
- </div>
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
- )