@boyernick/standard-ui-react 0.4.2-canary.61 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyernick/standard-ui-react",
3
- "version": "0.4.2-canary.61",
3
+ "version": "0.4.2-canary.63",
4
4
  "description": "React components for StandardUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -149,6 +149,21 @@ function drawable(element: Element): element is HTMLImageElement | HTMLVideoElem
149
149
  return element instanceof HTMLCanvasElement && element.width > 0 && element.height > 0;
150
150
  }
151
151
 
152
+ /** Whether drawing this source would taint a canvas (its pixels are cross-origin). */
153
+ function unreadable(source: CanvasImageSource) {
154
+ const probe = document.createElement("canvas");
155
+ probe.width = probe.height = 1;
156
+ const context = probe.getContext("2d");
157
+ if (!context) return true;
158
+ try {
159
+ context.drawImage(source, 0, 0, 1, 1);
160
+ context.getImageData(0, 0, 1, 1);
161
+ return false;
162
+ } catch {
163
+ return true;
164
+ }
165
+ }
166
+
152
167
  function naturalSize(element: HTMLImageElement | HTMLVideoElement | HTMLCanvasElement) {
153
168
  if (element instanceof HTMLImageElement) return { width: element.naturalWidth, height: element.naturalHeight };
154
169
  if (element instanceof HTMLVideoElement) return { width: element.videoWidth, height: element.videoHeight };
@@ -196,6 +211,13 @@ type Pane = {
196
211
  element: HTMLElement;
197
212
  canvas: HTMLCanvasElement;
198
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;
199
221
  restorePosition: string | null;
200
222
  hover: number;
201
223
  press: number;
@@ -219,11 +241,6 @@ export interface LensGlass {
219
241
  export function createLensGlass({ root, glassElements, defaults = {} }: LensGlassOptions): LensGlass {
220
242
  getShared(); // throws if WebGL is unavailable, so the root can fall back
221
243
 
222
- const scene = document.createElement("canvas");
223
- const maybeSceneContext = scene.getContext("2d");
224
- if (!maybeSceneContext) throw new Error("Glass: 2D canvas is unavailable.");
225
- const sceneContext: CanvasRenderingContext2D = maybeSceneContext;
226
-
227
244
  // If anything below throws, take back what was already added to the DOM so
228
245
  // a failed start leaves the panes as they were.
229
246
  const injected: Array<{ canvas: HTMLCanvasElement; element: HTMLElement; position: string | null }> = [];
@@ -249,6 +266,9 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
249
266
  injected.push(record);
250
267
  const context = canvas.getContext("2d");
251
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.");
252
272
 
253
273
  let restorePosition: string | null = null;
254
274
  if (getComputedStyle(element).position === "static") {
@@ -258,7 +278,7 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
258
278
  }
259
279
 
260
280
  const pane: Pane = {
261
- element, canvas, context, restorePosition,
281
+ element, canvas, context, scene, sceneContext, lastVideoFrame: 0, shown: false, restorePosition,
262
282
  hover: 0, press: 0, hoverTarget: 0, pressTarget: 0, signature: "", cleanup: () => {},
263
283
  };
264
284
  const enter = () => { pane.hoverTarget = 1; wake(); };
@@ -317,10 +337,19 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
317
337
  return getComputedStyle(document.documentElement).backgroundColor || "#000";
318
338
  };
319
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).
320
343
  let forced = true;
344
+ let redraw = true;
321
345
  let visible = true;
322
346
  let frame = 0;
323
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;
324
353
 
325
354
  const intersection = new IntersectionObserver((entries) => {
326
355
  visible = entries.some((entry) => entry.isIntersecting);
@@ -330,57 +359,118 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
330
359
 
331
360
  const mutations = new MutationObserver((records) => {
332
361
  // Our own canvases change on every frame we draw; they are not the ground.
333
- const relevant = records.filter(
334
- (record) => (record.target as HTMLElement).dataset?.glassCanvas === undefined,
335
- );
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
+ });
336
370
  if (!relevant.length) return;
337
- // Inline style changes (a carousel's transform while dragging) move
338
- // things, which the per-frame position check already sees; only a change
339
- // in what exists or how it is styled means re-listing what paints.
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.
340
374
  if (relevant.some((record) => record.type === "childList" || record.attributeName !== "style")) {
341
375
  collectMedia();
342
376
  }
377
+ // An inline style can change opacity or clipping without moving anything.
343
378
  forced = true;
344
379
  wake();
345
380
  });
346
381
  mutations.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["src", "data-config", "class", "style"] });
347
382
 
348
- // Media keeps changing on its own while it plays or loads.
349
- const onMediaEvent = () => { forced = true; wake(); };
350
- root.addEventListener("load", onMediaEvent, true);
351
- root.addEventListener("loadeddata", onMediaEvent, true);
352
- root.addEventListener("seeked", onMediaEvent, true);
353
- window.addEventListener("resize", onMediaEvent);
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 });
354
398
  // A root that started in a background tab draws when the tab is shown.
355
- document.addEventListener("visibilitychange", onMediaEvent);
399
+ document.addEventListener("visibilitychange", onChange);
356
400
 
357
401
  function wake() {
402
+ quietFrames = 0;
358
403
  if (!frame && !destroyed) frame = requestAnimationFrame(tick);
359
404
  }
360
405
 
361
- function tick() {
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) {
362
419
  frame = 0;
363
420
  // Hidden tabs get no animation frames anyway; no need to check here.
364
421
  if (destroyed || !visible) return;
365
- let animating = false;
366
- const ground = groundColor();
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() : "";
367
431
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
368
- const playing = media.some(
369
- (element) => element instanceof HTMLVideoElement && !element.paused && !element.ended,
432
+ const playing = media.filter(
433
+ (element): element is HTMLVideoElement =>
434
+ element instanceof HTMLVideoElement && !element.paused && !element.ended,
370
435
  );
436
+ let animating = false;
437
+ let videoBehindShownPane = false;
371
438
 
372
439
  for (const pane of panes) {
373
440
  pane.hover += (pane.hoverTarget - pane.hover) * 0.25;
374
441
  pane.press += (pane.pressTarget - pane.press) * 0.35;
375
442
  if (Math.abs(pane.hoverTarget - pane.hover) < 0.01) pane.hover = pane.hoverTarget;
376
443
  if (Math.abs(pane.pressTarget - pane.press) < 0.01) pane.press = pane.pressTarget;
377
- if (pane.hover !== pane.hoverTarget || pane.press !== pane.pressTarget) animating = true;
378
- renderPane(pane, ground, dpr, forced || playing);
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);
379
469
  }
380
470
  forced = false;
381
- // Keep polling positions while anything could be moving: transforms and
382
- // scroll offsets inside the root change without a DOM mutation.
383
- if (animating || playing || media.length > 0) frame = requestAnimationFrame(tick);
471
+ redraw = false;
472
+ quietFrames = moved || animating ? 0 : quietFrames + 1;
473
+ if (animating || videoBehindShownPane || quietFrames < 12) frame = requestAnimationFrame(tick);
384
474
  }
385
475
 
386
476
  function renderPane(pane: Pane, ground: string, dpr: number, force: boolean) {
@@ -424,14 +514,20 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
424
514
  });
425
515
  }
426
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);
427
523
  const signature = [
428
- region.x.toFixed(1), region.y.toFixed(1), region.width.toFixed(1), region.height.toFixed(1),
524
+ px(region.width), px(region.height),
429
525
  dpr, ground, element.dataset.config ?? "", pane.hover.toFixed(2), pane.press.toFixed(2),
430
526
  ...drawn.map(({ element: item, clip, opacity, filter, color }) => {
431
527
  const r = item.getBoundingClientRect();
432
528
  const frameKey = item instanceof HTMLVideoElement ? item.currentTime.toFixed(3)
433
529
  : item instanceof HTMLImageElement ? item.currentSrc : "";
434
- 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}`;
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}`;
435
531
  }),
436
532
  ].join(";");
437
533
  if (!force && signature === pane.signature) return;
@@ -442,8 +538,16 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
442
538
  if (region.width * region.height * scale * scale > MAX_SCENE_PIXELS) {
443
539
  scale = Math.sqrt(MAX_SCENE_PIXELS / (region.width * region.height));
444
540
  }
445
- scene.width = Math.max(1, Math.round(region.width * scale));
446
- scene.height = Math.max(1, Math.round(region.height * scale));
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);
447
551
  sceneContext.setTransform(scale, 0, 0, scale, -region.x * scale, -region.y * scale);
448
552
  sceneContext.globalAlpha = 1;
449
553
  sceneContext.fillStyle = ground;
@@ -489,8 +593,12 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
489
593
  try {
490
594
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, scene);
491
595
  } catch {
492
- // A canvas we drew was cross-origin. Leave it out from now on.
493
- for (const { element: item } of drawn) if (item instanceof HTMLCanvasElement) tainted.add(item);
596
+ // Something we drew was cross-origin after all — a canvas, or media
597
+ // behind a same-origin address that redirects elsewhere (a video served
598
+ // through a signed-link redirect). Find it and leave it out from now on.
599
+ const media = drawn.filter(({ element: item }) => item instanceof HTMLImageElement || item instanceof HTMLVideoElement || item instanceof HTMLCanvasElement);
600
+ const culprits = media.filter(({ element: item }) => unreadable(item as CanvasImageSource));
601
+ for (const { element: item } of culprits.length ? culprits : media) tainted.add(item);
494
602
  pane.signature = "";
495
603
  forced = true;
496
604
  return;
@@ -558,6 +666,7 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
558
666
  const instance: LensGlass = {
559
667
  markChanged() {
560
668
  forced = true;
669
+ redraw = true;
561
670
  wake();
562
671
  },
563
672
  destroy() {
@@ -565,11 +674,10 @@ export function createLensGlass({ root, glassElements, defaults = {} }: LensGlas
565
674
  if (frame) cancelAnimationFrame(frame);
566
675
  intersection.disconnect();
567
676
  mutations.disconnect();
568
- root.removeEventListener("load", onMediaEvent, true);
569
- root.removeEventListener("loadeddata", onMediaEvent, true);
570
- root.removeEventListener("seeked", onMediaEvent, true);
571
- window.removeEventListener("resize", onMediaEvent);
572
- 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);
573
681
  for (const pane of panes) {
574
682
  pane.cleanup();
575
683
  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)
@@ -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"