@boyernick/standard-ui-react 0.4.2-canary.62 → 0.4.2-canary.63
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/lib/lens-glass/renderer.ts +127 -38
- package/src/video-player.tsx +33 -0
package/package.json
CHANGED
|
@@ -211,6 +211,13 @@ type Pane = {
|
|
|
211
211
|
element: HTMLElement;
|
|
212
212
|
canvas: HTMLCanvasElement;
|
|
213
213
|
context: CanvasRenderingContext2D;
|
|
214
|
+
/** This pane's own ground canvas, kept between frames rather than resized. */
|
|
215
|
+
scene: HTMLCanvasElement;
|
|
216
|
+
sceneContext: CanvasRenderingContext2D;
|
|
217
|
+
/** When a playing video behind it last redrew this pane. */
|
|
218
|
+
lastVideoFrame: number;
|
|
219
|
+
/** Whether the pane was drawable last time it was checked. */
|
|
220
|
+
shown: boolean;
|
|
214
221
|
restorePosition: string | null;
|
|
215
222
|
hover: number;
|
|
216
223
|
press: number;
|
|
@@ -234,11 +241,6 @@ export interface LensGlass {
|
|
|
234
241
|
export function createLensGlass({ root, glassElements, defaults = {} }: LensGlassOptions): LensGlass {
|
|
235
242
|
getShared(); // throws if WebGL is unavailable, so the root can fall back
|
|
236
243
|
|
|
237
|
-
const scene = document.createElement("canvas");
|
|
238
|
-
const maybeSceneContext = scene.getContext("2d");
|
|
239
|
-
if (!maybeSceneContext) throw new Error("Glass: 2D canvas is unavailable.");
|
|
240
|
-
const sceneContext: CanvasRenderingContext2D = maybeSceneContext;
|
|
241
|
-
|
|
242
244
|
// If anything below throws, take back what was already added to the DOM so
|
|
243
245
|
// a failed start leaves the panes as they were.
|
|
244
246
|
const injected: Array<{ canvas: HTMLCanvasElement; element: HTMLElement; position: string | null }> = [];
|
|
@@ -264,6 +266,9 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
264
266
|
injected.push(record);
|
|
265
267
|
const context = canvas.getContext("2d");
|
|
266
268
|
if (!context) throw new Error("Glass: 2D canvas is unavailable.");
|
|
269
|
+
const scene = document.createElement("canvas");
|
|
270
|
+
const sceneContext = scene.getContext("2d");
|
|
271
|
+
if (!sceneContext) throw new Error("Glass: 2D canvas is unavailable.");
|
|
267
272
|
|
|
268
273
|
let restorePosition: string | null = null;
|
|
269
274
|
if (getComputedStyle(element).position === "static") {
|
|
@@ -273,7 +278,7 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
273
278
|
}
|
|
274
279
|
|
|
275
280
|
const pane: Pane = {
|
|
276
|
-
element, canvas, context, restorePosition,
|
|
281
|
+
element, canvas, context, scene, sceneContext, lastVideoFrame: 0, shown: false, restorePosition,
|
|
277
282
|
hover: 0, press: 0, hoverTarget: 0, pressTarget: 0, signature: "", cleanup: () => {},
|
|
278
283
|
};
|
|
279
284
|
const enter = () => { pane.hoverTarget = 1; wake(); };
|
|
@@ -332,10 +337,19 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
332
337
|
return getComputedStyle(document.documentElement).backgroundColor || "#000";
|
|
333
338
|
};
|
|
334
339
|
|
|
340
|
+
// `forced`: something may have changed, so check every pane this frame.
|
|
341
|
+
// `redraw`: draw every pane even if nothing it depends on looks different
|
|
342
|
+
// (markChanged, a restored WebGL context).
|
|
335
343
|
let forced = true;
|
|
344
|
+
let redraw = true;
|
|
336
345
|
let visible = true;
|
|
337
346
|
let frame = 0;
|
|
338
347
|
let destroyed = false;
|
|
348
|
+
// Frames since anything last moved or changed; the loop stops a few frames
|
|
349
|
+
// after the page settles and waits for an event to wake it.
|
|
350
|
+
let quietFrames = 0;
|
|
351
|
+
let lastLayout = "";
|
|
352
|
+
let transitions = 0;
|
|
339
353
|
|
|
340
354
|
const intersection = new IntersectionObserver((entries) => {
|
|
341
355
|
visible = entries.some((entry) => entry.isIntersecting);
|
|
@@ -345,57 +359,118 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
345
359
|
|
|
346
360
|
const mutations = new MutationObserver((records) => {
|
|
347
361
|
// Our own canvases change on every frame we draw; they are not the ground.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
)
|
|
362
|
+
// A pane's own contents (a progress bar, a label) are drawn over the glass,
|
|
363
|
+
// not refracted by it; only its configuration matters.
|
|
364
|
+
const relevant = records.filter((record) => {
|
|
365
|
+
const target = record.target as HTMLElement;
|
|
366
|
+
if (target.dataset?.glassCanvas !== undefined) return false;
|
|
367
|
+
if (record.attributeName === "data-config") return true;
|
|
368
|
+
return !(target instanceof Element && !paneSet.has(target as HTMLElement) && insidePane(target));
|
|
369
|
+
});
|
|
351
370
|
if (!relevant.length) return;
|
|
352
|
-
// Inline style changes (a carousel's transform while dragging) move
|
|
353
|
-
// things, which the
|
|
354
|
-
//
|
|
371
|
+
// Inline style changes (a carousel's transform while dragging) only move
|
|
372
|
+
// things, which the position check sees; a change in what exists or how
|
|
373
|
+
// it is styled means re-listing what paints.
|
|
355
374
|
if (relevant.some((record) => record.type === "childList" || record.attributeName !== "style")) {
|
|
356
375
|
collectMedia();
|
|
357
376
|
}
|
|
377
|
+
// An inline style can change opacity or clipping without moving anything.
|
|
358
378
|
forced = true;
|
|
359
379
|
wake();
|
|
360
380
|
});
|
|
361
381
|
mutations.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["src", "data-config", "class", "style"] });
|
|
362
382
|
|
|
363
|
-
//
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
383
|
+
// Everything else that can change what a pane shows arrives as an event.
|
|
384
|
+
const onChange = () => { forced = true; wake(); };
|
|
385
|
+
const onMove = () => wake();
|
|
386
|
+
const onTransitionStart = () => { transitions += 1; forced = true; wake(); };
|
|
387
|
+
const onTransitionEnd = () => { transitions = Math.max(0, transitions - 1); forced = true; wake(); };
|
|
388
|
+
const rootEvents: Array<[string, EventListener]> = [
|
|
389
|
+
["load", onChange], ["loadeddata", onChange], ["seeked", onChange], ["play", onChange],
|
|
390
|
+
["pause", onChange], ["ended", onChange],
|
|
391
|
+
["transitionrun", onTransitionStart], ["transitionend", onTransitionEnd], ["transitioncancel", onTransitionEnd],
|
|
392
|
+
["animationstart", onTransitionStart], ["animationend", onTransitionEnd], ["animationcancel", onTransitionEnd],
|
|
393
|
+
["pointerover", onChange], ["pointerout", onChange], ["pointermove", onMove], ["pointerdown", onMove],
|
|
394
|
+
];
|
|
395
|
+
for (const [type, listener] of rootEvents) root.addEventListener(type, listener, true);
|
|
396
|
+
window.addEventListener("resize", onChange);
|
|
397
|
+
window.addEventListener("scroll", onMove, { capture: true, passive: true });
|
|
369
398
|
// A root that started in a background tab draws when the tab is shown.
|
|
370
|
-
document.addEventListener("visibilitychange",
|
|
399
|
+
document.addEventListener("visibilitychange", onChange);
|
|
371
400
|
|
|
372
401
|
function wake() {
|
|
402
|
+
quietFrames = 0;
|
|
373
403
|
if (!frame && !destroyed) frame = requestAnimationFrame(tick);
|
|
374
404
|
}
|
|
375
405
|
|
|
376
|
-
|
|
406
|
+
const rectKey = (element: Element) => {
|
|
407
|
+
const r = element.getBoundingClientRect();
|
|
408
|
+
return `${r.x.toFixed(1)},${r.y.toFixed(1)},${r.width.toFixed(1)},${r.height.toFixed(1)}`;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/** Whether a pane can be seen at all: not faded out, hidden or clipped away. */
|
|
412
|
+
function paneShown(pane: Pane) {
|
|
413
|
+
const seen = visibility(pane.element, root);
|
|
414
|
+
if (!seen || seen.opacity < 0.02) return false;
|
|
415
|
+
return Boolean(intersect(seen.clip, { x: 0, y: 0, width: innerWidth, height: innerHeight }));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function tick(now: number) {
|
|
377
419
|
frame = 0;
|
|
378
420
|
// Hidden tabs get no animation frames anyway; no need to check here.
|
|
379
421
|
if (destroyed || !visible) return;
|
|
380
|
-
|
|
381
|
-
|
|
422
|
+
|
|
423
|
+
// Cheap: where the panes and media are. Anything else that matters
|
|
424
|
+
// arrives as an event and sets `forced`.
|
|
425
|
+
const layout = panes.map((pane) => rectKey(pane.element)).join(";") + "|" + media.map(rectKey).join(";");
|
|
426
|
+
const moved = layout !== lastLayout || transitions > 0;
|
|
427
|
+
lastLayout = layout;
|
|
428
|
+
const check = forced || moved;
|
|
429
|
+
|
|
430
|
+
const ground = check ? groundColor() : "";
|
|
382
431
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
383
|
-
const playing = media.
|
|
384
|
-
(element)
|
|
432
|
+
const playing = media.filter(
|
|
433
|
+
(element): element is HTMLVideoElement =>
|
|
434
|
+
element instanceof HTMLVideoElement && !element.paused && !element.ended,
|
|
385
435
|
);
|
|
436
|
+
let animating = false;
|
|
437
|
+
let videoBehindShownPane = false;
|
|
386
438
|
|
|
387
439
|
for (const pane of panes) {
|
|
388
440
|
pane.hover += (pane.hoverTarget - pane.hover) * 0.25;
|
|
389
441
|
pane.press += (pane.pressTarget - pane.press) * 0.35;
|
|
390
442
|
if (Math.abs(pane.hoverTarget - pane.hover) < 0.01) pane.hover = pane.hoverTarget;
|
|
391
443
|
if (Math.abs(pane.pressTarget - pane.press) < 0.01) pane.press = pane.pressTarget;
|
|
392
|
-
|
|
393
|
-
|
|
444
|
+
const paneAnimating = pane.hover !== pane.hoverTarget || pane.press !== pane.pressTarget;
|
|
445
|
+
if (paneAnimating) animating = true;
|
|
446
|
+
|
|
447
|
+
if (check || paneAnimating) pane.shown = paneShown(pane);
|
|
448
|
+
if (!pane.shown) {
|
|
449
|
+
// Draw afresh when it next appears.
|
|
450
|
+
pane.signature = "";
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// A playing video behind a shown pane changes every frame; follow it
|
|
455
|
+
// at up to 30fps.
|
|
456
|
+
const paneBox = toBox(pane.element.getBoundingClientRect());
|
|
457
|
+
const overVideo = playing.some((video) => intersect(toBox(video.getBoundingClientRect()), paneBox));
|
|
458
|
+
let videoFrame = false;
|
|
459
|
+
if (overVideo) {
|
|
460
|
+
videoBehindShownPane = true;
|
|
461
|
+
if (now - pane.lastVideoFrame >= 33) {
|
|
462
|
+
pane.lastVideoFrame = now;
|
|
463
|
+
videoFrame = true;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (!check && !paneAnimating && !videoFrame) continue;
|
|
468
|
+
renderPane(pane, ground || groundColor(), dpr, redraw);
|
|
394
469
|
}
|
|
395
470
|
forced = false;
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (animating ||
|
|
471
|
+
redraw = false;
|
|
472
|
+
quietFrames = moved || animating ? 0 : quietFrames + 1;
|
|
473
|
+
if (animating || videoBehindShownPane || quietFrames < 12) frame = requestAnimationFrame(tick);
|
|
399
474
|
}
|
|
400
475
|
|
|
401
476
|
function renderPane(pane: Pane, ground: string, dpr: number, force: boolean) {
|
|
@@ -439,14 +514,20 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
439
514
|
});
|
|
440
515
|
}
|
|
441
516
|
|
|
517
|
+
// Relative to the pane: a pane that moves together with what is behind it
|
|
518
|
+
// (a video player's controls as its slide pages) shows the same thing, and
|
|
519
|
+
// its canvas moves with it, so it needs no redraw.
|
|
520
|
+
const ox = rect.left;
|
|
521
|
+
const oy = rect.top;
|
|
522
|
+
const px = (value: number) => value.toFixed(1);
|
|
442
523
|
const signature = [
|
|
443
|
-
|
|
524
|
+
px(region.width), px(region.height),
|
|
444
525
|
dpr, ground, element.dataset.config ?? "", pane.hover.toFixed(2), pane.press.toFixed(2),
|
|
445
526
|
...drawn.map(({ element: item, clip, opacity, filter, color }) => {
|
|
446
527
|
const r = item.getBoundingClientRect();
|
|
447
528
|
const frameKey = item instanceof HTMLVideoElement ? item.currentTime.toFixed(3)
|
|
448
529
|
: item instanceof HTMLImageElement ? item.currentSrc : "";
|
|
449
|
-
return `${r.x
|
|
530
|
+
return `${px(r.x - ox)},${px(r.y - oy)},${px(r.width)},${px(r.height)}|${px(clip.x - ox)},${px(clip.y - oy)},${px(clip.width)},${px(clip.height)}|${opacity.toFixed(2)}|${filter}|${color ?? ""}|${frameKey}`;
|
|
450
531
|
}),
|
|
451
532
|
].join(";");
|
|
452
533
|
if (!force && signature === pane.signature) return;
|
|
@@ -457,8 +538,16 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
457
538
|
if (region.width * region.height * scale * scale > MAX_SCENE_PIXELS) {
|
|
458
539
|
scale = Math.sqrt(MAX_SCENE_PIXELS / (region.width * region.height));
|
|
459
540
|
}
|
|
460
|
-
|
|
461
|
-
|
|
541
|
+
const { scene, sceneContext } = pane;
|
|
542
|
+
const sceneWidth = Math.max(1, Math.round(region.width * scale));
|
|
543
|
+
const sceneHeight = Math.max(1, Math.round(region.height * scale));
|
|
544
|
+
// Resizing a canvas reallocates it; only do so when the size changes.
|
|
545
|
+
if (scene.width !== sceneWidth || scene.height !== sceneHeight) {
|
|
546
|
+
scene.width = sceneWidth;
|
|
547
|
+
scene.height = sceneHeight;
|
|
548
|
+
}
|
|
549
|
+
sceneContext.setTransform(1, 0, 0, 1, 0, 0);
|
|
550
|
+
sceneContext.clearRect(0, 0, sceneWidth, sceneHeight);
|
|
462
551
|
sceneContext.setTransform(scale, 0, 0, scale, -region.x * scale, -region.y * scale);
|
|
463
552
|
sceneContext.globalAlpha = 1;
|
|
464
553
|
sceneContext.fillStyle = ground;
|
|
@@ -577,6 +666,7 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
577
666
|
const instance: LensGlass = {
|
|
578
667
|
markChanged() {
|
|
579
668
|
forced = true;
|
|
669
|
+
redraw = true;
|
|
580
670
|
wake();
|
|
581
671
|
},
|
|
582
672
|
destroy() {
|
|
@@ -584,11 +674,10 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
584
674
|
if (frame) cancelAnimationFrame(frame);
|
|
585
675
|
intersection.disconnect();
|
|
586
676
|
mutations.disconnect();
|
|
587
|
-
root.removeEventListener(
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
document.removeEventListener("visibilitychange", onMediaEvent);
|
|
677
|
+
for (const [type, listener] of rootEvents) root.removeEventListener(type, listener, true);
|
|
678
|
+
window.removeEventListener("resize", onChange);
|
|
679
|
+
window.removeEventListener("scroll", onMove, { capture: true });
|
|
680
|
+
document.removeEventListener("visibilitychange", onChange);
|
|
592
681
|
for (const pane of panes) {
|
|
593
682
|
pane.cleanup();
|
|
594
683
|
pane.canvas.remove();
|
package/src/video-player.tsx
CHANGED
|
@@ -45,6 +45,15 @@ export type VideoPlayerProps = ComponentProps<"div"> & {
|
|
|
45
45
|
title?: string
|
|
46
46
|
/** Accessible name for the video. Defaults to title. */
|
|
47
47
|
"aria-label"?: string
|
|
48
|
+
/**
|
|
49
|
+
* CORS mode for the video. The glass controls can only refract frames the
|
|
50
|
+
* page may read, which needs the host to allow cross-origin reads. By
|
|
51
|
+
* default the player asks with `anonymous` and, if the host refuses and the
|
|
52
|
+
* video fails to load, loads it once more without CORS — the video plays
|
|
53
|
+
* either way, and the controls fall back to the ground colour. Pass a mode
|
|
54
|
+
* to fix it, or `null` never to ask.
|
|
55
|
+
*/
|
|
56
|
+
crossOrigin?: "anonymous" | "use-credentials" | null
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
export const VideoPlayer = ({
|
|
@@ -53,6 +62,7 @@ export const VideoPlayer = ({
|
|
|
53
62
|
title,
|
|
54
63
|
className,
|
|
55
64
|
"aria-label": ariaLabel,
|
|
65
|
+
crossOrigin,
|
|
56
66
|
...props
|
|
57
67
|
}: VideoPlayerProps) => {
|
|
58
68
|
const playerRef = useRef<HTMLDivElement>(null)
|
|
@@ -65,6 +75,13 @@ export const VideoPlayer = ({
|
|
|
65
75
|
const [buffering, setBuffering] = useState(false)
|
|
66
76
|
const [ended, setEnded] = useState(false)
|
|
67
77
|
const [failed, setFailed] = useState(false)
|
|
78
|
+
// Automatic CORS: ask first, fall back once if the host refuses. Kept per
|
|
79
|
+
// source, so a new src gets a fresh attempt.
|
|
80
|
+
const automaticCors = crossOrigin === undefined
|
|
81
|
+
const [corsRefusedSrc, setCorsRefusedSrc] = useState<string | null>(null)
|
|
82
|
+
const videoCrossOrigin = automaticCors
|
|
83
|
+
? corsRefusedSrc === src ? undefined : "anonymous"
|
|
84
|
+
: crossOrigin ?? undefined
|
|
68
85
|
const [fullscreen, setFullscreen] = useState(false)
|
|
69
86
|
const pipSupported = useSyncExternalStore(
|
|
70
87
|
subscribePipSupport,
|
|
@@ -181,6 +198,11 @@ export const VideoPlayer = ({
|
|
|
181
198
|
setEnded(true)
|
|
182
199
|
}
|
|
183
200
|
const handleError = () => {
|
|
201
|
+
// Refused with CORS: try once more without it, rather than fail.
|
|
202
|
+
if (video.dataset.cors === "auto" && video.crossOrigin) {
|
|
203
|
+
setCorsRefusedSrc(video.getAttribute("src"))
|
|
204
|
+
return
|
|
205
|
+
}
|
|
184
206
|
setPlaying(false)
|
|
185
207
|
setLoading(false)
|
|
186
208
|
setBuffering(false)
|
|
@@ -233,6 +255,15 @@ export const VideoPlayer = ({
|
|
|
233
255
|
}
|
|
234
256
|
}, [])
|
|
235
257
|
|
|
258
|
+
// Changing the crossorigin attribute alone does not refetch; reload so the
|
|
259
|
+
// fallback takes effect.
|
|
260
|
+
const appliedCrossOrigin = useRef(videoCrossOrigin)
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (appliedCrossOrigin.current === videoCrossOrigin) return
|
|
263
|
+
appliedCrossOrigin.current = videoCrossOrigin
|
|
264
|
+
videoRef.current?.load()
|
|
265
|
+
}, [videoCrossOrigin])
|
|
266
|
+
|
|
236
267
|
useEffect(() => {
|
|
237
268
|
const handleFullscreenChange = () => {
|
|
238
269
|
setFullscreen(document.fullscreenElement === playerRef.current)
|
|
@@ -299,6 +330,8 @@ export const VideoPlayer = ({
|
|
|
299
330
|
ref={videoRef}
|
|
300
331
|
src={src}
|
|
301
332
|
poster={poster}
|
|
333
|
+
crossOrigin={videoCrossOrigin}
|
|
334
|
+
data-cors={automaticCors ? "auto" : undefined}
|
|
302
335
|
preload="auto"
|
|
303
336
|
playsInline
|
|
304
337
|
className="aspect-video w-full cursor-pointer object-cover group-data-[fullscreen]:h-full group-data-[fullscreen]:w-full group-data-[fullscreen]:aspect-auto group-data-[fullscreen]:object-cover"
|