@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyernick/standard-ui-react",
3
- "version": "0.4.2-canary.62",
3
+ "version": "0.4.2-canary.64",
4
4
  "description": "React components for StandardUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- if (!source || source.startsWith("data:") || source.startsWith("blob:")) return false;
134
- try {
135
- return new URL(source, location.href).origin !== location.origin;
136
- } catch {
137
- return true;
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.getBoundingClientRect());
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.getBoundingClientRect()));
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.getBoundingClientRect())) ?? { x: 0, y: 0, width: 0, height: 0 };
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
- const relevant = records.filter(
349
- (record) => (record.target as HTMLElement).dataset?.glassCanvas === undefined,
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 per-frame position check already sees; only a change
354
- // in what exists or how it is styled means re-listing what paints.
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
- // Media keeps changing on its own while it plays or loads.
364
- const onMediaEvent = () => { forced = true; wake(); };
365
- root.addEventListener("load", onMediaEvent, true);
366
- root.addEventListener("loadeddata", onMediaEvent, true);
367
- root.addEventListener("seeked", onMediaEvent, true);
368
- window.addEventListener("resize", onMediaEvent);
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", onMediaEvent);
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
- function tick() {
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
- let animating = false;
381
- const ground = groundColor();
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.some(
384
- (element) => element instanceof HTMLVideoElement && !element.paused && !element.ended,
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
- if (pane.hover !== pane.hoverTarget || pane.press !== pane.pressTarget) animating = true;
393
- renderPane(pane, ground, dpr, forced || playing);
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
- // Keep polling positions while anything could be moving: transforms and
397
- // scroll offsets inside the root change without a DOM mutation.
398
- if (animating || playing || media.length > 0) frame = requestAnimationFrame(tick);
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.getBoundingClientRect();
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
- region.x.toFixed(1), region.y.toFixed(1), region.width.toFixed(1), region.height.toFixed(1),
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.getBoundingClientRect();
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.toFixed(1)},${r.y.toFixed(1)},${r.width.toFixed(1)},${r.height.toFixed(1)}|${clip.x.toFixed(1)},${clip.y.toFixed(1)},${clip.width.toFixed(1)},${clip.height.toFixed(1)}|${opacity.toFixed(2)}|${filter}|${color ?? ""}|${frameKey}`;
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
- scene.width = Math.max(1, Math.round(region.width * scale));
461
- scene.height = Math.max(1, Math.round(region.height * scale));
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.getBoundingClientRect());
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.getBoundingClientRect();
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
- sceneContext.drawImage(source, box.left + content.x, box.top + content.y, content.width, content.height);
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("load", onMediaEvent, true);
588
- root.removeEventListener("loadeddata", onMediaEvent, true);
589
- root.removeEventListener("seeked", onMediaEvent, true);
590
- window.removeEventListener("resize", onMediaEvent);
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();
@@ -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
- "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]:rounded-none data-[fullscreen]:bg-black data-[fullscreen]:ring-0 fullscreen:h-screen fullscreen:w-screen fullscreen:rounded-none fullscreen:bg-black fullscreen:ring-0",
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"