@boyernick/standard-ui-react 0.4.2-canary.58 → 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.
- package/package.json +1 -1
- package/src/button.tsx +6 -1
- package/src/glass.tsx +68 -24
- package/src/index.ts +5 -0
- package/src/lib/glass-lifecycle.ts +33 -0
- package/src/lib/glass-lighting.ts +33 -0
- package/src/lib/liquidglass/NOTICE.md +14 -0
- package/src/lib/liquidglass/index.js +38 -6
- package/src/video-player.tsx +206 -222
package/package.json
CHANGED
package/src/button.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cva, type VariantProps } from "class-variance-authority"
|
|
2
|
-
import type { ButtonHTMLAttributes, ReactNode } from "react"
|
|
2
|
+
import type { ButtonHTMLAttributes, ReactNode, Ref } from "react"
|
|
3
3
|
import { cn } from "./lib/cn"
|
|
4
4
|
import { focusRing, focusRingDestructive } from "./lib/focus"
|
|
5
5
|
|
|
@@ -8,6 +8,10 @@ const buttonVariants = cva(
|
|
|
8
8
|
{
|
|
9
9
|
variants: {
|
|
10
10
|
variant: {
|
|
11
|
+
glass: cn(
|
|
12
|
+
"relative isolate border border-transparent bg-transparent text-fg-scrim",
|
|
13
|
+
focusRing,
|
|
14
|
+
),
|
|
11
15
|
primary: cn(
|
|
12
16
|
"border border-brand-primary-border bg-brand-primary text-brand-foreground inset-shadow-solid-top hover:bg-brand-primary-hover active:bg-brand-primary-active",
|
|
13
17
|
focusRing,
|
|
@@ -83,6 +87,7 @@ export type ButtonProps = Omit<
|
|
|
83
87
|
"prefix"
|
|
84
88
|
> &
|
|
85
89
|
VariantProps<typeof buttonVariants> & {
|
|
90
|
+
ref?: Ref<HTMLButtonElement>
|
|
86
91
|
/** Shows a spinner, sets aria-busy, and disables the button */
|
|
87
92
|
loading?: boolean
|
|
88
93
|
/** Square control sized to the height of `size` — pass an icon as children */
|
package/src/glass.tsx
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import type { GlassConfig } from "./lib/liquidglass/index.js";
|
|
5
5
|
import { cn } from "./lib/cn";
|
|
6
|
+
import { scheduleGlassRenderer } from "./lib/glass-lifecycle";
|
|
7
|
+
import { attachGlassLighting } from "./lib/glass-lighting";
|
|
8
|
+
import { Button, type ButtonProps } from "./button";
|
|
6
9
|
|
|
7
10
|
export type GlassState = "pending" | "live" | "off";
|
|
8
11
|
|
|
@@ -11,6 +14,8 @@ export interface GlassRootProps extends React.ComponentPropsWithoutRef<"div"> {
|
|
|
11
14
|
defaults?: Partial<GlassConfig>;
|
|
12
15
|
/** Disable WebGL rendering while preserving the underlying DOM. */
|
|
13
16
|
disabled?: boolean;
|
|
17
|
+
/** Move glass-optical reflections with the pointer; respects reduced motion. */
|
|
18
|
+
interactiveLighting?: boolean;
|
|
14
19
|
/** Called when the LiquidGlass renderer changes state. */
|
|
15
20
|
onGlassStateChange?: (state: GlassState) => void;
|
|
16
21
|
}
|
|
@@ -41,6 +46,7 @@ export const GlassRoot = React.forwardRef<HTMLDivElement, GlassRootProps>(
|
|
|
41
46
|
className,
|
|
42
47
|
defaults,
|
|
43
48
|
disabled = false,
|
|
49
|
+
interactiveLighting = false,
|
|
44
50
|
onGlassStateChange,
|
|
45
51
|
children,
|
|
46
52
|
...props
|
|
@@ -48,13 +54,22 @@ export const GlassRoot = React.forwardRef<HTMLDivElement, GlassRootProps>(
|
|
|
48
54
|
forwardedRef,
|
|
49
55
|
) {
|
|
50
56
|
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
|
57
|
+
const startupRef = React.useRef<Promise<void>>(Promise.resolve());
|
|
51
58
|
const stateCallbackRef = React.useRef(onGlassStateChange);
|
|
52
|
-
|
|
59
|
+
React.useEffect(() => {
|
|
60
|
+
stateCallbackRef.current = onGlassStateChange;
|
|
61
|
+
}, [onGlassStateChange]);
|
|
53
62
|
|
|
54
63
|
// GlassConfig is a flat JSON-safe object. A serialized key means callers
|
|
55
64
|
// can pass an inline object without tearing down WebGL on every render.
|
|
56
65
|
const defaultsKey = JSON.stringify(defaults ?? {});
|
|
57
66
|
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
if (interactiveLighting && rootRef.current && !disabled) {
|
|
69
|
+
return attachGlassLighting(rootRef.current);
|
|
70
|
+
}
|
|
71
|
+
}, [interactiveLighting, disabled]);
|
|
72
|
+
|
|
58
73
|
const setRootRef = React.useCallback(
|
|
59
74
|
(node: HTMLDivElement | null) => {
|
|
60
75
|
rootRef.current = node;
|
|
@@ -85,35 +100,39 @@ export const GlassRoot = React.forwardRef<HTMLDivElement, GlassRootProps>(
|
|
|
85
100
|
return;
|
|
86
101
|
}
|
|
87
102
|
|
|
88
|
-
let cancelled = false;
|
|
89
|
-
let destroy: (() => void) | undefined;
|
|
90
|
-
|
|
91
103
|
report("pending");
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
104
|
+
let renderer: { markChanged(): void } | undefined;
|
|
105
|
+
// Image load does not bubble and is not a DOM mutation. Lazy-loaded
|
|
106
|
+
// images otherwise leave the renderer's initial empty scene cached.
|
|
107
|
+
const refresh = () => renderer?.markChanged();
|
|
108
|
+
root.addEventListener("load", refresh, true);
|
|
109
|
+
|
|
110
|
+
const lifecycle = scheduleGlassRenderer(
|
|
111
|
+
startupRef.current,
|
|
112
|
+
async () => {
|
|
113
|
+
const { LiquidGlass } = await import("./lib/liquidglass/index.js");
|
|
114
|
+
await document.fonts.ready;
|
|
115
|
+
return LiquidGlass.init({
|
|
96
116
|
root,
|
|
97
117
|
glassElements,
|
|
98
118
|
defaults: JSON.parse(defaultsKey) as Partial<GlassConfig>,
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
instance.destroy();
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
destroy = () => instance.destroy();
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
(instance) => {
|
|
122
|
+
renderer = instance;
|
|
108
123
|
report("live");
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
124
|
+
},
|
|
125
|
+
(error) => {
|
|
126
|
+
console.warn("GlassRoot: renderer initialization failed.", error);
|
|
127
|
+
report("off");
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
startupRef.current = lifecycle.settled;
|
|
113
131
|
|
|
114
132
|
return () => {
|
|
115
|
-
|
|
116
|
-
|
|
133
|
+
root.removeEventListener("load", refresh, true);
|
|
134
|
+
renderer = undefined;
|
|
135
|
+
lifecycle.dispose();
|
|
117
136
|
};
|
|
118
137
|
}, [defaultsKey, disabled]);
|
|
119
138
|
|
|
@@ -143,7 +162,7 @@ export const Glass = React.forwardRef<HTMLDivElement, GlassProps>(function Glass
|
|
|
143
162
|
ref={ref}
|
|
144
163
|
data-slot="glass"
|
|
145
164
|
data-config={config ? JSON.stringify(config) : undefined}
|
|
146
|
-
className={cn("relative", className)}
|
|
165
|
+
className={cn("relative isolate", className)}
|
|
147
166
|
{...props}
|
|
148
167
|
>
|
|
149
168
|
{children}
|
|
@@ -154,4 +173,29 @@ export const Glass = React.forwardRef<HTMLDivElement, GlassProps>(function Glass
|
|
|
154
173
|
GlassRoot.displayName = "GlassRoot";
|
|
155
174
|
Glass.displayName = "Glass";
|
|
156
175
|
|
|
176
|
+
export interface GlassButtonProps extends Omit<ButtonProps, "variant"> {
|
|
177
|
+
config?: Partial<GlassConfig>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A native StandardUI button, rendered as a direct child of GlassRoot. */
|
|
181
|
+
export function GlassButton({ config, ...props }: GlassButtonProps) {
|
|
182
|
+
return (
|
|
183
|
+
<Button
|
|
184
|
+
{...props}
|
|
185
|
+
variant="glass"
|
|
186
|
+
data-slot="glass"
|
|
187
|
+
data-glass-radius="css"
|
|
188
|
+
data-config={JSON.stringify({
|
|
189
|
+
blurAmount: 0.12,
|
|
190
|
+
refraction: 0.25,
|
|
191
|
+
zRadius: 6,
|
|
192
|
+
chromAberration: 0.02,
|
|
193
|
+
shadowOpacity: 0.15,
|
|
194
|
+
...config,
|
|
195
|
+
button: true,
|
|
196
|
+
})}
|
|
197
|
+
/>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
157
201
|
export type { GlassConfig } from "./lib/liquidglass/index.js";
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export { Button, buttonVariants, type ButtonProps } from "./button";
|
|
2
|
+
export {
|
|
3
|
+
Glass, GlassRoot, GlassButton,
|
|
4
|
+
type GlassProps, type GlassRootProps, type GlassButtonProps,
|
|
5
|
+
type GlassConfig, type GlassState,
|
|
6
|
+
} from "./glass";
|
|
2
7
|
export { Input, inputVariants, type InputProps } from "./input";
|
|
3
8
|
export {
|
|
4
9
|
Kbd,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Serialize renderer ownership of a root, including asynchronous startup. */
|
|
2
|
+
export function scheduleGlassRenderer<T extends { destroy(): void }>(
|
|
3
|
+
previous: Promise<void>,
|
|
4
|
+
initialize: () => Promise<T>,
|
|
5
|
+
ready: (instance: T) => void,
|
|
6
|
+
failed: (error: unknown) => void,
|
|
7
|
+
) {
|
|
8
|
+
let cancelled = false;
|
|
9
|
+
let instance: T | undefined;
|
|
10
|
+
const settled = previous.then(async () => {
|
|
11
|
+
if (cancelled) return;
|
|
12
|
+
try {
|
|
13
|
+
instance = await initialize();
|
|
14
|
+
if (cancelled) {
|
|
15
|
+
instance.destroy();
|
|
16
|
+
instance = undefined;
|
|
17
|
+
} else {
|
|
18
|
+
ready(instance);
|
|
19
|
+
}
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (!cancelled) failed(error);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
settled,
|
|
27
|
+
dispose() {
|
|
28
|
+
cancelled = true;
|
|
29
|
+
instance?.destroy();
|
|
30
|
+
instance = undefined;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Update CSS-only reflections without re-rendering React or the WebGL scene. */
|
|
2
|
+
export function attachGlassLighting(root: HTMLElement) {
|
|
3
|
+
const target = root.closest("a, button") ?? root
|
|
4
|
+
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)")
|
|
5
|
+
let frame = 0
|
|
6
|
+
const reset = () => {
|
|
7
|
+
cancelAnimationFrame(frame)
|
|
8
|
+
root.style.removeProperty("--glass-light-angle")
|
|
9
|
+
root.style.removeProperty("--glass-light-x")
|
|
10
|
+
root.style.removeProperty("--glass-light-y")
|
|
11
|
+
}
|
|
12
|
+
const move = (event: Event) => {
|
|
13
|
+
if (!(event instanceof PointerEvent) || reducedMotion.matches) return
|
|
14
|
+
const bounds = target.getBoundingClientRect()
|
|
15
|
+
const x = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width))
|
|
16
|
+
const y = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height))
|
|
17
|
+
cancelAnimationFrame(frame)
|
|
18
|
+
frame = requestAnimationFrame(() => {
|
|
19
|
+
root.style.setProperty("--glass-light-angle", `${125 + x * 35 + y * 10}deg`)
|
|
20
|
+
root.style.setProperty("--glass-light-x", `${20 + x * 40}%`)
|
|
21
|
+
root.style.setProperty("--glass-light-y", `${y * 35}%`)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
target.addEventListener("pointermove", move)
|
|
25
|
+
target.addEventListener("pointerleave", reset)
|
|
26
|
+
reducedMotion.addEventListener("change", reset)
|
|
27
|
+
return () => {
|
|
28
|
+
reset()
|
|
29
|
+
target.removeEventListener("pointermove", move)
|
|
30
|
+
target.removeEventListener("pointerleave", reset)
|
|
31
|
+
reducedMotion.removeEventListener("change", reset)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -8,3 +8,17 @@ License: MIT (as declared by the upstream package).
|
|
|
8
8
|
`index.js` is the package's published bundled WebGL renderer. StandardUI
|
|
9
9
|
vendors the build because version 1.0.3 publishes a `postinstall`
|
|
10
10
|
command whose `patch-package` executable is not a runtime dependency.
|
|
11
|
+
|
|
12
|
+
Local patch: `LiquidGlass.init()` destroys a partially initialized instance
|
|
13
|
+
before rethrowing a startup error, releasing its canvas, listeners and WebGL
|
|
14
|
+
resources.
|
|
15
|
+
|
|
16
|
+
StandardUI's `data-glass-radius="css"` panes derive the shader radius from the
|
|
17
|
+
computed CSS radius. Disabled buttons suppress shader hover/press feedback.
|
|
18
|
+
|
|
19
|
+
Responsive images are decoded from `currentSrc` without `srcset` before canvas
|
|
20
|
+
sampling. This keeps object-fit crop coordinates in resource pixels instead of
|
|
21
|
+
mixing density-corrected `naturalWidth` with full-resolution canvas coordinates.
|
|
22
|
+
|
|
23
|
+
Injected canvases compensate for the host's border so the rendered lens stays
|
|
24
|
+
centered on bordered controls and their icons.
|
|
@@ -1831,6 +1831,7 @@ var LiquidGlass = class _LiquidGlass {
|
|
|
1831
1831
|
this.defaults = { ...DEFAULTS, ...defaults };
|
|
1832
1832
|
this.glassSet = new Set(Array.from(glassElements || []));
|
|
1833
1833
|
this.glassCanvases = /* @__PURE__ */ new Map();
|
|
1834
|
+
this._decodedImages = /* @__PURE__ */ new WeakMap();
|
|
1834
1835
|
this.capture = new HtmlCapture(root);
|
|
1835
1836
|
this.capture.onCacheUpdate = (element) => {
|
|
1836
1837
|
this._markGlassesIntersecting(element);
|
|
@@ -1852,8 +1853,13 @@ var LiquidGlass = class _LiquidGlass {
|
|
|
1852
1853
|
// ────────────────────────────────────────────
|
|
1853
1854
|
static async init(options) {
|
|
1854
1855
|
const instance = new _LiquidGlass(options);
|
|
1855
|
-
|
|
1856
|
-
|
|
1856
|
+
try {
|
|
1857
|
+
await instance._start();
|
|
1858
|
+
return instance;
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
instance.destroy();
|
|
1861
|
+
throw error;
|
|
1862
|
+
}
|
|
1857
1863
|
}
|
|
1858
1864
|
// ────────────────────────────────────────────
|
|
1859
1865
|
// Lifecycle
|
|
@@ -2285,9 +2291,13 @@ var LiquidGlass = class _LiquidGlass {
|
|
|
2285
2291
|
cachedEl.configCacheKey = configKey;
|
|
2286
2292
|
}
|
|
2287
2293
|
const config = { ...this.defaults, ...cachedEl.configCache || {} };
|
|
2294
|
+
// StandardUI buttons derive the lens outline from their CSS radius token.
|
|
2295
|
+
if (el.dataset.glassRadius === "css") {
|
|
2296
|
+
config.cornerRadius = parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0;
|
|
2297
|
+
}
|
|
2288
2298
|
if (config.button) {
|
|
2289
2299
|
const state = this._buttonStates.get(el);
|
|
2290
|
-
if (state) {
|
|
2300
|
+
if (state && !el.matches(":disabled")) {
|
|
2291
2301
|
if (state.pressed) {
|
|
2292
2302
|
config.zRadius = config.zRadius * 0.8;
|
|
2293
2303
|
config.shadowSpread = config.shadowSpread * 1.2;
|
|
@@ -2325,8 +2335,10 @@ var LiquidGlass = class _LiquidGlass {
|
|
|
2325
2335
|
canvas.height = Math.round((elH + padH) * dpr);
|
|
2326
2336
|
canvas.style.cssText = [
|
|
2327
2337
|
"position:absolute",
|
|
2328
|
-
|
|
2329
|
-
|
|
2338
|
+
// Absolute children start at the padding edge. Align the lens with the
|
|
2339
|
+
// outer border box used for sampling and centering the button's icon.
|
|
2340
|
+
`left:${-SHADOW_PAD - el.clientLeft}px`,
|
|
2341
|
+
`top:${-SHADOW_PAD - el.clientTop}px`,
|
|
2330
2342
|
`width:${elW + padW}px`,
|
|
2331
2343
|
`height:${elH + padH}px`,
|
|
2332
2344
|
"pointer-events:none",
|
|
@@ -2674,8 +2686,28 @@ var LiquidGlass = class _LiquidGlass {
|
|
|
2674
2686
|
targetCtx.drawImage(liveCanvas, dx, dy, dw, dh);
|
|
2675
2687
|
return true;
|
|
2676
2688
|
} else if (tag === "IMG") {
|
|
2677
|
-
|
|
2689
|
+
let img = el;
|
|
2678
2690
|
if (!img.complete || img.naturalWidth === 0) return false;
|
|
2691
|
+
// srcset makes naturalWidth density-corrected, but drawImage source
|
|
2692
|
+
// coordinates address the resource's pixels. Decode currentSrc without
|
|
2693
|
+
// srcset so object-fit cropping and canvas sampling use the same units.
|
|
2694
|
+
if (img.srcset) {
|
|
2695
|
+
const source = img.currentSrc || img.src;
|
|
2696
|
+
let cached = this._decodedImages.get(el);
|
|
2697
|
+
if (!cached || cached.source !== source) {
|
|
2698
|
+
const decoded = new Image();
|
|
2699
|
+
if (img.crossOrigin !== null) decoded.crossOrigin = img.crossOrigin;
|
|
2700
|
+
decoded.referrerPolicy = img.referrerPolicy;
|
|
2701
|
+
decoded.onload = () => {
|
|
2702
|
+
if (this._running) this.markChanged(el);
|
|
2703
|
+
};
|
|
2704
|
+
cached = { source, image: decoded };
|
|
2705
|
+
this._decodedImages.set(el, cached);
|
|
2706
|
+
decoded.src = source;
|
|
2707
|
+
}
|
|
2708
|
+
img = cached.image;
|
|
2709
|
+
if (!img.complete || img.naturalWidth === 0) return false;
|
|
2710
|
+
}
|
|
2679
2711
|
this._drawMediaFitted(
|
|
2680
2712
|
targetCtx,
|
|
2681
2713
|
img,
|
package/src/video-player.tsx
CHANGED
|
@@ -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
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
<
|
|
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
|
-
{/*
|
|
317
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
337
|
-
className="
|
|
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
|
-
) :
|
|
345
|
-
|
|
346
|
-
className={
|
|
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
|
-
|
|
362
|
-
)
|
|
363
|
-
</
|
|
345
|
+
</>
|
|
346
|
+
)}
|
|
347
|
+
</Glass>
|
|
364
348
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
}
|