@boyernick/standard-ui-react 0.4.2-canary.62 → 0.4.2-canary.64
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 +244 -52
- package/src/video-player.tsx +38 -1
package/package.json
CHANGED
|
@@ -114,6 +114,23 @@ const instances = new Set<{ markChanged(): void }>();
|
|
|
114
114
|
// Reading what is behind a pane
|
|
115
115
|
// ────────────────────────────────────────────────────────────────
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Layout does not change within a frame (the renderer only moves its own
|
|
119
|
+
* absolutely positioned canvases), so each element is measured once per frame
|
|
120
|
+
* however many panes look at it.
|
|
121
|
+
*/
|
|
122
|
+
let frameRects: Map<Element, DOMRect> | null = null;
|
|
123
|
+
|
|
124
|
+
function rectOf(element: Element) {
|
|
125
|
+
if (!frameRects) return element.getBoundingClientRect();
|
|
126
|
+
let rect = frameRects.get(element);
|
|
127
|
+
if (!rect) {
|
|
128
|
+
rect = element.getBoundingClientRect();
|
|
129
|
+
frameRects.set(element, rect);
|
|
130
|
+
}
|
|
131
|
+
return rect;
|
|
132
|
+
}
|
|
133
|
+
|
|
117
134
|
const toBox = (rect: DOMRect): Box => ({
|
|
118
135
|
x: rect.left,
|
|
119
136
|
y: rect.top,
|
|
@@ -127,15 +144,24 @@ const transparent = (color: string) =>
|
|
|
127
144
|
/** Media whose pixels a canvas cannot read without tainting it. */
|
|
128
145
|
const tainted = new WeakSet<Element>();
|
|
129
146
|
|
|
147
|
+
const originChecks = new WeakMap<Element, { source: string; cross: boolean }>();
|
|
148
|
+
|
|
130
149
|
function crossOrigin(element: HTMLImageElement | HTMLVideoElement) {
|
|
131
150
|
if (element.crossOrigin) return false;
|
|
132
151
|
const source = element.currentSrc || element.getAttribute("src") || "";
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
152
|
+
const known = originChecks.get(element);
|
|
153
|
+
if (known && known.source === source) return known.cross;
|
|
154
|
+
let cross: boolean;
|
|
155
|
+
if (!source || source.startsWith("data:") || source.startsWith("blob:")) cross = false;
|
|
156
|
+
else {
|
|
157
|
+
try {
|
|
158
|
+
cross = new URL(source, location.href).origin !== location.origin;
|
|
159
|
+
} catch {
|
|
160
|
+
cross = true;
|
|
161
|
+
}
|
|
138
162
|
}
|
|
163
|
+
originChecks.set(element, { source, cross });
|
|
164
|
+
return cross;
|
|
139
165
|
}
|
|
140
166
|
|
|
141
167
|
function drawable(element: Element): element is HTMLImageElement | HTMLVideoElement | HTMLCanvasElement {
|
|
@@ -149,6 +175,71 @@ function drawable(element: Element): element is HTMLImageElement | HTMLVideoElem
|
|
|
149
175
|
return element instanceof HTMLCanvasElement && element.width > 0 && element.height > 0;
|
|
150
176
|
}
|
|
151
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Images are drawn from a downsized copy decoded off the main thread. Drawing
|
|
180
|
+
* a full-size <img> the first time decodes it synchronously — a Retina
|
|
181
|
+
* screenshot behind a carousel arrow held a frame for 380ms — and the glass
|
|
182
|
+
* frosts what it shows anyway, so it never needs more than this.
|
|
183
|
+
*/
|
|
184
|
+
const MAX_BITMAP_EDGE = 960;
|
|
185
|
+
|
|
186
|
+
type ImageCopy = { src: string; bitmap?: ImageBitmap; pending?: boolean; failed?: boolean };
|
|
187
|
+
const imageCopies = new WeakMap<HTMLImageElement, ImageCopy>();
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The drawable copy of an image: a ready bitmap, the image itself where
|
|
191
|
+
* bitmaps are unavailable, or null while the copy is still decoding (it is
|
|
192
|
+
* left out until `ready` is called).
|
|
193
|
+
*/
|
|
194
|
+
function imageCopy(image: HTMLImageElement, ready: () => void): CanvasImageSource | null {
|
|
195
|
+
if (typeof createImageBitmap !== "function") return image;
|
|
196
|
+
const src = image.currentSrc || image.src;
|
|
197
|
+
let copy = imageCopies.get(image);
|
|
198
|
+
if (!copy || copy.src !== src) {
|
|
199
|
+
copy?.bitmap?.close();
|
|
200
|
+
copy = { src };
|
|
201
|
+
imageCopies.set(image, copy);
|
|
202
|
+
}
|
|
203
|
+
if (copy.bitmap) return copy.bitmap;
|
|
204
|
+
if (copy.failed) return image;
|
|
205
|
+
if (!copy.pending) {
|
|
206
|
+
const target = copy;
|
|
207
|
+
target.pending = true;
|
|
208
|
+
const longEdge = Math.max(image.naturalWidth, image.naturalHeight);
|
|
209
|
+
const scale = Math.min(1, MAX_BITMAP_EDGE / Math.max(1, longEdge));
|
|
210
|
+
const options: ImageBitmapOptions | undefined = scale < 1
|
|
211
|
+
? {
|
|
212
|
+
resizeWidth: Math.max(1, Math.round(image.naturalWidth * scale)),
|
|
213
|
+
resizeHeight: Math.max(1, Math.round(image.naturalHeight * scale)),
|
|
214
|
+
resizeQuality: "medium",
|
|
215
|
+
}
|
|
216
|
+
: undefined;
|
|
217
|
+
// Decode from the file's bytes (the HTTP cache has them): a Blob decodes
|
|
218
|
+
// entirely off the main thread, where an <img> source can still decode on
|
|
219
|
+
// it. SVG and anything else a Blob cannot decode falls back to the element.
|
|
220
|
+
const fromBytes = fetch(src, { mode: "cors", credentials: "same-origin", cache: "force-cache" })
|
|
221
|
+
.then((response) => {
|
|
222
|
+
if (!response.ok) throw new Error(`Glass: image fetch ${response.status}`);
|
|
223
|
+
return response.blob();
|
|
224
|
+
})
|
|
225
|
+
.then((blob) => createImageBitmap(blob, options));
|
|
226
|
+
fromBytes
|
|
227
|
+
.catch(() => createImageBitmap(image, options))
|
|
228
|
+
.then((bitmap) => {
|
|
229
|
+
if (imageCopies.get(image) === target) target.bitmap = bitmap;
|
|
230
|
+
else bitmap.close();
|
|
231
|
+
})
|
|
232
|
+
.catch(() => {
|
|
233
|
+
target.failed = true;
|
|
234
|
+
})
|
|
235
|
+
.finally(() => {
|
|
236
|
+
target.pending = false;
|
|
237
|
+
ready();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
152
243
|
/** Whether drawing this source would taint a canvas (its pixels are cross-origin). */
|
|
153
244
|
function unreadable(source: CanvasImageSource) {
|
|
154
245
|
const probe = document.createElement("canvas");
|
|
@@ -175,7 +266,7 @@ function naturalSize(element: HTMLImageElement | HTMLVideoElement | HTMLCanvasEl
|
|
|
175
266
|
* up to the root, and its opacity after every ancestor's. Null if hidden.
|
|
176
267
|
*/
|
|
177
268
|
function visibility(element: Element, root: Element) {
|
|
178
|
-
let clip: Box | null = toBox(element
|
|
269
|
+
let clip: Box | null = toBox(rectOf(element));
|
|
179
270
|
let opacity = 1;
|
|
180
271
|
for (let node: Element | null = element; node && node !== root; node = node.parentElement) {
|
|
181
272
|
const style = getComputedStyle(node);
|
|
@@ -183,7 +274,7 @@ function visibility(element: Element, root: Element) {
|
|
|
183
274
|
opacity *= Number(style.opacity) || 0;
|
|
184
275
|
if (opacity <= 0.01) return null;
|
|
185
276
|
if (node !== element && (style.overflowX !== "visible" || style.overflowY !== "visible")) {
|
|
186
|
-
clip = clip && intersect(clip, toBox(node
|
|
277
|
+
clip = clip && intersect(clip, toBox(rectOf(node)));
|
|
187
278
|
if (!clip) return null;
|
|
188
279
|
}
|
|
189
280
|
}
|
|
@@ -196,7 +287,7 @@ function clipOf(element: Element): Box {
|
|
|
196
287
|
for (let node = element.parentElement; node; node = node.parentElement) {
|
|
197
288
|
const style = getComputedStyle(node);
|
|
198
289
|
if (style.overflowX !== "visible" || style.overflowY !== "visible") {
|
|
199
|
-
clip = intersect(clip, toBox(node
|
|
290
|
+
clip = intersect(clip, toBox(rectOf(node))) ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
200
291
|
}
|
|
201
292
|
if (node.dataset?.slot === "glass-root") break;
|
|
202
293
|
}
|
|
@@ -211,6 +302,13 @@ type Pane = {
|
|
|
211
302
|
element: HTMLElement;
|
|
212
303
|
canvas: HTMLCanvasElement;
|
|
213
304
|
context: CanvasRenderingContext2D;
|
|
305
|
+
/** This pane's own ground canvas, kept between frames rather than resized. */
|
|
306
|
+
scene: HTMLCanvasElement;
|
|
307
|
+
sceneContext: CanvasRenderingContext2D;
|
|
308
|
+
/** When a playing video behind it last redrew this pane. */
|
|
309
|
+
lastVideoFrame: number;
|
|
310
|
+
/** Whether the pane was drawable last time it was checked. */
|
|
311
|
+
shown: boolean;
|
|
214
312
|
restorePosition: string | null;
|
|
215
313
|
hover: number;
|
|
216
314
|
press: number;
|
|
@@ -234,11 +332,6 @@ export interface LensGlass {
|
|
|
234
332
|
export function createLensGlass({ root, glassElements, defaults = {} }: LensGlassOptions): LensGlass {
|
|
235
333
|
getShared(); // throws if WebGL is unavailable, so the root can fall back
|
|
236
334
|
|
|
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
335
|
// If anything below throws, take back what was already added to the DOM so
|
|
243
336
|
// a failed start leaves the panes as they were.
|
|
244
337
|
const injected: Array<{ canvas: HTMLCanvasElement; element: HTMLElement; position: string | null }> = [];
|
|
@@ -264,6 +357,9 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
264
357
|
injected.push(record);
|
|
265
358
|
const context = canvas.getContext("2d");
|
|
266
359
|
if (!context) throw new Error("Glass: 2D canvas is unavailable.");
|
|
360
|
+
const scene = document.createElement("canvas");
|
|
361
|
+
const sceneContext = scene.getContext("2d");
|
|
362
|
+
if (!sceneContext) throw new Error("Glass: 2D canvas is unavailable.");
|
|
267
363
|
|
|
268
364
|
let restorePosition: string | null = null;
|
|
269
365
|
if (getComputedStyle(element).position === "static") {
|
|
@@ -273,7 +369,7 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
273
369
|
}
|
|
274
370
|
|
|
275
371
|
const pane: Pane = {
|
|
276
|
-
element, canvas, context, restorePosition,
|
|
372
|
+
element, canvas, context, scene, sceneContext, lastVideoFrame: 0, shown: false, restorePosition,
|
|
277
373
|
hover: 0, press: 0, hoverTarget: 0, pressTarget: 0, signature: "", cleanup: () => {},
|
|
278
374
|
};
|
|
279
375
|
const enter = () => { pane.hoverTarget = 1; wake(); };
|
|
@@ -332,10 +428,19 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
332
428
|
return getComputedStyle(document.documentElement).backgroundColor || "#000";
|
|
333
429
|
};
|
|
334
430
|
|
|
431
|
+
// `forced`: something may have changed, so check every pane this frame.
|
|
432
|
+
// `redraw`: draw every pane even if nothing it depends on looks different
|
|
433
|
+
// (markChanged, a restored WebGL context).
|
|
335
434
|
let forced = true;
|
|
435
|
+
let redraw = true;
|
|
336
436
|
let visible = true;
|
|
337
437
|
let frame = 0;
|
|
338
438
|
let destroyed = false;
|
|
439
|
+
// Frames since anything last moved or changed; the loop stops a few frames
|
|
440
|
+
// after the page settles and waits for an event to wake it.
|
|
441
|
+
let quietFrames = 0;
|
|
442
|
+
let lastLayout = "";
|
|
443
|
+
let transitions = 0;
|
|
339
444
|
|
|
340
445
|
const intersection = new IntersectionObserver((entries) => {
|
|
341
446
|
visible = entries.some((entry) => entry.isIntersecting);
|
|
@@ -345,62 +450,132 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
345
450
|
|
|
346
451
|
const mutations = new MutationObserver((records) => {
|
|
347
452
|
// Our own canvases change on every frame we draw; they are not the ground.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
)
|
|
453
|
+
// A pane's own contents (a progress bar, a label) are drawn over the glass,
|
|
454
|
+
// not refracted by it; only its configuration matters.
|
|
455
|
+
const relevant = records.filter((record) => {
|
|
456
|
+
const target = record.target as HTMLElement;
|
|
457
|
+
if (target.dataset?.glassCanvas !== undefined) return false;
|
|
458
|
+
if (record.attributeName === "data-config") return true;
|
|
459
|
+
return !(target instanceof Element && !paneSet.has(target as HTMLElement) && insidePane(target));
|
|
460
|
+
});
|
|
351
461
|
if (!relevant.length) return;
|
|
352
|
-
// Inline style changes (a carousel's transform while dragging) move
|
|
353
|
-
// things, which the
|
|
354
|
-
//
|
|
462
|
+
// Inline style changes (a carousel's transform while dragging) only move
|
|
463
|
+
// things, which the position check sees; a change in what exists or how
|
|
464
|
+
// it is styled means re-listing what paints.
|
|
355
465
|
if (relevant.some((record) => record.type === "childList" || record.attributeName !== "style")) {
|
|
356
466
|
collectMedia();
|
|
357
467
|
}
|
|
468
|
+
// An inline style can change opacity or clipping without moving anything.
|
|
358
469
|
forced = true;
|
|
359
470
|
wake();
|
|
360
471
|
});
|
|
361
472
|
mutations.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["src", "data-config", "class", "style"] });
|
|
362
473
|
|
|
363
|
-
//
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
474
|
+
// Everything else that can change what a pane shows arrives as an event.
|
|
475
|
+
const onChange = () => { forced = true; wake(); };
|
|
476
|
+
const onMove = () => wake();
|
|
477
|
+
const onTransitionStart = () => { transitions += 1; forced = true; wake(); };
|
|
478
|
+
const onTransitionEnd = () => { transitions = Math.max(0, transitions - 1); forced = true; wake(); };
|
|
479
|
+
const rootEvents: Array<[string, EventListener]> = [
|
|
480
|
+
["load", onChange], ["loadeddata", onChange], ["seeked", onChange], ["play", onChange],
|
|
481
|
+
["pause", onChange], ["ended", onChange],
|
|
482
|
+
["transitionrun", onTransitionStart], ["transitionend", onTransitionEnd], ["transitioncancel", onTransitionEnd],
|
|
483
|
+
["animationstart", onTransitionStart], ["animationend", onTransitionEnd], ["animationcancel", onTransitionEnd],
|
|
484
|
+
["pointerover", onChange], ["pointerout", onChange], ["pointermove", onMove], ["pointerdown", onMove],
|
|
485
|
+
];
|
|
486
|
+
for (const [type, listener] of rootEvents) root.addEventListener(type, listener, true);
|
|
487
|
+
window.addEventListener("resize", onChange);
|
|
488
|
+
window.addEventListener("scroll", onMove, { capture: true, passive: true });
|
|
369
489
|
// A root that started in a background tab draws when the tab is shown.
|
|
370
|
-
document.addEventListener("visibilitychange",
|
|
490
|
+
document.addEventListener("visibilitychange", onChange);
|
|
371
491
|
|
|
372
492
|
function wake() {
|
|
493
|
+
quietFrames = 0;
|
|
373
494
|
if (!frame && !destroyed) frame = requestAnimationFrame(tick);
|
|
374
495
|
}
|
|
375
496
|
|
|
376
|
-
|
|
497
|
+
const rectKey = (element: Element) => {
|
|
498
|
+
const r = rectOf(element);
|
|
499
|
+
return `${r.x.toFixed(1)},${r.y.toFixed(1)},${r.width.toFixed(1)},${r.height.toFixed(1)}`;
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
/** Whether a pane can be seen at all: not faded out, hidden or clipped away. */
|
|
503
|
+
function paneShown(pane: Pane) {
|
|
504
|
+
const seen = visibility(pane.element, root);
|
|
505
|
+
if (!seen || seen.opacity < 0.02) return false;
|
|
506
|
+
return Boolean(intersect(seen.clip, { x: 0, y: 0, width: innerWidth, height: innerHeight }));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function tick(now: number) {
|
|
377
510
|
frame = 0;
|
|
378
511
|
// Hidden tabs get no animation frames anyway; no need to check here.
|
|
379
512
|
if (destroyed || !visible) return;
|
|
380
|
-
|
|
381
|
-
|
|
513
|
+
frameRects = new Map();
|
|
514
|
+
try {
|
|
515
|
+
step(now);
|
|
516
|
+
} finally {
|
|
517
|
+
frameRects = null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function step(now: number) {
|
|
522
|
+
|
|
523
|
+
// Cheap: where the panes and media are. Anything else that matters
|
|
524
|
+
// arrives as an event and sets `forced`.
|
|
525
|
+
const layout = panes.map((pane) => rectKey(pane.element)).join(";") + "|" + media.map(rectKey).join(";");
|
|
526
|
+
const moved = layout !== lastLayout || transitions > 0;
|
|
527
|
+
lastLayout = layout;
|
|
528
|
+
const check = forced || moved;
|
|
529
|
+
|
|
530
|
+
const ground = check ? groundColor() : "";
|
|
382
531
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
383
|
-
const playing = media.
|
|
384
|
-
(element)
|
|
532
|
+
const playing = media.filter(
|
|
533
|
+
(element): element is HTMLVideoElement =>
|
|
534
|
+
element instanceof HTMLVideoElement && !element.paused && !element.ended,
|
|
385
535
|
);
|
|
536
|
+
let animating = false;
|
|
537
|
+
let videoBehindShownPane = false;
|
|
386
538
|
|
|
387
539
|
for (const pane of panes) {
|
|
388
540
|
pane.hover += (pane.hoverTarget - pane.hover) * 0.25;
|
|
389
541
|
pane.press += (pane.pressTarget - pane.press) * 0.35;
|
|
390
542
|
if (Math.abs(pane.hoverTarget - pane.hover) < 0.01) pane.hover = pane.hoverTarget;
|
|
391
543
|
if (Math.abs(pane.pressTarget - pane.press) < 0.01) pane.press = pane.pressTarget;
|
|
392
|
-
|
|
393
|
-
|
|
544
|
+
const paneAnimating = pane.hover !== pane.hoverTarget || pane.press !== pane.pressTarget;
|
|
545
|
+
if (paneAnimating) animating = true;
|
|
546
|
+
|
|
547
|
+
if (check || paneAnimating) pane.shown = paneShown(pane);
|
|
548
|
+
if (!pane.shown) {
|
|
549
|
+
// Draw afresh when it next appears.
|
|
550
|
+
pane.signature = "";
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// A playing video behind a shown pane changes every frame; follow it
|
|
555
|
+
// at up to 30fps.
|
|
556
|
+
const paneBox = toBox(rectOf(pane.element));
|
|
557
|
+
const overVideo = playing.some((video) => intersect(toBox(rectOf(video)), paneBox));
|
|
558
|
+
let videoFrame = false;
|
|
559
|
+
if (overVideo) {
|
|
560
|
+
videoBehindShownPane = true;
|
|
561
|
+
if (now - pane.lastVideoFrame >= 33) {
|
|
562
|
+
pane.lastVideoFrame = now;
|
|
563
|
+
videoFrame = true;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (!check && !paneAnimating && !videoFrame) continue;
|
|
568
|
+
renderPane(pane, ground || groundColor(), dpr, redraw);
|
|
394
569
|
}
|
|
395
570
|
forced = false;
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (animating ||
|
|
571
|
+
redraw = false;
|
|
572
|
+
quietFrames = moved || animating ? 0 : quietFrames + 1;
|
|
573
|
+
if (animating || videoBehindShownPane || quietFrames < 12) frame = requestAnimationFrame(tick);
|
|
399
574
|
}
|
|
400
575
|
|
|
401
576
|
function renderPane(pane: Pane, ground: string, dpr: number, force: boolean) {
|
|
402
577
|
const { element } = pane;
|
|
403
|
-
const rect = element
|
|
578
|
+
const rect = rectOf(element);
|
|
404
579
|
if (rect.width < 1 || rect.height < 1) return;
|
|
405
580
|
const style = getComputedStyle(element);
|
|
406
581
|
const config = { ...defaults, ...parseConfig(element.dataset.config) };
|
|
@@ -421,8 +596,8 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
421
596
|
const drawn: Layer[] = [];
|
|
422
597
|
for (const item of paintList) {
|
|
423
598
|
const isMedia = item instanceof HTMLImageElement || item instanceof HTMLVideoElement || item instanceof HTMLCanvasElement;
|
|
599
|
+
if (!intersect(toBox(rectOf(item)), region)) continue;
|
|
424
600
|
if (isMedia && !drawable(item)) continue;
|
|
425
|
-
if (!intersect(toBox(item.getBoundingClientRect()), region)) continue;
|
|
426
601
|
const seen = visibility(item, root);
|
|
427
602
|
if (!seen) continue;
|
|
428
603
|
const clip = intersect(seen.clip, region);
|
|
@@ -439,14 +614,20 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
439
614
|
});
|
|
440
615
|
}
|
|
441
616
|
|
|
617
|
+
// Relative to the pane: a pane that moves together with what is behind it
|
|
618
|
+
// (a video player's controls as its slide pages) shows the same thing, and
|
|
619
|
+
// its canvas moves with it, so it needs no redraw.
|
|
620
|
+
const ox = rect.left;
|
|
621
|
+
const oy = rect.top;
|
|
622
|
+
const px = (value: number) => value.toFixed(1);
|
|
442
623
|
const signature = [
|
|
443
|
-
|
|
624
|
+
px(region.width), px(region.height),
|
|
444
625
|
dpr, ground, element.dataset.config ?? "", pane.hover.toFixed(2), pane.press.toFixed(2),
|
|
445
626
|
...drawn.map(({ element: item, clip, opacity, filter, color }) => {
|
|
446
|
-
const r = item
|
|
627
|
+
const r = rectOf(item);
|
|
447
628
|
const frameKey = item instanceof HTMLVideoElement ? item.currentTime.toFixed(3)
|
|
448
629
|
: item instanceof HTMLImageElement ? item.currentSrc : "";
|
|
449
|
-
return `${r.x
|
|
630
|
+
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
631
|
}),
|
|
451
632
|
].join(";");
|
|
452
633
|
if (!force && signature === pane.signature) return;
|
|
@@ -457,8 +638,16 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
457
638
|
if (region.width * region.height * scale * scale > MAX_SCENE_PIXELS) {
|
|
458
639
|
scale = Math.sqrt(MAX_SCENE_PIXELS / (region.width * region.height));
|
|
459
640
|
}
|
|
460
|
-
|
|
461
|
-
|
|
641
|
+
const { scene, sceneContext } = pane;
|
|
642
|
+
const sceneWidth = Math.max(1, Math.round(region.width * scale));
|
|
643
|
+
const sceneHeight = Math.max(1, Math.round(region.height * scale));
|
|
644
|
+
// Resizing a canvas reallocates it; only do so when the size changes.
|
|
645
|
+
if (scene.width !== sceneWidth || scene.height !== sceneHeight) {
|
|
646
|
+
scene.width = sceneWidth;
|
|
647
|
+
scene.height = sceneHeight;
|
|
648
|
+
}
|
|
649
|
+
sceneContext.setTransform(1, 0, 0, 1, 0, 0);
|
|
650
|
+
sceneContext.clearRect(0, 0, sceneWidth, sceneHeight);
|
|
462
651
|
sceneContext.setTransform(scale, 0, 0, scale, -region.x * scale, -region.y * scale);
|
|
463
652
|
sceneContext.globalAlpha = 1;
|
|
464
653
|
sceneContext.fillStyle = ground;
|
|
@@ -467,7 +656,7 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
467
656
|
sceneContext.save();
|
|
468
657
|
sceneContext.globalAlpha = opacity;
|
|
469
658
|
if (color !== null) {
|
|
470
|
-
const box = toBox(item
|
|
659
|
+
const box = toBox(rectOf(item));
|
|
471
660
|
const visible = intersect(box, clip);
|
|
472
661
|
if (visible) {
|
|
473
662
|
sceneContext.fillStyle = color;
|
|
@@ -477,7 +666,7 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
477
666
|
continue;
|
|
478
667
|
}
|
|
479
668
|
const source = item as HTMLImageElement | HTMLVideoElement | HTMLCanvasElement;
|
|
480
|
-
const box = source
|
|
669
|
+
const box = rectOf(source);
|
|
481
670
|
const itemStyle = getComputedStyle(source);
|
|
482
671
|
const content = source instanceof HTMLCanvasElement
|
|
483
672
|
? { x: 0, y: 0, width: box.width, height: box.height }
|
|
@@ -490,8 +679,11 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
490
679
|
sceneContext.clip();
|
|
491
680
|
// Canvas understands CSS filter functions (blur, brightness, …) as is.
|
|
492
681
|
if (filter !== "none") sceneContext.filter = filter;
|
|
682
|
+
const picture = source instanceof HTMLImageElement ? imageCopy(source, instance.markChanged) : source;
|
|
493
683
|
try {
|
|
494
|
-
|
|
684
|
+
if (picture) {
|
|
685
|
+
sceneContext.drawImage(picture, box.left + content.x, box.top + content.y, content.width, content.height);
|
|
686
|
+
}
|
|
495
687
|
} catch {
|
|
496
688
|
tainted.add(source);
|
|
497
689
|
}
|
|
@@ -577,6 +769,7 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
577
769
|
const instance: LensGlass = {
|
|
578
770
|
markChanged() {
|
|
579
771
|
forced = true;
|
|
772
|
+
redraw = true;
|
|
580
773
|
wake();
|
|
581
774
|
},
|
|
582
775
|
destroy() {
|
|
@@ -584,11 +777,10 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
|
|
|
584
777
|
if (frame) cancelAnimationFrame(frame);
|
|
585
778
|
intersection.disconnect();
|
|
586
779
|
mutations.disconnect();
|
|
587
|
-
root.removeEventListener(
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
document.removeEventListener("visibilitychange", onMediaEvent);
|
|
780
|
+
for (const [type, listener] of rootEvents) root.removeEventListener(type, listener, true);
|
|
781
|
+
window.removeEventListener("resize", onChange);
|
|
782
|
+
window.removeEventListener("scroll", onMove, { capture: true });
|
|
783
|
+
document.removeEventListener("visibilitychange", onChange);
|
|
592
784
|
for (const pane of panes) {
|
|
593
785
|
pane.cleanup();
|
|
594
786
|
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)
|
|
@@ -289,7 +320,11 @@ export const VideoPlayer = ({
|
|
|
289
320
|
// carries its own hairline ring and doubled the frame. Black only in
|
|
290
321
|
// fullscreen, where letterboxing needs it: behind the rounded clip it
|
|
291
322
|
// bled through the video's anti-aliased corners as a dark stroke.
|
|
292
|
-
|
|
323
|
+
// The corners stay rounded in fullscreen: browsers keep :fullscreen
|
|
324
|
+
// matching through their exit animation, so a square corner collapsed
|
|
325
|
+
// back into the page square and only rounded once it landed. Against
|
|
326
|
+
// the black backdrop the rounding does not show while fullscreen.
|
|
327
|
+
"group relative isolate overflow-hidden rounded-xl shadow-[var(--shadow-300)] ring-1 ring-border-primary data-[fullscreen]:h-screen data-[fullscreen]:w-screen data-[fullscreen]:bg-black data-[fullscreen]:ring-0 fullscreen:h-screen fullscreen:w-screen fullscreen:bg-black fullscreen:ring-0",
|
|
293
328
|
className,
|
|
294
329
|
)}
|
|
295
330
|
aria-busy={loading || buffering || undefined}
|
|
@@ -299,6 +334,8 @@ export const VideoPlayer = ({
|
|
|
299
334
|
ref={videoRef}
|
|
300
335
|
src={src}
|
|
301
336
|
poster={poster}
|
|
337
|
+
crossOrigin={videoCrossOrigin}
|
|
338
|
+
data-cors={automaticCors ? "auto" : undefined}
|
|
302
339
|
preload="auto"
|
|
303
340
|
playsInline
|
|
304
341
|
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"
|