@boyernick/standard-ui-react 0.4.2-canary.57 → 0.4.2-canary.59

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.
@@ -17,13 +17,14 @@ import { IconPlay } from "@central-icons-react/round-filled-radius-2-stroke-2/Ic
17
17
  import { IconVolumeFull } from "@central-icons-react/round-filled-radius-2-stroke-2/IconVolumeFull"
18
18
  import { IconVolumeOff } from "@central-icons-react/round-filled-radius-2-stroke-2/IconVolumeOff"
19
19
  import { cn } from "./lib/cn"
20
+ import { Glass, GlassButton, GlassRoot } from "./glass"
20
21
 
21
22
  /**
22
23
  * Picture-in-Picture support is a static browser capability, not React state.
23
24
  * Reading it through an external store keeps the server snapshot `false` so
24
25
  * hydration matches, without a post-mount setState that re-renders the player.
25
26
  */
26
- const subscribePipSupport = () => () => {}
27
+ const subscribePipSupport = () => () => { }
27
28
  const getPipSupported = () => document.pictureInPictureEnabled
28
29
  const getPipSupportedOnServer = () => false
29
30
 
@@ -34,26 +35,20 @@ const formatTime = (seconds: number) => {
34
35
  return `${mins}:${secs.toString().padStart(2, "0")}`
35
36
  }
36
37
 
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
- /** One control, one piece of glass. Sized to 36 because a standalone floating
42
- * object needs to be tappable in its own right — inside the old bar these
43
- * could lean on the bar for presence and sat at 28.
44
- *
45
- * No hover background: the material *is* the background, and a translucent
46
- * white wash over it fights the fill and the rim. The lens brightens instead,
47
- * by the same step as the image modal's glass controls.
48
- *
49
- * No drop shadow on any glass here: the material draws its shape with the
50
- * rim, and `shadow-lg` would add its own hairline ring over it. */
51
- const overlayControl =
52
- "glass glass-dark [--glass-alpha:52%] inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full text-white/85 outline-none transition-[filter,color,transform] duration-[var(--duration-sm)] ease-enter hover:text-white hover:brightness-150 active:scale-95 focus-visible:text-white focus-visible:ring-2 focus-visible:ring-white/50 motion-reduce:transition-none motion-reduce:active:scale-100"
53
-
54
- /** The scrubber and its two timecodes travel together as one object. */
55
- const overlayTrack =
56
- "glass glass-dark [--glass-alpha:52%] flex h-9 min-w-0 flex-1 items-center gap-2 rounded-full px-3"
38
+ // All panes are direct children of the video root, sharing one WebGL context.
39
+ const videoGlass = {
40
+ blurAmount: 0.08,
41
+ refraction: 0.12,
42
+ zRadius: 3,
43
+ chromAberration: 0,
44
+ brightness: -0.3,
45
+ edgeHighlight: 0.045,
46
+ specular: 0.02,
47
+ fresnel: 1,
48
+ shadowOpacity: 0.065,
49
+ shadowSpread: 12,
50
+ shadowOffsetY: 3,
51
+ }
57
52
 
58
53
  export type VideoPlayerProps = ComponentProps<"div"> & {
59
54
  src: string
@@ -279,8 +274,17 @@ export const VideoPlayer = ({
279
274
  const loaded = duration > 0 ? (buffered / duration) * 100 : 0
280
275
  const remaining = Math.max(duration - current, 0)
281
276
 
277
+ const controlVisibility = cn(
278
+ "glass-optical glass-optical-media absolute bottom-2 transition-opacity duration-[var(--duration-md)] ease-enter group-data-[fullscreen]:bottom-5 motion-reduce:transition-none",
279
+ "group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto",
280
+ "[@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto",
281
+ playing ? "pointer-events-none opacity-0" : "opacity-100",
282
+ )
283
+
282
284
  return (
283
- <div
285
+ <GlassRoot
286
+ interactiveLighting
287
+ defaults={videoGlass}
284
288
  ref={playerRef}
285
289
  data-slot="video-player"
286
290
  data-playing={playing || undefined}
@@ -313,216 +317,196 @@ export const VideoPlayer = ({
313
317
  onClick={handleTogglePlay}
314
318
  />
315
319
 
316
- {/* These centred states stay inert so the video remains the click target. */}
317
- <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
320
+ {/* The status pane stays mounted so startup owns a stable set of panes.
321
+ It remains inert; clicking the video still toggles playback. */}
322
+ <Glass
323
+ data-glass-radius="css"
324
+ className={cn(
325
+ "glass-optical glass-optical-media pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center text-fg-scrim",
326
+ failed ? "max-w-sm flex-col gap-2 rounded-xl px-5 py-4 text-center" : ended ? "h-8 gap-1 rounded-full px-2.5" : "size-11 rounded-full",
327
+ playing && !buffering && !loading && !failed && "invisible",
328
+ )}
329
+ >
318
330
  {failed ? (
319
- <div
320
- role="alert"
321
- className="mx-6 flex max-w-sm flex-col items-center gap-2 rounded-xl glass glass-dark px-5 py-4 text-center text-white"
322
- >
323
- <IconExclamationTriangle
324
- size={22}
325
- mode="raw"
326
- className="size-5.5"
327
- aria-hidden
328
- />
331
+ <div role="alert" className="flex flex-col items-center gap-2">
332
+ <IconExclamationTriangle size={22} mode="raw" aria-hidden />
329
333
  <span className="text-sm-strong">Video unavailable</span>
330
- <span className="text-xs text-white/70">
331
- Check the source and try again.
332
- </span>
334
+ <span className="text-xs text-fg-scrim-secondary">Check the source and try again.</span>
333
335
  </div>
334
336
  ) : loading || buffering ? (
335
- <div
336
- role="status"
337
- className="flex size-14 items-center justify-center rounded-full glass glass-dark text-white"
338
- >
339
- <span className="size-6 animate-spin rounded-full border-2 border-white/30 border-t-white motion-reduce:animate-none" />
340
- <span className="sr-only">
341
- {buffering ? "Buffering video" : "Loading video"}
342
- </span>
337
+ <div role="status">
338
+ <span className="block size-6 animate-spin rounded-full border-2 border-fg-scrim/30 border-t-fg-scrim motion-reduce:animate-none" />
339
+ <span className="sr-only">{buffering ? "Buffering video" : "Loading video"}</span>
343
340
  </div>
344
- ) : !playing ? (
345
- <div
346
- className={cn(
347
- "flex items-center justify-center rounded-full glass glass-dark text-white",
348
- ended ? "h-8 gap-1 px-2.5" : "size-11",
349
- )}
350
- >
351
- <IconPlay
352
- size={ended ? 12 : 18}
353
- mode="raw"
354
- // No nudge: Central's play glyph is already optically centred in
355
- // its 24-unit box (its mass sits at x 11.7), so a margin shifts
356
- // it right a second time.
357
- className={cn(ended ? "size-3" : "size-4.5")}
358
- aria-hidden
359
- />
341
+ ) : (
342
+ <>
343
+ <IconPlay size={ended ? 12 : 18} mode="raw" className={ended ? "size-3" : "size-4.5"} aria-hidden />
360
344
  {ended ? <span className="text-xs-strong">Replay</span> : null}
361
- </div>
362
- ) : null}
363
- </div>
345
+ </>
346
+ )}
347
+ </Glass>
364
348
 
365
- {!failed && !loading ? (
366
- <div
367
- className={cn(
368
- // A floating bar rather than a full-bleed scrim. The scrim existed
369
- // to keep white controls legible over arbitrary footage; the glass
370
- // material does that itself, and does it without darkening a strip
371
- // of the picture the viewer is trying to watch. Inset from the
372
- // edges so it reads as an object on the video rather than part of
373
- // the frame.
374
- "absolute inset-x-2 bottom-2 flex translate-y-0 items-center gap-2 transition-[opacity,transform] duration-[var(--duration-md)] ease-enter group-data-[fullscreen]:inset-x-5 group-data-[fullscreen]:bottom-5 motion-reduce:transition-none",
375
- "group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100",
376
- // Nothing hovers on a touch screen, so the chrome would never come
377
- // back once playback started. Keep it visible there instead.
378
- "[@media(hover:none)]:translate-y-0 [@media(hover:none)]:opacity-100",
379
- playing ? "translate-y-2 opacity-0" : "opacity-100",
380
- )}
381
- >
382
- <button
383
- type="button"
384
- className={overlayControl}
385
- aria-label={ended ? "Replay" : playing ? "Pause" : "Play"}
386
- onClick={handleTogglePlay}
387
- >
388
- {playing ? (
389
- <IconPause size={14} mode="raw" className="size-3.5" aria-hidden />
390
- ) : (
391
- <IconPlay size={14} mode="raw" className="size-3.5" aria-hidden />
392
- )}
393
- </button>
394
- <button
395
- type="button"
396
- className={overlayControl}
397
- aria-label={muted ? "Unmute" : "Mute"}
398
- onClick={handleToggleMute}
399
- >
400
- {muted ? (
401
- <IconVolumeOff
402
- size={14}
403
- mode="raw"
404
- className="size-3.5"
405
- aria-hidden
406
- />
407
- ) : (
408
- <IconVolumeFull
409
- size={14}
410
- mode="raw"
411
- className="size-3.5"
412
- aria-hidden
413
- />
414
- )}
415
- </button>
416
- <div className={overlayTrack}>
417
- <span className="text-xs min-w-9 shrink-0 tabular-nums text-white/85">
418
- {formatTime(current)}
419
- </span>
349
+ <GlassButton
350
+ type="button"
351
+ rounded
352
+ iconOnly
353
+ config={videoGlass}
354
+ className={cn(controlVisibility, "left-2 group-data-[fullscreen]:left-5", (failed || loading) && "invisible")}
355
+ aria-label={ended ? "Replay" : playing ? "Pause" : "Play"}
356
+ onClick={handleTogglePlay}
357
+ >
358
+ {playing ? (
359
+ <IconPause size={14} mode="raw" className="size-3.5" aria-hidden />
360
+ ) : (
361
+ <IconPlay size={14} mode="raw" className="size-3.5" aria-hidden />
362
+ )}
363
+ </GlassButton>
364
+ <GlassButton
365
+ type="button"
366
+ rounded
367
+ iconOnly
368
+ config={videoGlass}
369
+ className={cn(controlVisibility, "left-13 group-data-[fullscreen]:left-16", (failed || loading) && "invisible")}
370
+ aria-label={muted ? "Unmute" : "Mute"}
371
+ onClick={handleToggleMute}
372
+ >
373
+ {muted ? (
374
+ <IconVolumeOff
375
+ size={14}
376
+ mode="raw"
377
+ className="size-3.5"
378
+ aria-hidden
379
+ />
380
+ ) : (
381
+ <IconVolumeFull
382
+ size={14}
383
+ mode="raw"
384
+ className="size-3.5"
385
+ aria-hidden
386
+ />
387
+ )}
388
+ </GlassButton>
389
+ <Glass
390
+ data-glass-radius="css"
391
+ className={cn(
392
+ controlVisibility,
393
+ "left-24 flex h-9 min-w-0 items-center gap-2 rounded-full px-3 group-data-[fullscreen]:left-27",
394
+ pipSupported ? "right-24 group-data-[fullscreen]:right-27" : "right-13 group-data-[fullscreen]:right-16",
395
+ (failed || loading) && "invisible",
396
+ )}
397
+ >
398
+ <span className="text-xs min-w-9 shrink-0 tabular-nums text-white/85">
399
+ {formatTime(current)}
400
+ </span>
420
401
 
421
- <label className="sr-only" htmlFor={seekId}>
422
- Seek
423
- </label>
424
- <div className="group/seek relative flex h-5 min-w-0 flex-1 items-center">
425
- <span
426
- ref={seekPreviewRef}
427
- 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"
428
- />
429
- {/* Track, buffered fill and played fill sit under the input, which
402
+ <label className="sr-only" htmlFor={seekId}>
403
+ Seek
404
+ </label>
405
+ <div className="group/seek relative flex h-5 min-w-0 flex-1 items-center">
406
+ <span
407
+ ref={seekPreviewRef}
408
+ 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"
409
+ />
410
+ {/* Track, buffered fill and played fill sit under the input, which
430
411
  stays transparent so its thumb is the only thing it paints. */}
431
- <span
432
- aria-hidden
433
- 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"
434
- />
435
- <span
436
- aria-hidden
437
- 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"
438
- style={{ width: `${loaded}%` }}
439
- />
440
- <span
441
- aria-hidden
442
- 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"
443
- style={{ width: `${played}%` }}
444
- />
445
- <input
446
- id={seekId}
447
- type="range"
448
- min={0}
449
- max={duration || 0}
450
- step={0.01}
451
- value={current}
452
- onChange={(event) => {
453
- const value = Number(event.target.value)
454
- handleSeek(value)
455
- showSeekPreview(value)
456
- }}
457
- onPointerMove={(event) => {
458
- if (duration <= 0) return
459
- const rect = event.currentTarget.getBoundingClientRect()
460
- const ratio = Math.min(
461
- Math.max((event.clientX - rect.left) / rect.width, 0),
462
- 1,
463
- )
464
- showSeekPreview(ratio * duration)
465
- }}
466
- onPointerLeave={(event) => {
467
- if (document.activeElement !== event.currentTarget) {
468
- hideSeekPreview()
469
- }
470
- }}
471
- onFocus={() => showSeekPreview(current)}
472
- onBlur={hideSeekPreview}
473
- aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
474
- className={cn(
475
- "relative m-0 h-5 w-full cursor-pointer appearance-none bg-transparent outline-none",
476
- // Both vendor thumbs, or Firefox falls back to a default one.
477
- "[&::-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",
478
- "[&::-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",
479
- "focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-white/70",
480
- "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",
481
- )}
482
- />
483
- </div>
412
+ <span
413
+ aria-hidden
414
+ 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"
415
+ />
416
+ <span
417
+ aria-hidden
418
+ 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"
419
+ style={{ width: `${loaded}%` }}
420
+ />
421
+ <span
422
+ aria-hidden
423
+ 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"
424
+ style={{ width: `${played}%` }}
425
+ />
426
+ <input
427
+ id={seekId}
428
+ type="range"
429
+ min={0}
430
+ max={duration || 0}
431
+ step={0.01}
432
+ value={current}
433
+ onChange={(event) => {
434
+ const value = Number(event.target.value)
435
+ handleSeek(value)
436
+ showSeekPreview(value)
437
+ }}
438
+ onPointerMove={(event) => {
439
+ if (duration <= 0) return
440
+ const rect = event.currentTarget.getBoundingClientRect()
441
+ const ratio = Math.min(
442
+ Math.max((event.clientX - rect.left) / rect.width, 0),
443
+ 1,
444
+ )
445
+ showSeekPreview(ratio * duration)
446
+ }}
447
+ onPointerLeave={(event) => {
448
+ if (document.activeElement !== event.currentTarget) {
449
+ hideSeekPreview()
450
+ }
451
+ }}
452
+ onFocus={() => showSeekPreview(current)}
453
+ onBlur={hideSeekPreview}
454
+ aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
455
+ className={cn(
456
+ "relative m-0 h-5 w-full cursor-pointer appearance-none bg-transparent outline-none",
457
+ // Both vendor thumbs, or Firefox falls back to a default one.
458
+ "[&::-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",
459
+ "[&::-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",
460
+ "focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-white/70",
461
+ "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",
462
+ )}
463
+ />
464
+ </div>
484
465
 
485
- <span className="text-xs min-w-10 shrink-0 text-right tabular-nums text-white/85">
486
- -{formatTime(remaining)}
487
- </span>
488
- </div>
466
+ <span className="text-xs min-w-10 shrink-0 text-right tabular-nums text-white/85">
467
+ -{formatTime(remaining)}
468
+ </span>
469
+ </Glass>
489
470
 
490
- {pipSupported ? (
491
- <button
492
- type="button"
493
- className={overlayControl}
494
- aria-label={
495
- pipActive
496
- ? "Exit picture in picture"
497
- : "Picture in picture"
498
- }
499
- aria-pressed={pipActive}
500
- onClick={handlePictureInPicture}
501
- >
502
- <IconPictureInPicture
503
- size={14}
504
- mode="raw"
505
- className="size-3.5"
506
- aria-hidden
507
- />
508
- </button>
509
- ) : null}
510
- <button
511
- type="button"
512
- className={overlayControl}
513
- aria-label={fullscreen ? "Exit fullscreen" : "Fullscreen"}
514
- aria-pressed={fullscreen}
515
- onClick={handleFullscreen}
516
- >
517
- <IconFullScreen
518
- size={14}
519
- mode="raw"
520
- className="size-3.5"
521
- aria-hidden
522
- />
523
- </button>
524
- </div>
525
- ) : null}
526
- </div>
471
+
472
+ <GlassButton
473
+ type="button"
474
+ rounded
475
+ iconOnly
476
+ config={videoGlass}
477
+ className={cn(controlVisibility, "right-13 group-data-[fullscreen]:right-16", (!pipSupported || failed || loading) && "invisible")}
478
+ aria-label={
479
+ pipActive
480
+ ? "Exit picture in picture"
481
+ : "Picture in picture"
482
+ }
483
+ aria-pressed={pipActive}
484
+ onClick={handlePictureInPicture}
485
+ >
486
+ <IconPictureInPicture
487
+ size={14}
488
+ mode="raw"
489
+ className="size-3.5"
490
+ aria-hidden
491
+ />
492
+ </GlassButton>
493
+ <GlassButton
494
+ type="button"
495
+ rounded
496
+ iconOnly
497
+ config={videoGlass}
498
+ className={cn(controlVisibility, "right-2 group-data-[fullscreen]:right-5", (failed || loading) && "invisible")}
499
+ aria-label={fullscreen ? "Exit fullscreen" : "Fullscreen"}
500
+ aria-pressed={fullscreen}
501
+ onClick={handleFullscreen}
502
+ >
503
+ <IconFullScreen
504
+ size={14}
505
+ mode="raw"
506
+ className="size-3.5"
507
+ aria-hidden
508
+ />
509
+ </GlassButton>
510
+ </GlassRoot>
527
511
  )
528
512
  }