@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 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.63",
4
4
  "description": "React components for StandardUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- const relevant = records.filter(
349
- (record) => (record.target as HTMLElement).dataset?.glassCanvas === undefined,
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 per-frame position check already sees; only a change
354
- // 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.
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
- // 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);
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", onMediaEvent);
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
- 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) {
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
- let animating = false;
381
- 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() : "";
382
431
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
383
- const playing = media.some(
384
- (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,
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
- if (pane.hover !== pane.hoverTarget || pane.press !== pane.pressTarget) animating = true;
393
- 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);
394
469
  }
395
470
  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);
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
- region.x.toFixed(1), region.y.toFixed(1), region.width.toFixed(1), region.height.toFixed(1),
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.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}`;
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
- scene.width = Math.max(1, Math.round(region.width * scale));
461
- 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);
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("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);
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();
@@ -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"