@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.
Files changed (82) hide show
  1. package/package.json +5 -3
  2. package/src/accordion.tsx +5 -2
  3. package/src/alert-dialog.tsx +1 -1
  4. package/src/attachment.tsx +5 -2
  5. package/src/autocomplete.tsx +4 -3
  6. package/src/block-editor.tsx +1747 -0
  7. package/src/brand.tsx +2 -2
  8. package/src/breadcrumb.tsx +14 -7
  9. package/src/button.tsx +22 -11
  10. package/src/calendar.tsx +6 -3
  11. package/src/carousel.tsx +220 -53
  12. package/src/checkbox.tsx +3 -2
  13. package/src/code-block.tsx +66 -5
  14. package/src/collapsible.tsx +4 -2
  15. package/src/combobox.tsx +4 -3
  16. package/src/command.tsx +27 -12
  17. package/src/dialog.tsx +33 -10
  18. package/src/empty.tsx +4 -4
  19. package/src/field.tsx +4 -3
  20. package/src/filter-group.tsx +143 -0
  21. package/src/icons.tsx +28 -1
  22. package/src/illustrations.tsx +219 -141
  23. package/src/index.ts +238 -18
  24. package/src/input.tsx +8 -5
  25. package/src/kbd.tsx +10 -4
  26. package/src/lib/focus.ts +41 -0
  27. package/src/lib/popup.ts +1 -1
  28. package/src/lifeline/company-icon.tsx +71 -0
  29. package/src/lifeline/index.ts +42 -0
  30. package/src/lifeline/lifeline-data.ts +97 -0
  31. package/src/lifeline/lifeline-desktop.tsx +204 -0
  32. package/src/lifeline/lifeline-event.tsx +108 -0
  33. package/src/lifeline/lifeline-fireworks.tsx +302 -0
  34. package/src/lifeline/lifeline-hover-image.tsx +269 -0
  35. package/src/lifeline/lifeline-icons.tsx +39 -0
  36. package/src/lifeline/lifeline-intro-timing.ts +105 -0
  37. package/src/lifeline/lifeline-labels.tsx +24 -0
  38. package/src/lifeline/lifeline-layout.ts +1 -0
  39. package/src/lifeline/lifeline-legend.tsx +31 -0
  40. package/src/lifeline/lifeline-lightbox.tsx +309 -0
  41. package/src/lifeline/lifeline-marker.tsx +183 -0
  42. package/src/lifeline/lifeline-people.tsx +99 -0
  43. package/src/lifeline/lifeline-photos.tsx +366 -0
  44. package/src/lifeline/lifeline-shell.tsx +135 -0
  45. package/src/lifeline/lifeline-utils.ts +83 -0
  46. package/src/lifeline/lifeline-vertical.tsx +482 -0
  47. package/src/lifeline/lifeline.tsx +69 -0
  48. package/src/lifeline/types.ts +112 -0
  49. package/src/lifeline/use-lifeline-intro.ts +130 -0
  50. package/src/lifeline/use-lifeline-scroll.ts +1011 -0
  51. package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
  52. package/src/menubar.tsx +9 -2
  53. package/src/minimap.tsx +296 -0
  54. package/src/modal.tsx +608 -0
  55. package/src/navigation-menu.tsx +338 -67
  56. package/src/number-field.tsx +336 -61
  57. package/src/otp-field.tsx +4 -3
  58. package/src/pagination.tsx +262 -42
  59. package/src/password-protection.tsx +345 -0
  60. package/src/popover.tsx +1 -1
  61. package/src/progress.tsx +5 -0
  62. package/src/questionnaire.tsx +284 -0
  63. package/src/radio.tsx +4 -2
  64. package/src/scroll-area.tsx +4 -1
  65. package/src/select.tsx +5 -2
  66. package/src/sidebar.tsx +113 -28
  67. package/src/slider.tsx +66 -6
  68. package/src/sounds.tsx +6 -10
  69. package/src/spinner.tsx +105 -32
  70. package/src/switch.tsx +4 -1
  71. package/src/table.tsx +82 -15
  72. package/src/tabs.tsx +189 -38
  73. package/src/text-animate.tsx +71 -28
  74. package/src/textarea.tsx +6 -1
  75. package/src/timeline.tsx +378 -0
  76. package/src/toast.tsx +214 -35
  77. package/src/toggle.tsx +4 -2
  78. package/src/toolbar.tsx +12 -7
  79. package/src/tooltip.tsx +43 -3
  80. package/src/video-player.tsx +396 -127
  81. package/src/image-modal.tsx +0 -110
  82. package/src/markdown-editor.tsx +0 -302
@@ -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 { Button } from "./button"
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 video = videoRef.current
70
- if (!video) return
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 video.requestFullscreen?.()
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 handlePlay = () => setPlaying(true)
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 handleTime = () => setCurrent(video.currentTime)
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 progress = duration > 0 ? (current / duration) * 100 : 0
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 border border-border-primary bg-surface",
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
- <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 />
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
- <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}
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
- <div className="flex items-center gap-3">
136
- <label className="sr-only" htmlFor={seekId}>
137
- Seek
138
- </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>
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
- <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
183
- type="button"
184
- variant="ghost"
185
- size="sm"
186
- iconOnly
187
- rounded
188
- aria-label="Fullscreen"
189
- onClick={handleFullscreen}
190
- >
191
- <FullscreenIcon />
192
- </Button>
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
- </div>
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
- )