cursedbelt 4.5.0 → 4.6.0

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.
Files changed (38) hide show
  1. package/dist/react/media/HoverScrubVideoThumb.d.ts +7 -1
  2. package/dist/react/media/HoverScrubVideoThumb.d.ts.map +1 -1
  3. package/dist/react/media/HoverScrubVideoThumb.js +18 -3
  4. package/dist/react/media/HoverScrubVideoThumb.js.map +1 -1
  5. package/dist/react/media/previewGate.d.ts +36 -0
  6. package/dist/react/media/previewGate.d.ts.map +1 -0
  7. package/dist/react/media/previewGate.js +161 -0
  8. package/dist/react/media/previewGate.js.map +1 -0
  9. package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
  10. package/dist/react/media-gallery/GalleryTable.js +15 -3
  11. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  12. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  13. package/dist/react/media-gallery/MediaGallery.js +3 -1
  14. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  15. package/dist/react/media-gallery/galleryItemMedia.d.ts +32 -1
  16. package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
  17. package/dist/react/media-gallery/galleryItemMedia.js +64 -9
  18. package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
  19. package/dist/react/media-gallery/types.d.ts +8 -1
  20. package/dist/react/media-gallery/types.d.ts.map +1 -1
  21. package/dist/styles-areas/media-gallery.css +1 -1
  22. package/dist/styles-areas/media.css +1 -1
  23. package/package.json +3 -1
  24. package/src/declaredImports.spec.ts +66 -0
  25. package/src/publicSurface.spec.ts +12 -4
  26. package/src/react/media/HoverScrubVideoThumb.spec.tsx +4 -3
  27. package/src/react/media/HoverScrubVideoThumb.tsx +25 -3
  28. package/src/react/media/previewGate.spec.tsx +144 -0
  29. package/src/react/media/previewGate.ts +160 -0
  30. package/src/react/media-gallery/GalleryTable.tsx +14 -1
  31. package/src/react/media-gallery/MediaGallery.spec.tsx +52 -3
  32. package/src/react/media-gallery/MediaGallery.tsx +9 -1
  33. package/src/react/media-gallery/galleryItemMedia.spec.tsx +143 -0
  34. package/src/react/media-gallery/galleryItemMedia.tsx +80 -9
  35. package/src/react/media-gallery/types.ts +8 -1
  36. package/src/styles-areas/media-gallery.css +1 -1
  37. package/src/styles-areas/media.css +1 -1
  38. package/scripts/publicSurface.ts +0 -458
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
- import { run } from '../scripts/publicSurface.js';
2
+ import { dirname } from 'node:path';
3
+ import { run } from 'cursedops/public-surface';
3
4
 
4
5
  /**
5
6
  * The public-surface ratchet, wired into `bun run verify` at its cheapest correct
@@ -9,10 +10,15 @@ import { run } from '../scripts/publicSurface.js';
9
10
  *
10
11
  * WHY it exists, WHAT a symbol count includes and excludes, why `bin` targets and
11
12
  * the dependency list are deliberately NOT counted here, and what `--prune` may
12
- * and may not do are all in the header of `scripts/publicSurface.ts` — one check,
13
+ * and may not do are all in the header of `cursedops/src/publicSurface.ts` — one check,
13
14
  * one baseline (`publicSurface.baseline`), one place that explains itself. This
14
15
  * file is only the wiring.
15
16
  *
17
+ * Since 2026-09-22 (task 084) the measurement is ONE body, `cursedops/public-surface`, a
18
+ * devDependency: this package's own `scripts/publicSurface.ts` had forked from the other
19
+ * two libraries' copies. `bun run surface [--prune]` is the CLI. The probes below were
20
+ * run against that former copy; the shared body measured identical per-subpath counts.
21
+ *
16
22
  * 🔴 Verified failing, 2026-09-13, before it was trusted — every branch, not just
17
23
  * the easy one. Each probe was applied, observed, and removed again:
18
24
  *
@@ -38,7 +44,9 @@ import { run } from '../scripts/publicSurface.js';
38
44
  *
39
45
  * A ratchet nobody has seen red is a ratchet nobody knows is wired up.
40
46
  */
41
- const result = await run();
47
+ // The package this spec lives in — resolved from this file, never from `process.cwd()`,
48
+ // because `bun test` can be started from anywhere.
49
+ const result = await run({ root: dirname(import.meta.dir) });
42
50
 
43
51
  describe('public surface', () => {
44
52
  it('has measured a real surface', () => {
@@ -69,7 +77,7 @@ describe('public surface', () => {
69
77
  // allowance behind for the next one to grow into.
70
78
  expect(
71
79
  result.stale,
72
- `surface came off and the ratchet was not tightened — run \`bun scripts/publicSurface.ts --prune\`:\n ${result.stale.join('\n ')}`,
80
+ `surface came off and the ratchet was not tightened — run \`bun run surface --prune\`:\n ${result.stale.join('\n ')}`,
73
81
  ).toEqual([]);
74
82
  });
75
83
  });
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test';
2
- import { cleanup, fireEvent, render } from '@testing-library/react';
2
+ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
3
3
  import type { PlayableVideo } from 'cursedbelt-core/media';
4
4
  import { HoverScrubVideoThumb } from './HoverScrubVideoThumb.js';
5
5
 
@@ -68,9 +68,10 @@ describe('HoverScrubVideoThumb', () => {
68
68
  expect(container.querySelectorAll('svg').length).toBeLessThan(before);
69
69
  });
70
70
 
71
- test('autoPlay mode previews without hover and renders without onOpen as a non-button', () => {
71
+ test('autoPlay mode previews without hover and renders without onOpen as a non-button', async () => {
72
72
  const { container, queryByRole } = render(<HoverScrubVideoThumb video={video} autoPlay />);
73
- expect(container.querySelector('video')).not.toBeNull();
73
+ // No IntersectionObserver in happy-dom → the tile counts as visible, and starts once settled.
74
+ await waitFor(() => expect(container.querySelector('video')).not.toBeNull());
74
75
  expect(queryByRole('button')).toBeNull();
75
76
  });
76
77
 
@@ -4,6 +4,7 @@ import type { PlayableVideo } from 'cursedbelt-core/media';
4
4
  import { formatTimecode } from 'cursedbelt-core/media';
5
5
  import { cn } from '../lib/cn.js';
6
6
  import { NO_EDIT, previewTransform, swapsAxes } from './pictureEdit.js';
7
+ import { releaseVideo, useInView, useSettled } from './previewGate.js';
7
8
  import type { MediaOrientation } from './VideoTurner.js';
8
9
 
9
10
  /**
@@ -104,7 +105,13 @@ export interface HoverScrubVideoThumbProps {
104
105
  * - `loop`: plays from the start, muted, looping.
105
106
  */
106
107
  previewMode?: 'scrub' | 'loop';
107
- /** Preview continuously without hover — the watch page's silent side tiles. */
108
+ /**
109
+ * Preview continuously without hover — the watch page's silent side tiles.
110
+ *
111
+ * 🔴 Gated: the preview starts only once the tile is ON SCREEN and has stayed there for
112
+ * `PREVIEW_SETTLE_MS` (`previewGate.ts`), so a grid scrolled past fast never opens a stream
113
+ * per row. Without an `IntersectionObserver` the tile counts as visible.
114
+ */
108
115
  autoPlay?: boolean;
109
116
  /** Small overlay chips (top-left slot) — e.g. a quality badge. */
110
117
  badge?: ReactNode;
@@ -149,7 +156,11 @@ export function HoverScrubVideoThumb({
149
156
  // On touch devices ignore `autoPlay` — an unrequested video playing under the
150
157
  // finger reads as a bug (and iOS shows a stack of them below the watch player).
151
158
  // Touch-and-hold still triggers `hovering` → a preview on demand.
152
- const previewing = (autoPlay && canHover) || hovering;
159
+ const boxRef = useRef<HTMLDivElement>(null);
160
+ const wantsAuto = autoPlay && canHover;
161
+ // A hover is not gated: the pointer on the tile IS the intent the settle exists to infer.
162
+ const autoReady = useSettled(useInView(boxRef, wantsAuto));
163
+ const previewing = autoReady || hovering;
153
164
  const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
154
165
  const videoRef = useRef<HTMLVideoElement>(null);
155
166
 
@@ -167,15 +178,23 @@ export function HoverScrubVideoThumb({
167
178
  durationSeconds: video.durationSeconds,
168
179
  });
169
180
 
181
+ // A preview that stops closes its connection — an unmounted `<video>` keeps buffering.
182
+ useEffect(() => {
183
+ if (!previewing) return;
184
+ const el = videoRef.current;
185
+ return () => releaseVideo(el);
186
+ }, [previewing]);
187
+
170
188
  const turned = turnStyle(orientation);
171
189
 
172
190
  const media = (
173
- <div className='relative aspect-video w-full overflow-hidden bg-muted'>
191
+ <div ref={boxRef} className='relative aspect-video w-full overflow-hidden bg-muted'>
174
192
  {video.posterUrl && !posterFailed ? (
175
193
  <img
176
194
  src={video.posterUrl}
177
195
  alt={video.name}
178
196
  loading='lazy'
197
+ decoding='async'
179
198
  /*
180
199
  * 🔴 A poster URL that 404s falls back to the Play mark, and this is
181
200
  * REQUIRED rather than defensive.
@@ -255,6 +274,9 @@ export function HoverScrubVideoThumb({
255
274
  <video
256
275
  ref={videoRef}
257
276
  src={video.streamUrl}
277
+ // Only mounted once previewing (settled or hovered), so metadata is the most it may
278
+ // fetch before it plays — never the whole-file `auto` a browser may default to.
279
+ preload='metadata'
258
280
  muted
259
281
  loop
260
282
  autoPlay
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The video half of the gallery's load ceiling (`previewGate.ts`): an automatic preview starts
3
+ * only once its tile is on screen AND has settled there, and a tile scrolled past never starts.
4
+ *
5
+ * happy-dom's `IntersectionObserver` never reports, so each test installs a fake it can drive —
6
+ * a fresh class per test, which is also what proves the shared observer follows a replaced
7
+ * global instead of observing through a stale one.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
10
+ import { act, cleanup, render, waitFor } from '@testing-library/react';
11
+ import type { PlayableVideo } from 'cursedbelt-core/media';
12
+ import { HoverScrubVideoThumb } from './HoverScrubVideoThumb.js';
13
+ import { PREVIEW_SETTLE_MS, releaseVideo } from './previewGate.js';
14
+
15
+ const video: PlayableVideo = {
16
+ id: 'v1',
17
+ name: 'Clip one',
18
+ streamUrl: 'https://example.test/v1.mp4',
19
+ posterUrl: 'https://example.test/v1.jpg',
20
+ durationSeconds: 90,
21
+ };
22
+
23
+ type Callback = (entries: Array<{ target: Element; isIntersecting: boolean }>) => void;
24
+ let observers: FakeObserver[] = [];
25
+
26
+ class FakeObserver {
27
+ targets = new Set<Element>();
28
+ constructor(private readonly callback: Callback) {
29
+ observers.push(this);
30
+ }
31
+ observe(el: Element) {
32
+ this.targets.add(el);
33
+ }
34
+ unobserve(el: Element) {
35
+ this.targets.delete(el);
36
+ }
37
+ disconnect() {
38
+ this.targets.clear();
39
+ }
40
+ takeRecords() {
41
+ return [];
42
+ }
43
+ /** On the PROTOTYPE, as the real interface has it — what `reportingObserver` checks for. */
44
+ get thresholds(): number[] {
45
+ return [0];
46
+ }
47
+ fire(isIntersecting: boolean) {
48
+ this.callback([...this.targets].map((target) => ({ target, isIntersecting })));
49
+ }
50
+ }
51
+
52
+ const g = globalThis as { IntersectionObserver?: unknown };
53
+ /** Whatever the environment had, restored after each test so no later spec file inherits a fake. */
54
+ const original = g.IntersectionObserver;
55
+
56
+ beforeEach(() => {
57
+ observers = [];
58
+ // A NEW class each test: the gate must rebuild its shared observer for a replaced global.
59
+ g.IntersectionObserver = class extends FakeObserver {};
60
+ });
61
+
62
+ afterEach(() => {
63
+ cleanup();
64
+ if (original === undefined) delete g.IntersectionObserver;
65
+ else g.IntersectionObserver = original;
66
+ });
67
+
68
+ const live = () => observers.find((o) => o.targets.size > 0) as FakeObserver;
69
+ const pause = (ms: number) =>
70
+ act(async () => {
71
+ await new Promise((resolve) => setTimeout(resolve, ms));
72
+ });
73
+
74
+ describe('previewGate — an automatic preview waits until the tile is on screen and settled', () => {
75
+ test('🔴 an off-screen autoPlay tile has no <video> — no src, nothing autoplaying', async () => {
76
+ const { container } = render(<HoverScrubVideoThumb video={video} onOpen={() => {}} autoPlay />);
77
+ await waitFor(() => expect(live()).toBeDefined());
78
+ act(() => live().fire(false));
79
+ await pause(PREVIEW_SETTLE_MS + 60);
80
+ expect(container.querySelector('video')).toBeNull();
81
+ // The poster still stands in, and it is itself lazy.
82
+ expect(container.querySelector('img')?.getAttribute('loading')).toBe('lazy');
83
+ });
84
+
85
+ test('in view: nothing at once, the preview after it settles — with preload capped', async () => {
86
+ const { container } = render(<HoverScrubVideoThumb video={video} onOpen={() => {}} autoPlay />);
87
+ await waitFor(() => expect(live()).toBeDefined());
88
+ act(() => live().fire(true));
89
+ // Not yet: a tile crossing the viewport during a scroll is exactly this moment.
90
+ expect(container.querySelector('video')).toBeNull();
91
+ await waitFor(() => expect(container.querySelector('video')).not.toBeNull());
92
+ const el = container.querySelector('video') as HTMLVideoElement;
93
+ expect(el.getAttribute('src')).toBe(video.streamUrl);
94
+ expect(el.getAttribute('preload')).toBe('metadata');
95
+ });
96
+
97
+ test('🔴 a tile that leaves before settling never starts its load — the fast scroll', async () => {
98
+ const { container } = render(<HoverScrubVideoThumb video={video} onOpen={() => {}} autoPlay />);
99
+ await waitFor(() => expect(live()).toBeDefined());
100
+ act(() => live().fire(true));
101
+ await pause(PREVIEW_SETTLE_MS / 3);
102
+ act(() => live().fire(false));
103
+ await pause(PREVIEW_SETTLE_MS + 60);
104
+ expect(container.querySelector('video')).toBeNull();
105
+ });
106
+
107
+ test('a preview that scrolls off stops at once and its connection is released', async () => {
108
+ const { container } = render(<HoverScrubVideoThumb video={video} onOpen={() => {}} autoPlay />);
109
+ await waitFor(() => expect(live()).toBeDefined());
110
+ act(() => live().fire(true));
111
+ await waitFor(() => expect(container.querySelector('video')).not.toBeNull());
112
+ const el = container.querySelector('video') as HTMLVideoElement;
113
+ act(() => live().fire(false));
114
+ expect(container.querySelector('video')).toBeNull();
115
+ // Removing the element does not close its request; emptying `src` does.
116
+ expect(el.hasAttribute('src')).toBe(false);
117
+ });
118
+
119
+ test('🔴 an INERT observer (happy-dom 20 ships one) counts as absent, so the tile still previews', async () => {
120
+ g.IntersectionObserver = class {
121
+ observe() {}
122
+ unobserve() {}
123
+ disconnect() {}
124
+ takeRecords() {
125
+ return [];
126
+ }
127
+ };
128
+ const { container } = render(<HoverScrubVideoThumb video={video} onOpen={() => {}} autoPlay />);
129
+ await waitFor(() => expect(container.querySelector('video')).not.toBeNull());
130
+ });
131
+
132
+ test('without autoPlay no tile is observed at all', () => {
133
+ render(<HoverScrubVideoThumb video={video} onOpen={() => {}} />);
134
+ expect(observers.every((o) => o.targets.size === 0)).toBe(true);
135
+ });
136
+
137
+ test('releaseVideo tolerates null and an element with no media support', () => {
138
+ expect(() => releaseVideo(null)).not.toThrow();
139
+ const el = document.createElement('video');
140
+ el.setAttribute('src', '/x.mp4');
141
+ releaseVideo(el);
142
+ expect(el.hasAttribute('src')).toBe(false);
143
+ });
144
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * When a tile is allowed to start a VIDEO load — the gallery's ceiling on byte loads.
3
+ *
4
+ * ── 🔴 Why this exists, from `collections`' 2026-09-22 measurement ──────────────────────
5
+ * One wheel-scroll top to bottom of a 240-row gallery had **176 same-origin requests open at
6
+ * once** (`apps/collections/e2e/mintCeiling.browser.test.ts`), and none of them were `fetch`
7
+ * calls an app-side gate could see: they were the tiles' own `<img>` and `<video>` loads. The
8
+ * `<img>` half is `loading='lazy'` + `decoding='async'` on every tile picture; this file is the
9
+ * `<video>` half. With "Preview all" on, every tile the virtualizer mounted — the visible rows
10
+ * AND its overscan — attached an autoplaying `<video>` the moment it rendered, so a fast
11
+ * scroll opened a stream per row it passed and then abandoned it.
12
+ *
13
+ * So an automatic preview now waits for two things: the tile is actually on screen
14
+ * ({@link useInView}), and it has STAYED there for {@link PREVIEW_SETTLE_MS}
15
+ * ({@link useSettled}). A tile scrolled past faster than that never starts its load at all,
16
+ * and one that leaves before settling cancels the pending start. A deliberate hover is not
17
+ * gated — the pointer being on the tile is the intent the settle exists to infer.
18
+ *
19
+ * {@link releaseVideo} is the other end: a `<video>` React removes keeps downloading until it
20
+ * is garbage-collected, so a tile that stops previewing empties the element's source, which is
21
+ * what actually closes the connection.
22
+ */
23
+ import { useEffect, useState } from 'react';
24
+
25
+ /**
26
+ * How long a tile must stay on screen before an automatic preview starts. Long enough that a
27
+ * wheel flick past a row is well under it (a row crosses the viewport in tens of ms at scroll
28
+ * speed), short enough that a grid the viewer stopped on starts moving without a visible wait.
29
+ */
30
+ export const PREVIEW_SETTLE_MS = 200;
31
+
32
+ type Listener = (inView: boolean) => void;
33
+
34
+ /**
35
+ * ONE observer for every tile, not one per tile — a 400-row gallery is 400 elements and a
36
+ * single `IntersectionObserver` handles that in one callback per frame.
37
+ *
38
+ * Keyed by the constructor it was built with so a replaced global (a spec installing its own
39
+ * fake, a polyfill arriving late) is picked up instead of observing through a stale one.
40
+ */
41
+ let shared: { ctor: typeof IntersectionObserver; observer: IntersectionObserver } | null = null;
42
+ const listeners = new Map<Element, Set<Listener>>();
43
+
44
+ /**
45
+ * The environment's `IntersectionObserver`, but only one that can actually REPORT.
46
+ *
47
+ * 🔴 happy-dom 20 ships an inert stub — `observe()` is an empty method and the callback is
48
+ * never called — so "is it defined" is the wrong test: every tile would wait for a
49
+ * notification that cannot arrive and no preview would ever start, in this repo's specs and in
50
+ * every consumer's. The real interface carries `thresholds` as a readonly attribute on its
51
+ * PROTOTYPE (every engine since 2019); the stub does not. An observer without it is treated as
52
+ * absent, which degrades to the eager behavior every gallery had before this gate.
53
+ */
54
+ function reportingObserver(): typeof IntersectionObserver | null {
55
+ const ctor = (globalThis as { IntersectionObserver?: typeof IntersectionObserver })
56
+ .IntersectionObserver;
57
+ if (typeof ctor !== 'function') return null;
58
+ return 'thresholds' in ctor.prototype ? ctor : null;
59
+ }
60
+
61
+ function sharedObserver(): IntersectionObserver | null {
62
+ const ctor = reportingObserver();
63
+ if (!ctor) return null;
64
+ if (shared?.ctor === ctor) return shared.observer;
65
+ shared?.observer.disconnect();
66
+ const observer = new ctor((entries) => {
67
+ for (const entry of entries) {
68
+ const set = listeners.get(entry.target);
69
+ if (!set) continue;
70
+ for (const listener of set) listener(entry.isIntersecting);
71
+ }
72
+ });
73
+ shared = { ctor, observer };
74
+ // Anything already registered moves to the new observer rather than going deaf.
75
+ for (const el of listeners.keys()) observer.observe(el);
76
+ return observer;
77
+ }
78
+
79
+ /**
80
+ * True while the element intersects the viewport (clipped by its scrolling ancestors, which
81
+ * is what an `IntersectionObserver` with no root measures).
82
+ *
83
+ * 🔴 **No `IntersectionObserver` means VISIBLE**, not hidden. jsdom and old browsers have none,
84
+ * happy-dom has one that never reports (see {@link reportingObserver}), and a gallery whose previews silently never start there is a worse failure than
85
+ * one that loads eagerly — the eager load is the behavior every one of them had before this.
86
+ *
87
+ * `enabled: false` observes nothing and reports false, so a gallery that never asked for
88
+ * automatic previews registers no element at all.
89
+ */
90
+ export function useInView(ref: { current: Element | null }, enabled: boolean): boolean {
91
+ const [inView, setInView] = useState(false);
92
+ useEffect(() => {
93
+ if (!enabled) {
94
+ setInView(false);
95
+ return;
96
+ }
97
+ const el = ref.current;
98
+ const observer = sharedObserver();
99
+ if (!el || !observer) {
100
+ setInView(true);
101
+ return;
102
+ }
103
+ const listener: Listener = (next) => setInView(next);
104
+ let set = listeners.get(el);
105
+ if (!set) {
106
+ set = new Set();
107
+ listeners.set(el, set);
108
+ observer.observe(el);
109
+ }
110
+ set.add(listener);
111
+ return () => {
112
+ const current = listeners.get(el);
113
+ if (!current) return;
114
+ current.delete(listener);
115
+ if (current.size === 0) {
116
+ listeners.delete(el);
117
+ shared?.observer.unobserve(el);
118
+ }
119
+ };
120
+ // `ref` is a ref object and is stable by construction.
121
+ }, [enabled, ref]);
122
+ return enabled && inView;
123
+ }
124
+
125
+ /**
126
+ * `value`, but it only turns TRUE after holding true for `ms` — and turns false at once.
127
+ *
128
+ * The asymmetry is the point: starting a load is what costs, stopping one is what saves, so a
129
+ * flicker of `true` (a tile crossing the viewport during a scroll) never reaches the load and
130
+ * a `false` is never delayed.
131
+ */
132
+ export function useSettled(value: boolean, ms: number = PREVIEW_SETTLE_MS): boolean {
133
+ const [settled, setSettled] = useState(false);
134
+ useEffect(() => {
135
+ if (!value) {
136
+ setSettled(false);
137
+ return;
138
+ }
139
+ const timer = setTimeout(() => setSettled(true), ms);
140
+ return () => clearTimeout(timer);
141
+ }, [value, ms]);
142
+ return value && settled;
143
+ }
144
+
145
+ /**
146
+ * Close a `<video>`'s connection. Removing the element from the DOM does not — it keeps
147
+ * buffering until it is collected — so emptying `src` and calling `load()` is the documented
148
+ * way to make the browser drop the request (WHATWG media "emptied" / load algorithm).
149
+ * Tolerates a missing element and a `load` the environment does not implement.
150
+ */
151
+ export function releaseVideo(el: HTMLVideoElement | null): void {
152
+ if (!el) return;
153
+ try {
154
+ el.pause?.();
155
+ el.removeAttribute('src');
156
+ el.load?.();
157
+ } catch {
158
+ // A DOM without media support has no connection to close.
159
+ }
160
+ }
@@ -37,6 +37,7 @@ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
37
37
  import { CATEGORY_LABEL } from '../components/FileTypeIcon.js';
38
38
  import { useResizeObserver } from '../hooks/useResizeObserver.js';
39
39
  import { useCanHover, useScrubPreview } from '../media/HoverScrubVideoThumb.js';
40
+ import { releaseVideo, useInView, useSettled } from '../media/previewGate.js';
40
41
  import { createdLabel, type MediaCache, ThumbImg, useItemMedia } from './galleryItemMedia.js';
41
42
  import { galleryItemCategory } from './mediaGalleryModel.js';
42
43
  import type { MediaGalleryItem, MediaGalleryOrder } from './types.js';
@@ -411,12 +412,19 @@ function TableVideoPreview({
411
412
  }) {
412
413
  const ref = useRef<HTMLVideoElement | null>(null);
413
414
  useScrubPreview(ref, { active, durationSeconds: item.durationSeconds });
415
+ // Removing the element does not close its connection; emptying its source does.
416
+ useEffect(() => {
417
+ const el = ref.current;
418
+ return () => releaseVideo(el);
419
+ }, []);
414
420
  return (
415
421
  // biome-ignore lint/a11y/useMediaCaption: a muted 56px preview of an arbitrary user file
416
422
  <video
417
423
  ref={ref}
418
424
  src={src}
419
425
  poster={poster}
426
+ // Mounted only once previewing, so metadata is the most it fetches before it plays.
427
+ preload='metadata'
420
428
  muted
421
429
  loop
422
430
  autoPlay
@@ -477,7 +485,11 @@ function GalleryTableRow({
477
485
  const previewSrc = category === 'video' ? (media.streamUrl ?? media.url) : undefined;
478
486
  // `autoPreview` is suppressed on a coarse pointer exactly as the grid tile suppresses it, so
479
487
  // a phone never opens forty video connections under a finger. A deliberate hover still does.
480
- const previewing = previewSrc != null && ((autoPreview && canHover) || hovering);
488
+ // 🔴 And it waits until the row is on screen and has SETTLED there (`previewGate.ts`), the
489
+ // same gate the grid tile uses — a table scrolled past fast must not open a stream per row.
490
+ const thumbRef = useRef<HTMLButtonElement | null>(null);
491
+ const autoReady = useSettled(useInView(thumbRef, previewSrc != null && autoPreview && canHover));
492
+ const previewing = previewSrc != null && (autoReady || hovering);
481
493
 
482
494
  return (
483
495
  <tr
@@ -523,6 +535,7 @@ function GalleryTableRow({
523
535
  <button
524
536
  // guardrails-ignore no-raw-action-button: the row's own open affordance; see above.
525
537
  type='button'
538
+ ref={thumbRef}
526
539
  className='cbgt-open cbgt-open--thumb'
527
540
  onClick={onOpen}
528
541
  aria-label={`Open ${item.name}`}
@@ -166,6 +166,50 @@ describe('MediaGallery browse grid', () => {
166
166
  );
167
167
  expect(calls).toBe(1);
168
168
  });
169
+
170
+ test('🔴 grid tiles AND table rows draw their pictures lazy + async (the load ceiling)', () => {
171
+ const { getByRole, container } = render(<MediaGallery items={ITEMS} />);
172
+ const gridImgs = [...container.querySelectorAll('.cbgd-photo-tile img')];
173
+ expect(gridImgs.length).toBeGreaterThan(0);
174
+ for (const img of gridImgs) {
175
+ expect(img.getAttribute('loading')).toBe('lazy');
176
+ expect(img.getAttribute('decoding')).toBe('async');
177
+ }
178
+ fireEvent.click(getByRole('button', { name: /Table/ }));
179
+ const rowImgs = [...container.querySelectorAll('.cbgt-root img')];
180
+ expect(rowImgs.length).toBeGreaterThan(0);
181
+ for (const img of rowImgs) {
182
+ expect(img.getAttribute('loading')).toBe('lazy');
183
+ expect(img.getAttribute('decoding')).toBe('async');
184
+ }
185
+ });
186
+
187
+ test('🔴 toggling grid → table mid-resolve neither aborts nor re-asks (one shared entry)', async () => {
188
+ const signals: AbortSignal[] = [];
189
+ let answer: ((m: { url: string; thumbnailUrl: string }) => void) | undefined;
190
+ const lazy: MediaGalleryItem = {
191
+ id: 'lazy2',
192
+ name: 'slow.png',
193
+ resolveMedia: (signal) =>
194
+ new Promise((resolve) => {
195
+ if (signal) signals.push(signal);
196
+ answer = resolve;
197
+ }),
198
+ };
199
+ const { getByRole, container } = render(<MediaGallery items={[lazy]} />);
200
+ expect(signals).toHaveLength(1);
201
+ // Every grid tile unmounts before any table row mounts — the moment a naive refcount aborts.
202
+ fireEvent.click(getByRole('button', { name: /Table/ }));
203
+ await new Promise((resolve) => setTimeout(resolve, 0));
204
+ expect(signals).toHaveLength(1);
205
+ expect(signals[0]?.aborted).toBe(false);
206
+ answer?.({ url: '/resolved/lazy2', thumbnailUrl: '/resolved-thumb/lazy2' });
207
+ await waitFor(() =>
208
+ expect(container.querySelector('.cbgt-root img')?.getAttribute('src')).toBe(
209
+ '/resolved-thumb/lazy2',
210
+ ),
211
+ );
212
+ });
169
213
  });
170
214
 
171
215
  describe('MediaGallery multi-select (opt-in)', () => {
@@ -1723,13 +1767,16 @@ describe('🔴 Preview all reaches the TABLE, not only the grid', () => {
1723
1767
  fireEvent.click(view.getByRole('button', { name: /Table/ }));
1724
1768
  };
1725
1769
 
1726
- test('a video row swaps its still for a live preview when the toggle is on', () => {
1770
+ test('a video row swaps its still for a live preview when the toggle is on', async () => {
1727
1771
  const view = render(<MediaGallery items={ITEMS} />);
1728
1772
  showTable(view);
1729
1773
  // Off: every row is a still, the film's included.
1730
1774
  expect(view.container.querySelector('.cbgt-root video')).toBeNull();
1731
1775
 
1732
1776
  fireEvent.click(view.getByRole('button', { name: /Preview all/ }));
1777
+ // Not at once: an automatic preview waits for the row to SETTLE (`previewGate.ts`).
1778
+ expect(view.container.querySelector('.cbgt-root video')).toBeNull();
1779
+ await waitFor(() => expect(view.container.querySelector('.cbgt-root video')).not.toBeNull());
1733
1780
  const preview = view.container.querySelector('.cbgt-root video') as HTMLVideoElement;
1734
1781
  expect(preview).not.toBeNull();
1735
1782
  expect(preview.getAttribute('src')).toBe('/files/vid1');
@@ -1743,12 +1790,14 @@ describe('🔴 Preview all reaches the TABLE, not only the grid', () => {
1743
1790
  fireEvent.click(view.getByRole('button', { name: /Preview all/ }));
1744
1791
  });
1745
1792
 
1746
- test('a STILL row is untouched by the toggle', () => {
1793
+ test('a STILL row is untouched by the toggle', async () => {
1747
1794
  const view = render(<MediaGallery items={ITEMS} />);
1748
1795
  showTable(view);
1749
1796
  fireEvent.click(view.getByRole('button', { name: /Preview all/ }));
1750
1797
  // beach.jpg and report.pdf keep their images; only the film becomes a `<video>`.
1751
- expect(view.container.querySelectorAll('.cbgt-root video')).toHaveLength(1);
1798
+ await waitFor(() =>
1799
+ expect(view.container.querySelectorAll('.cbgt-root video')).toHaveLength(1),
1800
+ );
1752
1801
  fireEvent.click(view.getByRole('button', { name: /Preview all/ }));
1753
1802
  });
1754
1803
 
@@ -735,7 +735,15 @@ function DocThumb({
735
735
  <>
736
736
  {/* `key={src}`: a new thumbnail URL is a new attempt and must not inherit the
737
737
  previous one's recorded failure. */}
738
- <img key={src} src={src} alt={alt} loading='lazy' onError={() => setFailed(true)} />
738
+ <img
739
+ key={src}
740
+ src={src}
741
+ alt={alt}
742
+ // Lazy + async like every tile picture — see `ThumbImg` for the measurement.
743
+ loading='lazy'
744
+ decoding='async'
745
+ onError={() => setFailed(true)}
746
+ />
739
747
  <span className='cbmg-doc-badge'>
740
748
  <FileTypeIcon category={category} />
741
749
  </span>