@boyernick/standard-ui-react 0.4.2-canary.58 → 0.4.2-canary.60
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/carousel.tsx +46 -24
- 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/lib/media-glass.ts +14 -0
- package/src/modal.tsx +122 -114
- package/src/video-player.tsx +198 -225
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/carousel.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type KeyboardEvent,
|
|
16
16
|
} from "react"
|
|
17
17
|
import { Button } from "./button"
|
|
18
|
+
import { Glass, GlassRoot } from "./glass"
|
|
18
19
|
import { IconChevronRightSmall } from "./icons"
|
|
19
20
|
import { cn } from "./lib/cn"
|
|
20
21
|
import { focusRing, focusRingBorder } from "./lib/focus"
|
|
@@ -553,32 +554,53 @@ export const CarouselDots = ({ className, ...props }: CarouselDotsProps) => {
|
|
|
553
554
|
data-slot="carousel-dots"
|
|
554
555
|
role="tablist"
|
|
555
556
|
aria-label="Slide indicators"
|
|
556
|
-
className={cn("mt-4 flex items-center justify-start
|
|
557
|
+
className={cn("mt-4 flex items-center justify-start pl-4", className)}
|
|
557
558
|
{...props}
|
|
558
559
|
>
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
560
|
+
<GlassRoot interactiveLighting className="w-fit rounded-full">
|
|
561
|
+
<span aria-hidden className="absolute inset-0 rounded-full bg-background-secondary" />
|
|
562
|
+
<Glass
|
|
563
|
+
data-glass-radius="css"
|
|
564
|
+
className="glass-optical flex items-center gap-1 rounded-full px-3 py-2.5"
|
|
565
|
+
config={{
|
|
566
|
+
blurAmount: 0,
|
|
567
|
+
refraction: 0.12,
|
|
568
|
+
zRadius: 3,
|
|
569
|
+
chromAberration: 0,
|
|
570
|
+
tintStrength: 0,
|
|
571
|
+
edgeHighlight: 0.045,
|
|
572
|
+
specular: 0.02,
|
|
573
|
+
fresnel: 1,
|
|
574
|
+
shadowOpacity: 0.065,
|
|
575
|
+
shadowSpread: 12,
|
|
576
|
+
shadowOffsetY: 3,
|
|
577
|
+
}}
|
|
578
|
+
>
|
|
579
|
+
{scrollSnaps.map((_, index) => {
|
|
580
|
+
const isSelected = index === selectedIndex
|
|
581
|
+
|
|
582
|
+
return (
|
|
583
|
+
<button
|
|
584
|
+
key={index}
|
|
585
|
+
type="button"
|
|
586
|
+
role="tab"
|
|
587
|
+
aria-label={`Go to slide ${index + 1}`}
|
|
588
|
+
aria-selected={isSelected}
|
|
589
|
+
data-selected={isSelected ? "" : undefined}
|
|
590
|
+
className={cn(
|
|
591
|
+
"h-1.5 cursor-pointer rounded-full outline-none",
|
|
592
|
+
motion.all,
|
|
593
|
+
"focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background-primary",
|
|
594
|
+
isSelected
|
|
595
|
+
? "w-5 bg-fg-tertiary"
|
|
596
|
+
: "w-1.5 bg-fg-quaternary/50 hover:bg-fg-quaternary",
|
|
597
|
+
)}
|
|
598
|
+
onClick={() => scrollTo(index)}
|
|
599
|
+
/>
|
|
600
|
+
)
|
|
601
|
+
})}
|
|
602
|
+
</Glass>
|
|
603
|
+
</GlassRoot>
|
|
582
604
|
</div>
|
|
583
605
|
)
|
|
584
606
|
}
|
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,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Shared optical treatment for controls over images and video. */
|
|
2
|
+
export const mediaGlass = {
|
|
3
|
+
blurAmount: 0.08,
|
|
4
|
+
refraction: 0.12,
|
|
5
|
+
zRadius: 3,
|
|
6
|
+
chromAberration: 0,
|
|
7
|
+
brightness: -0.3,
|
|
8
|
+
edgeHighlight: 0.045,
|
|
9
|
+
specular: 0.02,
|
|
10
|
+
fresnel: 1,
|
|
11
|
+
shadowOpacity: 0.065,
|
|
12
|
+
shadowSpread: 12,
|
|
13
|
+
shadowOffsetY: 3,
|
|
14
|
+
}
|
package/src/modal.tsx
CHANGED
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
type ReactElement,
|
|
15
15
|
type ReactNode,
|
|
16
16
|
} from "react"
|
|
17
|
-
import {
|
|
17
|
+
import { buttonVariants } from "./button"
|
|
18
|
+
import { GlassButton, GlassRoot } from "./glass"
|
|
19
|
+
import { mediaGlass } from "./lib/media-glass"
|
|
18
20
|
import {
|
|
19
21
|
Carousel,
|
|
20
22
|
CarouselContent,
|
|
@@ -51,10 +53,9 @@ import {
|
|
|
51
53
|
const TRACKPAD_LABEL =
|
|
52
54
|
"Swipe, use the arrow keys, or use the controls to navigate"
|
|
53
55
|
|
|
54
|
-
//
|
|
55
|
-
// hover fill is a page grey and is cleared, or it shows through the body.
|
|
56
|
+
// Match the video controls while preserving the image viewer focus treatment.
|
|
56
57
|
const modalControlClassName =
|
|
57
|
-
"glass glass-
|
|
58
|
+
"glass-optical glass-optical-media text-fg-scrim hover:text-fg-scrim outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-surface-scrim focus-visible:ring-ring/20"
|
|
58
59
|
|
|
59
60
|
export const modalContentVariants = cva(
|
|
60
61
|
"h-dvh max-h-dvh w-screen max-w-none gap-0 overflow-hidden rounded-none border-0 bg-transparent p-0 shadow-none max-sm:max-w-none",
|
|
@@ -249,11 +250,14 @@ const ModalDownload = ({
|
|
|
249
250
|
<ModalControlTooltip label="Download image">
|
|
250
251
|
<a
|
|
251
252
|
href={src}
|
|
253
|
+
data-slot="glass"
|
|
254
|
+
data-glass-radius="css"
|
|
255
|
+
data-config={JSON.stringify({ ...mediaGlass, button: true })}
|
|
252
256
|
download={name ?? ""}
|
|
253
257
|
aria-label="Download image"
|
|
254
258
|
className={cn(
|
|
255
259
|
buttonVariants({
|
|
256
|
-
variant: "
|
|
260
|
+
variant: "glass",
|
|
257
261
|
size: "md",
|
|
258
262
|
rounded: true,
|
|
259
263
|
iconOnly: true,
|
|
@@ -273,9 +277,9 @@ const ModalDismiss = () => (
|
|
|
273
277
|
<ModalControlTooltip label="Close image">
|
|
274
278
|
<DialogClose
|
|
275
279
|
render={
|
|
276
|
-
<
|
|
280
|
+
<GlassButton
|
|
277
281
|
type="button"
|
|
278
|
-
|
|
282
|
+
config={mediaGlass}
|
|
279
283
|
size="md"
|
|
280
284
|
iconOnly
|
|
281
285
|
rounded
|
|
@@ -322,22 +326,24 @@ export const ModalContent = ({
|
|
|
322
326
|
{...props}
|
|
323
327
|
>
|
|
324
328
|
<TooltipProvider delay={250}>
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
329
|
+
<GlassRoot interactiveLighting defaults={mediaGlass} className="size-full">
|
|
330
|
+
<ModalBackground src={src} />
|
|
331
|
+
<DialogTitle className="sr-only">{alt}</DialogTitle>
|
|
332
|
+
<div
|
|
333
|
+
data-slot="modal-stage"
|
|
334
|
+
className="relative z-10 flex size-full flex-col items-center justify-center p-4"
|
|
335
|
+
>
|
|
336
|
+
<ModalImage src={src} alt={alt} imgProps={imgProps} />
|
|
337
|
+
{variant === "caption" && (caption || children) ? (
|
|
338
|
+
<ModalCaption>{caption ?? children}</ModalCaption>
|
|
339
|
+
) : null}
|
|
340
|
+
</div>
|
|
341
|
+
<ModalDismiss />
|
|
342
|
+
<ModalDownload
|
|
343
|
+
src={downloadSrc ?? src}
|
|
344
|
+
name={downloadName}
|
|
345
|
+
/>
|
|
346
|
+
</GlassRoot>
|
|
341
347
|
</TooltipProvider>
|
|
342
348
|
</DialogPopup>
|
|
343
349
|
</DialogPortal>
|
|
@@ -493,99 +499,101 @@ const GalleryContent = ({
|
|
|
493
499
|
)}
|
|
494
500
|
>
|
|
495
501
|
<TooltipProvider delay={250}>
|
|
496
|
-
<
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
<
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
<div className="flex min-h-0 w-full items-center justify-center
|
|
518
|
-
<
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
502
|
+
<GlassRoot interactiveLighting defaults={mediaGlass} className="size-full">
|
|
503
|
+
<ModalBackground src={activeImage.src} />
|
|
504
|
+
<DialogTitle className="sr-only">{activeImage.alt}</DialogTitle>
|
|
505
|
+
<p className="sr-only" aria-live="polite">
|
|
506
|
+
Image {activeIndex + 1} of {images.length}. {TRACKPAD_LABEL}.
|
|
507
|
+
</p>
|
|
508
|
+
<div className="relative z-10 flex size-full flex-col gap-4 p-4">
|
|
509
|
+
<Carousel
|
|
510
|
+
setApi={setApi}
|
|
511
|
+
opts={{ loop, startIndex: activeIndex, duration: 24, dragFree: false }}
|
|
512
|
+
data-slot="modal-stage"
|
|
513
|
+
className="relative min-h-0 w-full min-w-0 flex-1 [&_[data-slot=carousel-viewport]]:h-full [&_[data-slot=carousel-viewport]]:min-h-0"
|
|
514
|
+
aria-label="Gallery"
|
|
515
|
+
>
|
|
516
|
+
<CarouselContent className="h-full !ml-0 !pr-0">
|
|
517
|
+
{images.map((image, index) => (
|
|
518
|
+
<CarouselItem
|
|
519
|
+
key={`${image.src}-${index}`}
|
|
520
|
+
className="!basis-full flex h-full min-h-0 flex-col items-center justify-center !pl-0"
|
|
521
|
+
aria-label={`${index + 1} of ${images.length}`}
|
|
522
|
+
>
|
|
523
|
+
<div className="flex max-h-full min-h-0 w-full flex-col items-center justify-center">
|
|
524
|
+
<div className="flex min-h-0 w-full items-center justify-center overflow-hidden">
|
|
525
|
+
<ModalImage
|
|
526
|
+
{...image}
|
|
527
|
+
fit="contain"
|
|
528
|
+
imgProps={{
|
|
529
|
+
...image.imgProps,
|
|
530
|
+
className: cn(
|
|
531
|
+
"max-w-[min(94vw,72rem)]",
|
|
532
|
+
image.imgProps?.className,
|
|
533
|
+
),
|
|
534
|
+
}}
|
|
535
|
+
/>
|
|
536
|
+
</div>
|
|
537
|
+
{variant === "caption" && image.caption ? (
|
|
538
|
+
<ModalCaption>{image.caption}</ModalCaption>
|
|
539
|
+
) : null}
|
|
529
540
|
</div>
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
541
|
+
</CarouselItem>
|
|
542
|
+
))}
|
|
543
|
+
</CarouselContent>
|
|
544
|
+
</Carousel>
|
|
545
|
+
<GalleryFilmstrip
|
|
546
|
+
images={images}
|
|
547
|
+
activeIndex={activeIndex}
|
|
548
|
+
onSelect={handleSelect}
|
|
549
|
+
/>
|
|
550
|
+
</div>
|
|
551
|
+
<ModalDismiss />
|
|
552
|
+
<ModalDownload
|
|
553
|
+
src={activeImage.downloadSrc ?? activeImage.src}
|
|
554
|
+
name={activeImage.downloadName}
|
|
542
555
|
/>
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
<IconChevronRightSmall aria-hidden />
|
|
585
|
-
</Button>
|
|
586
|
-
</ModalControlTooltip>
|
|
587
|
-
</>
|
|
588
|
-
) : null}
|
|
556
|
+
{images.length > 1 ? (
|
|
557
|
+
<>
|
|
558
|
+
<ModalControlTooltip label="Previous image" side="right">
|
|
559
|
+
<GlassButton
|
|
560
|
+
type="button"
|
|
561
|
+
config={mediaGlass}
|
|
562
|
+
size="lg"
|
|
563
|
+
iconOnly
|
|
564
|
+
rounded
|
|
565
|
+
aria-label="Previous image"
|
|
566
|
+
disabled={!loop && !api?.canScrollPrev()}
|
|
567
|
+
onClick={() => api?.scrollPrev()}
|
|
568
|
+
className={cn(
|
|
569
|
+
modalControlClassName,
|
|
570
|
+
"absolute top-1/2 left-4 z-20 -translate-y-1/2 active:!-translate-y-1/2",
|
|
571
|
+
)}
|
|
572
|
+
>
|
|
573
|
+
<IconChevronRightSmall className="rotate-180" aria-hidden />
|
|
574
|
+
</GlassButton>
|
|
575
|
+
</ModalControlTooltip>
|
|
576
|
+
<ModalControlTooltip label="Next image" side="left">
|
|
577
|
+
<GlassButton
|
|
578
|
+
type="button"
|
|
579
|
+
config={mediaGlass}
|
|
580
|
+
size="lg"
|
|
581
|
+
iconOnly
|
|
582
|
+
rounded
|
|
583
|
+
aria-label="Next image"
|
|
584
|
+
disabled={!loop && !api?.canScrollNext()}
|
|
585
|
+
onClick={() => api?.scrollNext()}
|
|
586
|
+
className={cn(
|
|
587
|
+
modalControlClassName,
|
|
588
|
+
"absolute top-1/2 right-4 z-20 -translate-y-1/2 active:!-translate-y-1/2",
|
|
589
|
+
)}
|
|
590
|
+
>
|
|
591
|
+
<IconChevronRightSmall aria-hidden />
|
|
592
|
+
</GlassButton>
|
|
593
|
+
</ModalControlTooltip>
|
|
594
|
+
</>
|
|
595
|
+
) : null}
|
|
596
|
+
</GlassRoot>
|
|
589
597
|
</TooltipProvider>
|
|
590
598
|
</DialogPopup>
|
|
591
599
|
</DialogPortal>
|
package/src/video-player.tsx
CHANGED
|
@@ -17,13 +17,15 @@ 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"
|
|
21
|
+
import { mediaGlass } from "./lib/media-glass"
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Picture-in-Picture support is a static browser capability, not React state.
|
|
23
25
|
* Reading it through an external store keeps the server snapshot `false` so
|
|
24
26
|
* hydration matches, without a post-mount setState that re-renders the player.
|
|
25
27
|
*/
|
|
26
|
-
const subscribePipSupport = () => () => {}
|
|
28
|
+
const subscribePipSupport = () => () => { }
|
|
27
29
|
const getPipSupported = () => document.pictureInPictureEnabled
|
|
28
30
|
const getPipSupportedOnServer = () => false
|
|
29
31
|
|
|
@@ -34,26 +36,8 @@ const formatTime = (seconds: number) => {
|
|
|
34
36
|
return `${mins}:${secs.toString().padStart(2, "0")}`
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
//
|
|
38
|
-
|
|
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"
|
|
39
|
+
// All panes are direct children of the video root, sharing one WebGL context.
|
|
40
|
+
|
|
57
41
|
|
|
58
42
|
export type VideoPlayerProps = ComponentProps<"div"> & {
|
|
59
43
|
src: string
|
|
@@ -279,8 +263,17 @@ export const VideoPlayer = ({
|
|
|
279
263
|
const loaded = duration > 0 ? (buffered / duration) * 100 : 0
|
|
280
264
|
const remaining = Math.max(duration - current, 0)
|
|
281
265
|
|
|
266
|
+
const controlVisibility = cn(
|
|
267
|
+
"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",
|
|
268
|
+
"group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto",
|
|
269
|
+
"[@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto",
|
|
270
|
+
playing ? "pointer-events-none opacity-0" : "opacity-100",
|
|
271
|
+
)
|
|
272
|
+
|
|
282
273
|
return (
|
|
283
|
-
<
|
|
274
|
+
<GlassRoot
|
|
275
|
+
interactiveLighting
|
|
276
|
+
defaults={mediaGlass}
|
|
284
277
|
ref={playerRef}
|
|
285
278
|
data-slot="video-player"
|
|
286
279
|
data-playing={playing || undefined}
|
|
@@ -313,216 +306,196 @@ export const VideoPlayer = ({
|
|
|
313
306
|
onClick={handleTogglePlay}
|
|
314
307
|
/>
|
|
315
308
|
|
|
316
|
-
{/*
|
|
317
|
-
|
|
309
|
+
{/* The status pane stays mounted so startup owns a stable set of panes.
|
|
310
|
+
It remains inert; clicking the video still toggles playback. */}
|
|
311
|
+
<Glass
|
|
312
|
+
data-glass-radius="css"
|
|
313
|
+
className={cn(
|
|
314
|
+
"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",
|
|
315
|
+
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",
|
|
316
|
+
playing && !buffering && !loading && !failed && "invisible",
|
|
317
|
+
)}
|
|
318
|
+
>
|
|
318
319
|
{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
|
-
/>
|
|
320
|
+
<div role="alert" className="flex flex-col items-center gap-2">
|
|
321
|
+
<IconExclamationTriangle size={22} mode="raw" aria-hidden />
|
|
329
322
|
<span className="text-sm-strong">Video unavailable</span>
|
|
330
|
-
<span className="text-xs text-
|
|
331
|
-
Check the source and try again.
|
|
332
|
-
</span>
|
|
323
|
+
<span className="text-xs text-fg-scrim-secondary">Check the source and try again.</span>
|
|
333
324
|
</div>
|
|
334
325
|
) : 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>
|
|
326
|
+
<div role="status">
|
|
327
|
+
<span className="block size-6 animate-spin rounded-full border-2 border-fg-scrim/30 border-t-fg-scrim motion-reduce:animate-none" />
|
|
328
|
+
<span className="sr-only">{buffering ? "Buffering video" : "Loading video"}</span>
|
|
343
329
|
</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
|
-
/>
|
|
330
|
+
) : (
|
|
331
|
+
<>
|
|
332
|
+
<IconPlay size={ended ? 12 : 18} mode="raw" className={ended ? "size-3" : "size-4.5"} aria-hidden />
|
|
360
333
|
{ended ? <span className="text-xs-strong">Replay</span> : null}
|
|
361
|
-
|
|
362
|
-
)
|
|
363
|
-
</
|
|
364
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
334
|
+
</>
|
|
335
|
+
)}
|
|
336
|
+
</Glass>
|
|
337
|
+
|
|
338
|
+
<GlassButton
|
|
339
|
+
type="button"
|
|
340
|
+
rounded
|
|
341
|
+
iconOnly
|
|
342
|
+
config={mediaGlass}
|
|
343
|
+
className={cn(controlVisibility, "left-2 group-data-[fullscreen]:left-5", (failed || loading) && "invisible")}
|
|
344
|
+
aria-label={ended ? "Replay" : playing ? "Pause" : "Play"}
|
|
345
|
+
onClick={handleTogglePlay}
|
|
346
|
+
>
|
|
347
|
+
{playing ? (
|
|
348
|
+
<IconPause size={14} mode="raw" className="size-3.5" aria-hidden />
|
|
349
|
+
) : (
|
|
350
|
+
<IconPlay size={14} mode="raw" className="size-3.5" aria-hidden />
|
|
351
|
+
)}
|
|
352
|
+
</GlassButton>
|
|
353
|
+
<GlassButton
|
|
354
|
+
type="button"
|
|
355
|
+
rounded
|
|
356
|
+
iconOnly
|
|
357
|
+
config={mediaGlass}
|
|
358
|
+
className={cn(controlVisibility, "left-13 group-data-[fullscreen]:left-16", (failed || loading) && "invisible")}
|
|
359
|
+
aria-label={muted ? "Unmute" : "Mute"}
|
|
360
|
+
onClick={handleToggleMute}
|
|
361
|
+
>
|
|
362
|
+
{muted ? (
|
|
363
|
+
<IconVolumeOff
|
|
364
|
+
size={14}
|
|
365
|
+
mode="raw"
|
|
366
|
+
className="size-3.5"
|
|
367
|
+
aria-hidden
|
|
368
|
+
/>
|
|
369
|
+
) : (
|
|
370
|
+
<IconVolumeFull
|
|
371
|
+
size={14}
|
|
372
|
+
mode="raw"
|
|
373
|
+
className="size-3.5"
|
|
374
|
+
aria-hidden
|
|
375
|
+
/>
|
|
376
|
+
)}
|
|
377
|
+
</GlassButton>
|
|
378
|
+
<Glass
|
|
379
|
+
data-glass-radius="css"
|
|
380
|
+
className={cn(
|
|
381
|
+
controlVisibility,
|
|
382
|
+
"left-24 flex h-9 min-w-0 items-center gap-2 rounded-full px-3 group-data-[fullscreen]:left-27",
|
|
383
|
+
pipSupported ? "right-24 group-data-[fullscreen]:right-27" : "right-13 group-data-[fullscreen]:right-16",
|
|
384
|
+
(failed || loading) && "invisible",
|
|
385
|
+
)}
|
|
386
|
+
>
|
|
387
|
+
<span className="text-xs min-w-9 shrink-0 tabular-nums text-white/85">
|
|
388
|
+
{formatTime(current)}
|
|
389
|
+
</span>
|
|
390
|
+
|
|
391
|
+
<label className="sr-only" htmlFor={seekId}>
|
|
392
|
+
Seek
|
|
393
|
+
</label>
|
|
394
|
+
<div className="group/seek relative flex h-5 min-w-0 flex-1 items-center">
|
|
395
|
+
<span
|
|
396
|
+
ref={seekPreviewRef}
|
|
397
|
+
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"
|
|
398
|
+
/>
|
|
399
|
+
{/* Track, buffered fill and played fill sit under the input, which
|
|
430
400
|
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
|
-
</div>
|
|
484
|
-
|
|
485
|
-
<span className="text-xs min-w-10 shrink-0 text-right tabular-nums text-white/85">
|
|
486
|
-
-{formatTime(remaining)}
|
|
487
|
-
</span>
|
|
488
|
-
</div>
|
|
489
|
-
|
|
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>
|
|
401
|
+
<span
|
|
402
|
+
aria-hidden
|
|
403
|
+
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"
|
|
404
|
+
/>
|
|
405
|
+
<span
|
|
406
|
+
aria-hidden
|
|
407
|
+
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"
|
|
408
|
+
style={{ width: `${loaded}%` }}
|
|
409
|
+
/>
|
|
410
|
+
<span
|
|
411
|
+
aria-hidden
|
|
412
|
+
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"
|
|
413
|
+
style={{ width: `${played}%` }}
|
|
414
|
+
/>
|
|
415
|
+
<input
|
|
416
|
+
id={seekId}
|
|
417
|
+
type="range"
|
|
418
|
+
min={0}
|
|
419
|
+
max={duration || 0}
|
|
420
|
+
step={0.01}
|
|
421
|
+
value={current}
|
|
422
|
+
onChange={(event) => {
|
|
423
|
+
const value = Number(event.target.value)
|
|
424
|
+
handleSeek(value)
|
|
425
|
+
showSeekPreview(value)
|
|
426
|
+
}}
|
|
427
|
+
onPointerMove={(event) => {
|
|
428
|
+
if (duration <= 0) return
|
|
429
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
430
|
+
const ratio = Math.min(
|
|
431
|
+
Math.max((event.clientX - rect.left) / rect.width, 0),
|
|
432
|
+
1,
|
|
433
|
+
)
|
|
434
|
+
showSeekPreview(ratio * duration)
|
|
435
|
+
}}
|
|
436
|
+
onPointerLeave={(event) => {
|
|
437
|
+
if (document.activeElement !== event.currentTarget) {
|
|
438
|
+
hideSeekPreview()
|
|
439
|
+
}
|
|
440
|
+
}}
|
|
441
|
+
onFocus={() => showSeekPreview(current)}
|
|
442
|
+
onBlur={hideSeekPreview}
|
|
443
|
+
aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
|
|
444
|
+
className={cn(
|
|
445
|
+
"relative m-0 h-5 w-full cursor-pointer appearance-none bg-transparent outline-none",
|
|
446
|
+
// Both vendor thumbs, or Firefox falls back to a default one.
|
|
447
|
+
"[&::-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",
|
|
448
|
+
"[&::-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",
|
|
449
|
+
"focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-white/70",
|
|
450
|
+
"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",
|
|
451
|
+
)}
|
|
452
|
+
/>
|
|
524
453
|
</div>
|
|
525
|
-
|
|
526
|
-
|
|
454
|
+
|
|
455
|
+
<span className="text-xs min-w-10 shrink-0 text-right tabular-nums text-white/85">
|
|
456
|
+
-{formatTime(remaining)}
|
|
457
|
+
</span>
|
|
458
|
+
</Glass>
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
<GlassButton
|
|
462
|
+
type="button"
|
|
463
|
+
rounded
|
|
464
|
+
iconOnly
|
|
465
|
+
config={mediaGlass}
|
|
466
|
+
className={cn(controlVisibility, "right-13 group-data-[fullscreen]:right-16", (!pipSupported || failed || loading) && "invisible")}
|
|
467
|
+
aria-label={
|
|
468
|
+
pipActive
|
|
469
|
+
? "Exit picture in picture"
|
|
470
|
+
: "Picture in picture"
|
|
471
|
+
}
|
|
472
|
+
aria-pressed={pipActive}
|
|
473
|
+
onClick={handlePictureInPicture}
|
|
474
|
+
>
|
|
475
|
+
<IconPictureInPicture
|
|
476
|
+
size={14}
|
|
477
|
+
mode="raw"
|
|
478
|
+
className="size-3.5"
|
|
479
|
+
aria-hidden
|
|
480
|
+
/>
|
|
481
|
+
</GlassButton>
|
|
482
|
+
<GlassButton
|
|
483
|
+
type="button"
|
|
484
|
+
rounded
|
|
485
|
+
iconOnly
|
|
486
|
+
config={mediaGlass}
|
|
487
|
+
className={cn(controlVisibility, "right-2 group-data-[fullscreen]:right-5", (failed || loading) && "invisible")}
|
|
488
|
+
aria-label={fullscreen ? "Exit fullscreen" : "Fullscreen"}
|
|
489
|
+
aria-pressed={fullscreen}
|
|
490
|
+
onClick={handleFullscreen}
|
|
491
|
+
>
|
|
492
|
+
<IconFullScreen
|
|
493
|
+
size={14}
|
|
494
|
+
mode="raw"
|
|
495
|
+
className="size-3.5"
|
|
496
|
+
aria-hidden
|
|
497
|
+
/>
|
|
498
|
+
</GlassButton>
|
|
499
|
+
</GlassRoot>
|
|
527
500
|
)
|
|
528
501
|
}
|