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.
- package/dist/react/media/HoverScrubVideoThumb.d.ts +7 -1
- package/dist/react/media/HoverScrubVideoThumb.d.ts.map +1 -1
- package/dist/react/media/HoverScrubVideoThumb.js +18 -3
- package/dist/react/media/HoverScrubVideoThumb.js.map +1 -1
- package/dist/react/media/previewGate.d.ts +36 -0
- package/dist/react/media/previewGate.d.ts.map +1 -0
- package/dist/react/media/previewGate.js +161 -0
- package/dist/react/media/previewGate.js.map +1 -0
- package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +15 -3
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +3 -1
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts +32 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.js +64 -9
- package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +8 -1
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -1
- package/package.json +3 -1
- package/src/declaredImports.spec.ts +66 -0
- package/src/publicSurface.spec.ts +12 -4
- package/src/react/media/HoverScrubVideoThumb.spec.tsx +4 -3
- package/src/react/media/HoverScrubVideoThumb.tsx +25 -3
- package/src/react/media/previewGate.spec.tsx +144 -0
- package/src/react/media/previewGate.ts +160 -0
- package/src/react/media-gallery/GalleryTable.tsx +14 -1
- package/src/react/media-gallery/MediaGallery.spec.tsx +52 -3
- package/src/react/media-gallery/MediaGallery.tsx +9 -1
- package/src/react/media-gallery/galleryItemMedia.spec.tsx +143 -0
- package/src/react/media-gallery/galleryItemMedia.tsx +80 -9
- package/src/react/media-gallery/types.ts +8 -1
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -1
- package/scripts/publicSurface.ts +0 -458
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
|
-
import {
|
|
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 `
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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>
|