polite-media 0.3.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 (60) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/LICENSE +21 -0
  3. package/README.md +675 -0
  4. package/dist/coordinator.d.ts +231 -0
  5. package/dist/coordinator.d.ts.map +1 -0
  6. package/dist/coordinator.js +1017 -0
  7. package/dist/coordinator.js.map +1 -0
  8. package/dist/env.d.ts +30 -0
  9. package/dist/env.d.ts.map +1 -0
  10. package/dist/env.js +49 -0
  11. package/dist/env.js.map +1 -0
  12. package/dist/events.d.ts +70 -0
  13. package/dist/events.d.ts.map +1 -0
  14. package/dist/events.js +42 -0
  15. package/dist/events.js.map +1 -0
  16. package/dist/image.css +1 -0
  17. package/dist/image.d.ts +45 -0
  18. package/dist/image.d.ts.map +1 -0
  19. package/dist/image.js +127 -0
  20. package/dist/image.js.map +1 -0
  21. package/dist/layer.css +1 -0
  22. package/dist/reveal.d.ts +34 -0
  23. package/dist/reveal.d.ts.map +1 -0
  24. package/dist/reveal.js +72 -0
  25. package/dist/reveal.js.map +1 -0
  26. package/dist/sources.d.ts +22 -0
  27. package/dist/sources.d.ts.map +1 -0
  28. package/dist/sources.js +148 -0
  29. package/dist/sources.js.map +1 -0
  30. package/dist/targets.d.ts +20 -0
  31. package/dist/targets.d.ts.map +1 -0
  32. package/dist/targets.js +17 -0
  33. package/dist/targets.js.map +1 -0
  34. package/dist/video.css +1 -0
  35. package/dist/video.d.ts +15 -0
  36. package/dist/video.d.ts.map +1 -0
  37. package/dist/video.js +17 -0
  38. package/dist/video.js.map +1 -0
  39. package/dist/warm.d.ts +13 -0
  40. package/dist/warm.d.ts.map +1 -0
  41. package/dist/warm.js +12 -0
  42. package/dist/warm.js.map +1 -0
  43. package/dist/warming.d.ts +62 -0
  44. package/dist/warming.d.ts.map +1 -0
  45. package/dist/warming.js +136 -0
  46. package/dist/warming.js.map +1 -0
  47. package/package.json +96 -0
  48. package/src/coordinator.ts +1337 -0
  49. package/src/env.ts +56 -0
  50. package/src/events.ts +78 -0
  51. package/src/image.css +74 -0
  52. package/src/image.ts +160 -0
  53. package/src/layer.css +60 -0
  54. package/src/reveal.ts +75 -0
  55. package/src/sources.ts +164 -0
  56. package/src/targets.ts +27 -0
  57. package/src/video.css +74 -0
  58. package/src/video.ts +32 -0
  59. package/src/warm.ts +12 -0
  60. package/src/warming.ts +162 -0
@@ -0,0 +1,1337 @@
1
+ import { connectionAllowsMedia, mediaQuery, motionAllowed } from './env.js';
2
+ import {
3
+ POLITE_VIDEO_PAUSECHANGE,
4
+ POLITE_VIDEO_FAILED,
5
+ POLITE_VIDEO_READY,
6
+ type PolitePauseEventDetail,
7
+ type PoliteVideoEventDetail,
8
+ } from './events.js';
9
+ import { revealWhenPainted } from './reveal.js';
10
+ import { isUnusable, manageSources, resetSourceWarnings, type SourceManager } from './sources.js';
11
+ import { resolveTargets, type Target } from './targets.js';
12
+
13
+ /**
14
+ * @module
15
+ * The single arbiter of what plays. Nothing outside this module calls `play()`
16
+ * or `pause()`, so "why is this video running" always has one answer.
17
+ *
18
+ * The `@module` tag matters: without it TypeScript attaches this preamble to the
19
+ * first declaration below, and a consumer hovering that symbol gets a sentence
20
+ * about internal call discipline instead of its own documentation.
21
+ */
22
+
23
+ /**
24
+ * Deliberately three cases rather than a number. These are the ones that exist,
25
+ * the type system does the checking a bare `number` would invite (`0.5`), and it
26
+ * avoids inventing an answer to which of several incumbents a rival displaces --
27
+ * the incumbent's hysteresis has clean semantics only for a single slot.
28
+ */
29
+ export type AtOnce = 0 | 1 | 'all';
30
+
31
+ /**
32
+ * How patient a video is about starting, as a genuine ladder: each value waits
33
+ * for everything the one before it did, and then something more.
34
+ *
35
+ * Buffering is deliberately not a fourth value here. It is a different question
36
+ * and lives on {@link ConfigureOptions.requireBuffered}, so the two compose.
37
+ */
38
+ export type StartWhen = 'visible' | 'page-loaded' | 'interaction';
39
+
40
+ /**
41
+ * Options for {@link configure}. Every field is optional; anything left out keeps
42
+ * its default.
43
+ *
44
+ * All-optional deliberately. As a fully required interface this could not be used
45
+ * for what a consumer naturally reaches for -- `const preset: ConfigureOptions =
46
+ * { atOnce: 0 }` -- and only the inline `configure({ ... })` form worked, via
47
+ * contextual typing.
48
+ */
49
+ export interface ConfigureOptions {
50
+ /**
51
+ * How far outside the viewport a video starts buffering, so it is ready by the
52
+ * time it arrives. `'200px'` on a feed is the difference between a card that
53
+ * plays as it lands and one that shows its poster first.
54
+ *
55
+ * Defaults to `'0px'`: buffering video a visitor may never scroll to is the
56
+ * opposite of what this package is for, and on a twelve-card grid it would
57
+ * fetch twelve files.
58
+ *
59
+ * This drives a second observer of its own, and deliberately does not touch
60
+ * the thresholds. `intersectionRatio` is measured against the root *including*
61
+ * the margin, so a single observer made every threshold mean less than it
62
+ * said: measured on a 368px card at a 50px margin, 25% on screen reported 0.39
63
+ * and `pauseBelow: 0.25` actually stopped the video at about 10% visible, with
64
+ * the error scaling by element height. The observer that decides playback
65
+ * keeps no margin, so a fraction is always the true visible fraction.
66
+ */
67
+ prefetchMargin?: string;
68
+ /**
69
+ * Which viewports count as small. Configurable because 767px is one project's
70
+ * breakpoint, not a fact about phones.
71
+ */
72
+ smallViewport?: string;
73
+ /**
74
+ * How many videos may run at once.
75
+ *
76
+ * - `'all'` lets every visible video play. A bento grid of cards whose content
77
+ * *is* the video is meant to move.
78
+ * - `1` gives one video the screen at a time, the rest holding their posters.
79
+ * A feed wants this: the eye has one subject, and the handover happens as
80
+ * the next card takes the slot.
81
+ * - `0` never starts a video, spending nothing on data or battery.
82
+ *
83
+ * Pass an object to split the answer by viewport, which is the default:
84
+ * `{ small: 1, large: 'all' }`. Phones have far less decode headroom than
85
+ * desktops -- three concurrent H.264 streams while compositing drops frames
86
+ * badly on real hardware -- so they arbitrate while a desktop does not.
87
+ * {@link ConfigureOptions.smallViewport} decides which side a viewport is on.
88
+ *
89
+ * No standards or platform source recommends any of these; the default is the
90
+ * one with device testing behind it.
91
+ */
92
+ atOnce?: AtOnce | { small: AtOnce; large: AtOnce };
93
+ /**
94
+ * Visible fraction, 0 to 1, at or below which a video stops.
95
+ *
96
+ * Defaults to `0.5`: a video runs while it is the thing you are looking at and
97
+ * stops once it is mostly gone. At `0` it stopped only when entirely off
98
+ * screen, so one hanging on by a sliver effectively never stopped.
99
+ *
100
+ * **This caps how tall a managed video can be.** `intersectionRatio` is a
101
+ * fraction of the *element*, and with no margin on the playback observer its
102
+ * ceiling is `viewport / height` -- so anything taller than twice the viewport
103
+ * can never reach `0.5` and would never play. Measured at a 953px viewport:
104
+ * 1.5x viewport height peaks at 0.667 and 3x at 0.333. Lower it for a tall
105
+ * video, or shorten the box.
106
+ */
107
+ pauseBelow?: number;
108
+ /**
109
+ * How patient a video is about starting. Each rung is strictly more patient
110
+ * than the last.
111
+ *
112
+ * - `'visible'` starts fetching the moment the video is on screen. Module
113
+ * scripts are deferred, so on a real page that lands inside the tail of the
114
+ * page's own loading and competes with it.
115
+ * - `'page-loaded'` waits for `window`'s `load` event first, so the video
116
+ * competes with nothing the page still needs. The default.
117
+ * - `'interaction'` additionally waits for the visitor: the first
118
+ * `pointerdown`, `keydown` or `scroll`.
119
+ *
120
+ * `'page-loaded'` is the default because a video that never plays reads as
121
+ * broken. It keeps the part that matters most: the fetch happens after `load`,
122
+ * so those bytes never compete with the page's own.
123
+ *
124
+ * **Reach for `'interaction'` when Largest Contentful Paint matters.** The
125
+ * browser stops updating LCP on "a tap, scroll, or keypress"
126
+ * (https://web.dev/articles/lcp), so a video revealed after that signal can
127
+ * never become the LCP element, and a synthetic audit, which never interacts,
128
+ * never starts it at all. The cost is a visitor who lands and never scrolls,
129
+ * taps or types: they see a still.
130
+ *
131
+ * Below the fold the choice barely matters, since a video down there cannot be
132
+ * seen without scrolling and scrolling is the interaction. It is a policy for
133
+ * whatever is on screen at load, which in practice means the hero.
134
+ *
135
+ * Two things compose with this rather than replacing it.
136
+ * {@link ConfigureOptions.requireBuffered} asks for data as well as patience,
137
+ * and `until` gates one video on your own promise while this is the policy for
138
+ * all of them. A video waits for every gate that applies to it.
139
+ */
140
+ startWhen?: StartWhen;
141
+ /**
142
+ * Hold playback until the video can play through without stalling.
143
+ *
144
+ * Separate from {@link ConfigureOptions.startWhen} because they answer
145
+ * different questions: that one is *when may it begin*, this one is *how much
146
+ * data first*. As a fourth `startWhen` value it competed with `'interaction'`,
147
+ * so "wait for the user, and also wait for the buffer" could not be said at
148
+ * all.
149
+ *
150
+ * Raises `preload` to `'auto'` when it prepares, which it has to: the markup
151
+ * contract says `preload="none"`, and a browser buffers nothing until playback
152
+ * is asked for, so waiting for `canplaythrough` without the promotion would
153
+ * wait forever.
154
+ */
155
+ requireBuffered?: boolean;
156
+ }
157
+
158
+ /**
159
+ * Every field resolved. Internal: a caller never has to supply all six, which is
160
+ * what {@link ConfigureOptions} is for.
161
+ */
162
+ type ResolvedConfig = Required<ConfigureOptions>;
163
+
164
+ const defaults: ResolvedConfig = {
165
+ prefetchMargin: '0px',
166
+ smallViewport: '(max-width: 767px)',
167
+ atOnce: { small: 1, large: 'all' },
168
+ pauseBelow: 0.5,
169
+ startWhen: 'page-loaded',
170
+ requireBuffered: false,
171
+ };
172
+
173
+ /**
174
+ * How much more visible a rival must be before it takes the single slot. Without
175
+ * it, a carousel's peeking neighbour flaps the slot back and forth.
176
+ *
177
+ * A constant rather than an option: it is the tolerance that makes arbitration
178
+ * stable, not a policy anyone has a view on, and a value chosen without watching
179
+ * a carousel does not fail loudly -- it just reintroduces the flapping. Only
180
+ * live when {@link ConfigureOptions.atOnce} limits to a single slot.
181
+ */
182
+ const HYSTERESIS = 0.15;
183
+
184
+ /**
185
+ * Anti-flicker debounce for a video wobbling at the viewport edge. Not a window
186
+ * in which offscreen video is meant to keep decoding: leaving the viewport
187
+ * should read as stopping immediately, which is why only the fell-out-of-view
188
+ * path waits and a video that lost its slot stops at once.
189
+ *
190
+ * A constant for the same reason as {@link HYSTERESIS}. Raising it does not read
191
+ * as a setting, it reads as offscreen video that keeps decoding.
192
+ */
193
+ const PAUSE_GRACE_MS = 400;
194
+
195
+ let config: ResolvedConfig = { ...defaults };
196
+
197
+ /**
198
+ * Whether the page has finished loading.
199
+ *
200
+ * Read from `readyState` rather than latched by the `load` listener, because a
201
+ * module imported *after* load -- a late script, or a client-side navigation --
202
+ * would otherwise wait forever for an event that has already fired. The listener
203
+ * exists only to re-run the arbiter when the moment arrives.
204
+ */
205
+ function pageLoaded(): boolean {
206
+ return document.readyState === 'complete';
207
+ }
208
+
209
+ /**
210
+ * `HTMLMediaElement.HAVE_ENOUGH_DATA`. Inlined for the same reason reveal.ts
211
+ * inlines its own: this module never touches `HTMLMediaElement`, which does not
212
+ * exist in Node, and `test/node-import.test.ts` holds that line.
213
+ */
214
+ const HAVE_ENOUGH_DATA = 4;
215
+
216
+ /**
217
+ * Settings captured when the observer and the lifecycle listeners are built, at
218
+ * the first `register()`. Patching one later is rejected rather than ignored,
219
+ * because "ignored" is not what actually happened:
220
+ *
221
+ * - `pauseBelow` half-applies. Eligibility reads it live while the threshold
222
+ * ladder was fixed at construction, so a late 0.4 takes effect at 0.25, the
223
+ * nearest crossing the observer still reports. That is the exact silent lie
224
+ * `thresholds()` exists to prevent, reached through a second door.
225
+ * - `smallViewport` used to strand a listener. `mediaQuery` memoises by string,
226
+ * so detaching would resolve a different MediaQueryList than attaching did.
227
+ * - `prefetchMargin` builds the prefetch observer, at that same first `register()`.
228
+ * A late patch would not reach the one already built.
229
+ */
230
+ const CONSTRUCTION_TIME_KEYS = ['prefetchMargin', 'pauseBelow', 'smallViewport'] as const;
231
+
232
+ /**
233
+ * Call before the first `register`.
234
+ *
235
+ * `atOnce`, `startWhen` and `requireBuffered` are read on every reconcile, so
236
+ * they can be changed at any time and take effect on the next pass. The three
237
+ * keys in {@link CONSTRUCTION_TIME_KEYS} cannot, and throw if patched while
238
+ * videos are registered. Unregister everything first, or configure earlier.
239
+ */
240
+ export function configure(patch: ConfigureOptions): void {
241
+ validate(patch);
242
+
243
+ if (observer !== null) {
244
+ const late = CONSTRUCTION_TIME_KEYS.filter((key) => patch[key] !== undefined);
245
+ if (late.length > 0) {
246
+ throw new Error(
247
+ `polite-media: ${late.join(', ')} must be configured before the first register(); ` +
248
+ 'they are read when the observer is built. Unregister everything first.'
249
+ );
250
+ }
251
+ }
252
+
253
+ config = { ...config, ...patch };
254
+ }
255
+
256
+ /**
257
+ * `pauseBelow` and `prefetchMargin` do reach a platform API, through the threshold
258
+ * ladder and the observer options. Chromium rejects both -- a RangeError outside
259
+ * 0..1, a TypeError for NaN or Infinity, a SyntaxError for a malformed margin --
260
+ * but not until the IntersectionObserver constructor runs at the first
261
+ * `register()`, arbitrarily far from the `configure()` call responsible. So these
262
+ * checks relocate the browser's own error to the call that caused it.
263
+ *
264
+ * `smallViewport` is the one that cannot be checked. An invalid media query does
265
+ * not throw and does not normalise to something recognisable: Chromium echoes
266
+ * the malformed text straight back through `MediaQueryList.media` and simply
267
+ * never matches. So `smallViewport: '(max-width: 767)'`, one missing unit, means
268
+ * arbitration silently never engages and phones behave like desktops. Only the
269
+ * obviously empty case is caught; the rest is a documentation problem.
270
+ */
271
+ function validate(patch: ConfigureOptions): void {
272
+ const { pauseBelow } = patch;
273
+ if (
274
+ pauseBelow !== undefined &&
275
+ (!Number.isFinite(pauseBelow) || pauseBelow < 0 || pauseBelow > 1)
276
+ ) {
277
+ throw new RangeError(
278
+ `polite-media: pauseBelow must be a fraction between 0 and 1, got ${pauseBelow}`
279
+ );
280
+ }
281
+
282
+ // A `2` would otherwise behave as 1: it is neither 'all' nor 0, so it falls
283
+ // through to the single-slot branch and silently means something else.
284
+ if (patch.atOnce !== undefined) {
285
+ const values = typeof patch.atOnce === 'object' ? Object.values(patch.atOnce) : [patch.atOnce];
286
+ for (const value of values) {
287
+ if (value !== 0 && value !== 1 && value !== 'all') {
288
+ throw new RangeError(`polite-media: atOnce must be 0, 1 or 'all', got ${String(value)}`);
289
+ }
290
+ }
291
+ }
292
+
293
+ if (patch.smallViewport !== undefined && patch.smallViewport.trim() === '') {
294
+ throw new SyntaxError('polite-media: smallViewport must be a media query, got an empty string');
295
+ }
296
+
297
+ // prefetchMargin is handed to the platform to parse rather than checked by hand:
298
+ // the accepted grammar is CSS margin syntax and reimplementing it here would
299
+ // be a second, worse parser that drifts. Constructing a throwaway observer
300
+ // raises the browser's own SyntaxError now, at the configure() call, instead
301
+ // of at the first register().
302
+ //
303
+ // Skipped where IntersectionObserver does not exist, so that importing this
304
+ // module and configuring it under SSR or in a Node test still works.
305
+ if (patch.prefetchMargin !== undefined && typeof IntersectionObserver === 'function') {
306
+ new IntersectionObserver(() => {}, { rootMargin: patch.prefetchMargin }).disconnect();
307
+ }
308
+ }
309
+
310
+ export interface RegisterOptions {
311
+ /**
312
+ * Hold this video out of the arbiter's reach until the promise settles. A hero
313
+ * at scroll-top is reported visible in the observer's very first batch, so
314
+ * without a gate it starts before whatever the page is waiting on (a splash
315
+ * screen, a consent dialog) has finished.
316
+ */
317
+ until?: Promise<unknown>;
318
+ /**
319
+ * Element to observe instead of the video, for when the video is absolutely
320
+ * positioned inside a wrapper that carries the real layout box.
321
+ */
322
+ observe?: Element;
323
+ /**
324
+ * Override the page's {@link ConfigureOptions.startWhen} for this video.
325
+ *
326
+ * A page usually wants one policy, but not always: only a video that can be
327
+ * the LCP element needs the strictest gate, and holding a below-fold grid to
328
+ * the same rule buys nothing.
329
+ */
330
+ startWhen?: StartWhen;
331
+ }
332
+
333
+ interface Entry {
334
+ video: HTMLVideoElement;
335
+ target: Element;
336
+ /** Element carrying reveal state, so CSS can drive poster and video together. */
337
+ host: Element;
338
+ /** How much of the target is visible; 0 when offscreen. */
339
+ ratio: number;
340
+ /** Held out of arbitration until `until` settles. */
341
+ gated: boolean;
342
+ /**
343
+ * This element has been in the document at least once.
344
+ *
345
+ * Gates the disconnected sweep, which means "the page discarded this" -- and an
346
+ * element never in the page cannot have been discarded. Measured in Chromium:
347
+ * observing a detached target reports it immediately with isIntersecting false,
348
+ * so without this, registering a video before appending it is silently undone
349
+ * on the very first batch.
350
+ */
351
+ seenConnected: boolean;
352
+ /**
353
+ * Within `prefetchMargin` of the viewport, per the prefetch observer.
354
+ *
355
+ * Kept because prefetching can be refused for a reason that later goes away --
356
+ * the page still loading, most often -- and the observer does not report a
357
+ * target again just because it is still where it was.
358
+ */
359
+ nearby?: boolean;
360
+ /**
361
+ * The one-time machinery -- source list and error listener -- has been built.
362
+ *
363
+ * Separate from `started` because the gates flip that one back and forth: a
364
+ * reduced-motion or Save-Data close sets `started = false`, and reopening calls
365
+ * `start()` again. Conflating the two rebuilt the candidate list on every
366
+ * cycle, rewinding to a source already known to be undecodable and attaching a
367
+ * second error listener each time.
368
+ */
369
+ prepared: boolean;
370
+ /** Currently running. Flips whenever a gate opens or closes. */
371
+ started: boolean;
372
+ /** One pending play retry at a time, rather than a listener per rejection. */
373
+ retryArmed: boolean;
374
+ /** This video's own `startWhen`, when register() was given one. */
375
+ startWhen?: StartWhen;
376
+ /** Holding for `canplaythrough` under `requireBuffered`. */
377
+ awaitingBuffer?: boolean;
378
+ sources?: SourceManager;
379
+ pauseTimer?: ReturnType<typeof setTimeout>;
380
+ cancelReveal?: () => void;
381
+ /** Removes every listener this entry owns, in one call. */
382
+ listeners: AbortController;
383
+ }
384
+
385
+ /**
386
+ * One record per video rather than parallel maps keyed by video. Teardown then
387
+ * has exactly one place to forget, which is the failure mode that leaks timers
388
+ * and observers on a client-router site.
389
+ */
390
+ const entries = new Map<HTMLVideoElement, Entry>();
391
+ /** Reverse index for the observer callback, which reports targets, not videos. */
392
+ const byTarget = new Map<Element, Entry>();
393
+
394
+ /** Decides playback. Never carries a margin, so a ratio is the true visible fraction. */
395
+ let observer: IntersectionObserver | null = null;
396
+ /**
397
+ * Decides when to start buffering, and exists only when `prefetchMargin` asks for it.
398
+ * Separate because one observer cannot serve both jobs: its margin dilates the
399
+ * root that every ratio is measured against, so a margin big enough to be useful
400
+ * for prefetch would quietly rescale `pauseBelow`.
401
+ */
402
+ let prefetchObserver: IntersectionObserver | null = null;
403
+ /** Owns every page-level listener, so teardown is one abort rather than six removes. */
404
+ let lifecycle: AbortController | null = null;
405
+ /** True while a pending gesture listener is waiting to re-attempt a blocked play. */
406
+ let gestureArmed = false;
407
+
408
+ /**
409
+ * Sticky, and checked on every reconcile rather than applied once. An
410
+ * arbitration pass resurrecting a video the user deliberately stopped is the
411
+ * easy bug here, and the one that would make the control useless.
412
+ */
413
+ let userPaused = false;
414
+
415
+ /** Bumped by every reconcile, so a pass can tell that a newer one has overtaken it. */
416
+ let generation = 0;
417
+
418
+ /**
419
+ * The visitor has done something: pointer, key or scroll.
420
+ *
421
+ * Not filtered on `isTrusted`. A page that scrolls itself is in use, and the
422
+ * point of the gate is to keep video out of the window an audit measures, which
423
+ * a synthetic run never opens either way.
424
+ */
425
+ let interacted = false;
426
+
427
+ /**
428
+ * The two environment gates. Not every reason a video may be stopped -- the
429
+ * `until` gate, `atOnce`, `pauseBelow` and a user pause are decided in
430
+ * reconcile() -- but the two that mean "not on this device, right now", and the
431
+ * only two that retract an existing reveal back to the poster.
432
+ */
433
+ function videoAllowed(): boolean {
434
+ return motionAllowed() && connectionAllowsMedia();
435
+ }
436
+
437
+ function getObserver(): IntersectionObserver {
438
+ observer ??= new IntersectionObserver(
439
+ (records) => {
440
+ for (const record of records) {
441
+ const entry = byTarget.get(record.target);
442
+ if (entry) entry.ratio = record.isIntersecting ? record.intersectionRatio : 0;
443
+ }
444
+ reconcile();
445
+ },
446
+ { threshold: thresholds() }
447
+ );
448
+ return observer;
449
+ }
450
+
451
+ /** Null when no margin was asked for, which is the default: nothing prefetches unannounced. */
452
+ function getPrefetchObserver(): IntersectionObserver | null {
453
+ if (!wantsPrefetch()) return null;
454
+ prefetchObserver ??= new IntersectionObserver(
455
+ (records) => {
456
+ for (const record of records) {
457
+ const entry = byTarget.get(record.target);
458
+ // Only the source choice and the fetch. Playback stays with the other
459
+ // observer, which is the whole point of there being two.
460
+ if (!entry) continue;
461
+ entry.nearby = record.isIntersecting;
462
+ if (entry.nearby) prefetch(entry);
463
+ }
464
+ },
465
+ { rootMargin: config.prefetchMargin, threshold: 0 }
466
+ );
467
+ return prefetchObserver;
468
+ }
469
+
470
+ /**
471
+ * Any margin at all, so `'0px'` and every other spelling of zero leaves the
472
+ * second observer unbuilt.
473
+ *
474
+ * A digit test rather than a parser: a non-zero length has to contain a non-zero
475
+ * digit, whatever unit it wears. The spec accepts only absolute length dimension
476
+ * tokens and percentages here and throws a SyntaxError for anything else, which
477
+ * `validate()` already surfaces at the configure() call, so no other shape of
478
+ * string reaches this.
479
+ * https://w3c.github.io/IntersectionObserver/#parse-a-margin
480
+ */
481
+ function wantsPrefetch(): boolean {
482
+ return /[1-9]/.test(config.prefetchMargin);
483
+ }
484
+
485
+ /**
486
+ * Ratio thresholds rather than only the 0 boundary, so a turn can be handed from
487
+ * one video to the next as they cross, not just when one fully leaves.
488
+ *
489
+ * `pauseBelow` has to be in this list. The observer reports *only* at threshold
490
+ * crossings, so a pauseBelow of 0.4 with a fixed ladder would actually take
491
+ * effect at 0.25, the nearest crossing the browser bothers to report, and the
492
+ * setting would silently mean something other than what it says.
493
+ */
494
+ function thresholds(): number[] {
495
+ const ladder = [0, 0.1, 0.25, 0.5, 0.75, 1, config.pauseBelow];
496
+ return [...new Set(ladder)].sort((a, b) => a - b);
497
+ }
498
+
499
+ function cancelPause(entry: Entry): void {
500
+ if (entry.pauseTimer !== undefined) {
501
+ clearTimeout(entry.pauseTimer);
502
+ entry.pauseTimer = undefined;
503
+ }
504
+ }
505
+
506
+ function pauseNow(entry: Entry): void {
507
+ cancelPause(entry);
508
+ entry.video.pause();
509
+ }
510
+
511
+ /**
512
+ * Everything `startWhen` is still waiting for, for this video.
513
+ *
514
+ * One function rather than the condition written out at each site: it was
515
+ * duplicated in reconcile() and prefetch(), and the prefetch copy was added a
516
+ * commit later than the other, having originally been forgotten.
517
+ */
518
+ function waitingToStart(entry: Entry): boolean {
519
+ const startWhen = entry.startWhen ?? config.startWhen;
520
+ if (startWhen === 'visible') return false;
521
+ if (!pageLoaded()) return true;
522
+ // Deliberately load *and* interaction: a visitor can scroll before load, and
523
+ // starting the fetch then would be worse than 'page-loaded' rather than better.
524
+ return startWhen === 'interaction' && !interacted;
525
+ }
526
+
527
+ function pauseAfterGrace(entry: Entry): void {
528
+ if (entry.pauseTimer !== undefined) return;
529
+ entry.pauseTimer = setTimeout(() => {
530
+ entry.pauseTimer = undefined;
531
+ pauseNow(entry);
532
+ // It fell out of view rather than losing the slot or being paused by the
533
+ // user, so the next arrival goes through start() again. Resetting here
534
+ // rather than at the eligibility check is what keeps `resumeAll()` working:
535
+ // a user-paused video is also `paused`, and judging on that would strand it.
536
+ entry.started = false;
537
+ }, PAUSE_GRACE_MS);
538
+ }
539
+
540
+ /**
541
+ * Events, so a host can react without observing attributes or forking. Bubbling
542
+ * because the useful listener is usually on a container, not on each video.
543
+ */
544
+ function emit(entry: Entry, type: 'ready' | 'failed'): void {
545
+ entry.video.dispatchEvent(
546
+ new CustomEvent<PoliteVideoEventDetail>(
547
+ type === 'ready' ? POLITE_VIDEO_READY : POLITE_VIDEO_FAILED,
548
+ { bubbles: true, detail: { video: entry.video } }
549
+ )
550
+ );
551
+ }
552
+
553
+ /**
554
+ * Reveal state goes on the container, not the `<video>`, because the poster is an
555
+ * earlier sibling: an attribute on the video cannot style what precedes it, while
556
+ * one on the shared box drives both layers with descendant selectors.
557
+ */
558
+ function markReady(entry: Entry): void {
559
+ entry.host.setAttribute('data-polite-ready', '');
560
+ emit(entry, 'ready');
561
+ }
562
+
563
+ function clearReady(entry: Entry): void {
564
+ entry.cancelReveal?.();
565
+ entry.cancelReveal = undefined;
566
+ entry.host.removeAttribute('data-polite-ready');
567
+ }
568
+
569
+ function armReveal(entry: Entry): void {
570
+ entry.cancelReveal?.();
571
+ entry.cancelReveal = revealWhenPainted(entry.video, () => markReady(entry));
572
+ }
573
+
574
+ /**
575
+ * A video taller than the viewport can never be fully intersecting, because
576
+ * `intersectionRatio` is a fraction of the *element*. So a `pauseBelow` it cannot
577
+ * reach means it never starts, and nothing else would ever say so -- the poster
578
+ * simply stays. With no margin on the playback observer the ceiling is exactly
579
+ * `viewport / height`: measured in Chromium at a 953px viewport, 1.5x viewport
580
+ * height peaks at 0.667 and 3x at 0.333.
581
+ *
582
+ * Checked against the ceiling rather than the observed ratio, so it fires on the
583
+ * first report instead of waiting for a scroll that can never help.
584
+ */
585
+ function warnIfStartUnreachable(entry: Entry): void {
586
+ if (warnedUnreachable) return;
587
+
588
+ const { pauseBelow } = config;
589
+ if (pauseBelow === 0) return;
590
+
591
+ const height = entry.target.getBoundingClientRect().height;
592
+ if (height === 0) return;
593
+
594
+ // The prefetch margin is deliberately absent: it belongs to that observer,
595
+ // and the one that reports these ratios has no margin to grow the root by.
596
+ const ceiling = Math.min(1, window.innerHeight / height);
597
+ if (ceiling > pauseBelow) return;
598
+
599
+ warnedUnreachable = true;
600
+ console.warn(
601
+ 'polite-media: this video is too tall to ever be visible enough to play. ' +
602
+ `pauseBelow is ${pauseBelow}, but its highest possible visible fraction is ` +
603
+ `about ${ceiling.toFixed(2)}. Lower pauseBelow, or make the box shorter than ` +
604
+ 'the viewport.',
605
+ entry.video
606
+ );
607
+ }
608
+
609
+ let warnedUnreachable = false;
610
+
611
+ let pauseControlChecked = false;
612
+
613
+ /**
614
+ * The package's headline claim is that it never autoplays without a way to stop
615
+ * it, and that is the one part it cannot deliver alone: the hook ships, the
616
+ * button is the host's. Forgetting it is otherwise silent, which is how a real
617
+ * project ended up with seven looping videos and no control.
618
+ *
619
+ * Deferred by WCAG 2.2.2's own five seconds -- the criterion only applies to
620
+ * motion running longer than that -- which doubles as time for a control
621
+ * rendered by script to arrive. Only looping video is asked about, since a short
622
+ * clip that ends on its own is outside the criterion.
623
+ */
624
+ function warnIfNoPauseControl(video: HTMLVideoElement): void {
625
+ if (pauseControlChecked || !video.loop) return;
626
+ pauseControlChecked = true;
627
+
628
+ setTimeout(() => {
629
+ // Nothing is moving any more, so there is nothing to demand a control for.
630
+ if (entries.size === 0) return;
631
+ if (document.querySelector('[data-polite-pause-control]')) return;
632
+
633
+ console.warn(
634
+ 'polite-media: a looping video is playing with no way to stop it, which WCAG 2.2.2 ' +
635
+ 'requires. Add data-polite-pause-control to a <button>, or drive pauseAll() from your own control.'
636
+ );
637
+ }, 5000);
638
+ }
639
+
640
+ let warnedNothingToReveal = false;
641
+
642
+ /**
643
+ * The one misconfiguration that is otherwise undetectable.
644
+ *
645
+ * `host` is derived as the video's parent, while video.css keys off
646
+ * `data-polite-media` authored on that same element. Nothing forces the two to
647
+ * agree, so putting the attribute one level too high leaves every rule
648
+ * unmatched: the video is visible from the start, the poster never hides, and
649
+ * the library looks installed while doing nothing at all.
650
+ *
651
+ * Checked here rather than at registration because stylesheets have certainly
652
+ * applied by the time a video starts. The visual test is what separates a genuine
653
+ * mistake from a host driving the reveal from its own CSS, which is supported and
654
+ * must not be nagged -- and it covers visibility as well as opacity, because
655
+ * hiding a video either way is a working setup and only one of them shows up in
656
+ * the computed opacity. A warning that fires on correct code costs more than it
657
+ * saves: it teaches people to ignore the one that matters.
658
+ */
659
+ function warnIfNothingToReveal(entry: Entry): void {
660
+ if (warnedNothingToReveal) return;
661
+ if (entry.host.hasAttribute('data-polite-media')) return;
662
+
663
+ const style = getComputedStyle(entry.video);
664
+ if (style.opacity !== '1' || style.visibility !== 'visible') return;
665
+
666
+ warnedNothingToReveal = true;
667
+ console.warn(
668
+ "polite-media: no data-polite-media on this video's parent, so revealing it does " +
669
+ 'nothing. Put the attribute there, or hide the video with your own CSS.',
670
+ entry.video
671
+ );
672
+ }
673
+
674
+ /**
675
+ * Nothing decodable is left. The poster stays and the host is told, rather than
676
+ * leaving a permanently black box and no way to know about it.
677
+ */
678
+ function markFailed(entry: Entry): void {
679
+ clearReady(entry);
680
+ entry.host.setAttribute('data-polite-failed', '');
681
+ emit(entry, 'failed');
682
+ unregister(entry.video);
683
+ }
684
+
685
+ /**
686
+ * Waits for the next user gesture, then re-runs the arbiter.
687
+ *
688
+ * This is the only rung that survives a *persistent* refusal. Measured in
689
+ * Chromium: once `readyState` is 4, neither `canplay` nor `loadeddata` fires
690
+ * again -- 0 of each across three rejections -- so a video sitting saturated and
691
+ * stationary in view has no media event left to wake it, and produces no further
692
+ * observer batches either.
693
+ *
694
+ * Re-armed per failure rather than bound once at startup. A single
695
+ * `{ once: true }` listener is spent by the first tap anywhere on the document,
696
+ * which is usually long before the video that needs it ever became eligible.
697
+ */
698
+ function armGestureRetry(): void {
699
+ if (gestureArmed || !lifecycle) return;
700
+ gestureArmed = true;
701
+ document.addEventListener(
702
+ 'pointerdown',
703
+ () => {
704
+ gestureArmed = false;
705
+ reconcile();
706
+ },
707
+ { once: true, passive: true, signal: lifecycle.signal }
708
+ );
709
+ }
710
+
711
+ /**
712
+ * `play()` rejects for reasons that are recoverable rather than final: autoplay
713
+ * refused until a gesture, which MDN reports as `NotAllowedError`, or nothing
714
+ * buffered yet under `preload="none"`.
715
+ *
716
+ * Two rungs, because they cover different failures. `canplay` covers the
717
+ * not-yet-buffered case, where the element is below `HAVE_FUTURE_DATA` and will
718
+ * announce reaching it. The gesture covers a blocked autoplay policy, where the
719
+ * element is already saturated and will announce nothing further.
720
+ *
721
+ * `loadeddata` is deliberately absent: it was measured as dead in the same state
722
+ * as `canplay`, so adding it would only look like defence in depth.
723
+ */
724
+ function tryPlay(entry: Entry): void {
725
+ // Held while the buffer fills under `requireBuffered`. Guarded here
726
+ // rather than at the call sites, because reconcile()'s resume path and both
727
+ // retry rungs would otherwise start playback while it is still arriving.
728
+ if (entry.awaitingBuffer) return;
729
+
730
+ // Autoplay is permitted for muted media without any prior user engagement; the
731
+ // other routes MDN lists all require engagement this library cannot assume. So
732
+ // muted is the only condition it can rely on. Set rather than trusted, which
733
+ // also covers markup whose property was changed after parse.
734
+ entry.video.muted = true;
735
+ void entry.video.play().catch(() => {
736
+ armGestureRetry();
737
+
738
+ if (entry.retryArmed) return;
739
+ entry.retryArmed = true;
740
+ entry.video.addEventListener(
741
+ 'canplay',
742
+ () => {
743
+ entry.retryArmed = false;
744
+ reconcile();
745
+ },
746
+ { once: true, signal: entry.listeners.signal }
747
+ );
748
+ });
749
+ }
750
+
751
+ function onMediaError(entry: Entry): void {
752
+ // Only "this file is unusable" advances the list. See isUnusable: the abort
753
+ // that our own src assignment triggers must not consume a candidate.
754
+ if (!isUnusable(entry.video.error)) return;
755
+ clearReady(entry);
756
+ if (!entry.sources?.advance()) {
757
+ markFailed(entry);
758
+ return;
759
+ }
760
+ armReveal(entry);
761
+ tryPlay(entry);
762
+ }
763
+
764
+ /**
765
+ * Chooses the file and wires the error handling. Returns false when nothing was
766
+ * decodable, in which case the entry has already been failed and unregistered.
767
+ *
768
+ * Deferred to here rather than done at registration so `<source media>` is
769
+ * evaluated against the viewport as it is when the video is first wanted, which
770
+ * for a lazy video can be long after the page loaded. Done once, because
771
+ * reassigning `src` restarts playback from frame 0 and `sources.ts` states the
772
+ * invariant that the first choice sticks for the page's lifetime.
773
+ */
774
+ function prepare(entry: Entry): boolean {
775
+ if (entry.prepared) return true;
776
+ entry.prepared = true;
777
+
778
+ entry.sources = manageSources(entry.video);
779
+ entry.video.addEventListener('error', () => onMediaError(entry), {
780
+ signal: entry.listeners.signal,
781
+ });
782
+
783
+ if (!entry.sources.select()) {
784
+ markFailed(entry);
785
+ return false;
786
+ }
787
+ return true;
788
+ }
789
+
790
+ /**
791
+ * Get the bytes moving before the video is anywhere near playable, so it starts
792
+ * on the frame it arrives rather than showing its poster and catching up.
793
+ *
794
+ * The promotion is what does the work: `preload="none"` keeps the poster alone
795
+ * on first paint but also means the browser buffers nothing at all, so choosing
796
+ * a source without it fetches nothing. Measured: all three engines begin
797
+ * fetching on the promotion alone.
798
+ */
799
+ function prefetch(entry: Entry): void {
800
+ if (entry.gated || !videoAllowed()) return;
801
+ // The same page gate reconcile applies. Without it a prefetchMargin defeats
802
+ // startWhen entirely, because the fetch this triggers lands inside page load,
803
+ // which is the contention `'page-loaded'` exists to avoid. Measured on
804
+ // demo/feed.html: the video request went out before the load event.
805
+ if (waitingToStart(entry)) return;
806
+ if (!prepare(entry)) return;
807
+ if (entry.video.preload !== 'auto') entry.video.preload = 'auto';
808
+ }
809
+
810
+ function start(entry: Entry): void {
811
+ entry.started = true;
812
+
813
+ warnIfNothingToReveal(entry);
814
+ warnIfNoPauseControl(entry.video);
815
+ if (!prepare(entry)) return;
816
+
817
+ armReveal(entry);
818
+
819
+ // Waiting for `canplaythrough` without promoting `preload` first would wait
820
+ // forever, for the reason prefetch() describes.
821
+ if (
822
+ config.requireBuffered &&
823
+ !entry.awaitingBuffer &&
824
+ entry.video.readyState < HAVE_ENOUGH_DATA
825
+ ) {
826
+ entry.awaitingBuffer = true;
827
+ entry.video.preload = 'auto';
828
+ entry.video.addEventListener(
829
+ 'canplaythrough',
830
+ () => {
831
+ entry.awaitingBuffer = false;
832
+ reconcile();
833
+ },
834
+ { once: true, signal: entry.listeners.signal }
835
+ );
836
+ }
837
+
838
+ tryPlay(entry);
839
+ }
840
+
841
+ /** {@link ConfigureOptions.atOnce} resolved for the viewport as it is right now. */
842
+ function slots(): AtOnce {
843
+ const { atOnce } = config;
844
+ if (typeof atOnce !== 'object') return atOnce;
845
+ return mediaQuery(config.smallViewport).matches ? atOnce.small : atOnce.large;
846
+ }
847
+
848
+ /** Which of the visible videos may actually run. */
849
+ function pickWinners(candidates: Entry[]): Set<Entry> {
850
+ const limit = slots();
851
+ if (limit === 'all') return new Set(candidates);
852
+ if (limit === 0) return new Set();
853
+ if (candidates.length < 2) return new Set(candidates);
854
+
855
+ const leader = candidates.reduce((best, entry) => (entry.ratio > best.ratio ? entry : best));
856
+ // The incumbent keeps the slot unless a rival is *clearly* more visible, so a
857
+ // carousel's peeking neighbour cannot flap it back and forth.
858
+ const holder = candidates.find((entry) => entry.started && !entry.video.paused);
859
+ const keepsSlot = holder && holder.ratio >= leader.ratio - HYSTERESIS;
860
+
861
+ return new Set([keepsSlot ? holder : leader]);
862
+ }
863
+
864
+ export function reconcile(): void {
865
+ const pass = ++generation;
866
+
867
+ // A client-side router swaps the whole body and does not re-run module
868
+ // scripts, so nothing calls unregister for the elements it discarded. Left
869
+ // alone they sit in a strong Map keeping detached nodes alive, with the
870
+ // observer still watching elements that can never intersect again. Removing a
871
+ // target is itself reported, so this runs on the batch that caused it.
872
+ for (const entry of [...entries.values()]) {
873
+ if (entry.video.isConnected) entry.seenConnected = true;
874
+ else if (entry.seenConnected) unregister(entry.video);
875
+ }
876
+
877
+ // Reduced motion or a metered connection retracts the reveal as well as
878
+ // stopping playback. Pausing alone would leave a frozen frame on screen, which
879
+ // is worse than the poster it replaced.
880
+ if (!videoAllowed()) {
881
+ for (const entry of entries.values()) {
882
+ if (!entry.started) continue;
883
+ pauseNow(entry);
884
+ clearReady(entry);
885
+ entry.started = false;
886
+ }
887
+ return;
888
+ }
889
+
890
+ // A user pause is deliberately *not* the same as a gate closing: the frame
891
+ // they paused on stays visible, because that is what pausing means. Only the
892
+ // automatic gates above retract the reveal back to the poster.
893
+ if (userPaused) {
894
+ for (const entry of entries.values()) if (entry.started) pauseNow(entry);
895
+ return;
896
+ }
897
+
898
+ // Per video rather than per page, because `startWhen` is overridable at
899
+ // register(): a hero can hold out for the visitor while a below-fold grid does
900
+ // not. Checked here rather than at registration because load and the first
901
+ // interaction both arrive later and each has to re-run the arbiter.
902
+ const eligible = [...entries.values()].filter((e) => {
903
+ if (e.gated || waitingToStart(e)) return false;
904
+ if (!e.started && e.ratio > 0) warnIfStartUnreachable(e);
905
+ return e.ratio > config.pauseBelow;
906
+ });
907
+ const winners = pickWinners(eligible);
908
+ const wasEligible = new Set(eligible);
909
+ const limited = slots() !== 'all';
910
+
911
+ // Snapshot: start() can fail and unregister mid-loop, and mutating the map
912
+ // being iterated is a trap even where the language permits it. The snapshot
913
+ // alone is not enough, because start() dispatches to host listeners
914
+ // synchronously and one of those can call back in. Two ways it can, so two
915
+ // checks: a nested pass supersedes this one and its decisions are the current
916
+ // ones, and an entry the listener released must not be revived by a loop
917
+ // still holding it.
918
+ for (const entry of [...entries.values()]) {
919
+ if (generation !== pass) return;
920
+ if (entries.get(entry.video) !== entry) continue;
921
+ if (winners.has(entry)) {
922
+ cancelPause(entry);
923
+ if (!entry.started) start(entry);
924
+ else if (entry.video.paused) tryPlay(entry);
925
+ } else if (entry.started) {
926
+ // Which pause it gets turns on *why* it lost, not on how visible it is.
927
+ //
928
+ // Something took its place, so it stops immediately: two videos decoding
929
+ // through a handover is the exact contention arbitration exists to
930
+ // prevent. That covers losing the slot while still eligible, and also
931
+ // dropping below pauseBelow as the next video rose past it -- on a feed
932
+ // those are one scroll, and measured, the grace period below was letting
933
+ // the pair overlap for its full duration on every handover.
934
+ //
935
+ // Otherwise it simply fell out of view with nothing replacing it, which
936
+ // gets the grace period: a scroll can nudge a video past the boundary and
937
+ // straight back, and stopping instantly would stutter.
938
+ const replaced = wasEligible.has(entry) || (limited && winners.size > 0);
939
+ if (replaced) pauseNow(entry);
940
+ else pauseAfterGrace(entry);
941
+ }
942
+ }
943
+
944
+ // Retried here because the prefetch observer reports a target once, and a
945
+ // refusal may since have been lifted: page load, or the first interaction.
946
+ // Last, so it can never influence the decisions this pass just made.
947
+ for (const entry of [...entries.values()]) {
948
+ if (entry.nearby && !entry.prepared) prefetch(entry);
949
+ }
950
+ }
951
+
952
+ /**
953
+ * The visitor's first pointer, key or scroll, which opens the `'interaction'`
954
+ * gate for good.
955
+ *
956
+ * Distinct from the gesture retry below, which listens for a pointer to
957
+ * re-attempt a play() the browser refused. That one is about permission, this is
958
+ * about timing, and conflating them would start videos on a page whose autoplay
959
+ * was never blocked.
960
+ */
961
+ function onInteraction(): void {
962
+ if (interacted) return;
963
+ interacted = true;
964
+ reconcile();
965
+ }
966
+
967
+ /**
968
+ * Re-ask the arbiter on the events that produce no observer batch of their own.
969
+ * Without these a video comes back frozen: scripts do not re-run on a bfcache
970
+ * restore, and mobile browsers pause video while the tab is hidden then leave it
971
+ * paused on return. The one-shot pointerdown covers autoplay blocked until a
972
+ * first gesture, and reduced-motion is watched so the gate is honoured the
973
+ * moment it flips rather than at the next scroll.
974
+ */
975
+ function onPageShow(event: PageTransitionEvent): void {
976
+ if (event.persisted) reconcile();
977
+ }
978
+
979
+ function onVisibilityChange(): void {
980
+ if (document.visibilityState === 'visible') reconcile();
981
+ }
982
+
983
+ function onReconcileEvent(): void {
984
+ reconcile();
985
+ }
986
+
987
+ function motionQuery(): MediaQueryList {
988
+ return mediaQuery('(prefers-reduced-motion: reduce)');
989
+ }
990
+
991
+ /**
992
+ * Delegated so the control can be added, removed or re-rendered at any time
993
+ * without re-binding, and so the host owns the markup completely.
994
+ */
995
+ function onPauseControlClick(event: Event): void {
996
+ const target = event.target;
997
+ if (!(target instanceof Element)) return;
998
+ if (!target.closest('[data-polite-pause-control]')) return;
999
+ if (userPaused) resumeAll();
1000
+ else pauseAll();
1001
+ }
1002
+
1003
+ /**
1004
+ * One controller for every page-level listener, rather than six hand-mirrored
1005
+ * add/remove pairs.
1006
+ *
1007
+ * This is not only tidier, it removes a leak. `detachLifecycle` used to call
1008
+ * `mediaQuery(config.smallViewport)` a second time, and `mediaQuery` memoises by
1009
+ * query string -- so a `configure({ smallViewport })` between register and
1010
+ * unregister meant detaching from a *different* MediaQueryList and stranding the
1011
+ * listener on the original. Aborting cannot re-resolve the config, so the whole
1012
+ * failure mode stops existing rather than being remembered about.
1013
+ */
1014
+ function attachLifecycle(): void {
1015
+ if (lifecycle) return;
1016
+ lifecycle = new AbortController();
1017
+ const { signal } = lifecycle;
1018
+
1019
+ // Before anything is observed, so the first batch already knows it is paused.
1020
+ restorePaused();
1021
+
1022
+ if (!pageLoaded()) {
1023
+ window.addEventListener('load', () => reconcile(), { once: true, signal });
1024
+ }
1025
+
1026
+ // Once, then never again: the flag is sticky, so there is nothing to keep
1027
+ // listening for. Passive because none of these are cancelled, and `scroll`
1028
+ // especially must not be made to look cancellable.
1029
+ if (!interacted) {
1030
+ for (const type of ['pointerdown', 'keydown', 'scroll'] as const) {
1031
+ window.addEventListener(type, onInteraction, { once: true, passive: true, signal });
1032
+ }
1033
+ }
1034
+
1035
+ window.addEventListener('pageshow', onPageShow, { signal });
1036
+ document.addEventListener('visibilitychange', onVisibilityChange, { signal });
1037
+ document.addEventListener('click', onPauseControlClick, { signal });
1038
+ motionQuery().addEventListener('change', onReconcileEvent, { signal });
1039
+ // A viewport crossing the small/large boundary changes who is allowed to
1040
+ // play, so it has to re-run the arbiter just as scrolling does.
1041
+ mediaQuery(config.smallViewport).addEventListener('change', onReconcileEvent, { signal });
1042
+ }
1043
+
1044
+ function detachLifecycle(): void {
1045
+ lifecycle?.abort();
1046
+ lifecycle = null;
1047
+ gestureArmed = false;
1048
+ }
1049
+
1050
+ /**
1051
+ * Keeps `aria-pressed` current on any pause control that already declares it.
1052
+ *
1053
+ * Maintained rather than added, because MDN describes two valid patterns and
1054
+ * setting it unconditionally would break one of them: `aria-pressed` is for a
1055
+ * control whose label stays constant, while a control that swaps its label
1056
+ * between "Pause" and "Play" should not carry it at all -- a screen reader would
1057
+ * announce "Play, pressed". Declaring the attribute in markup is the author
1058
+ * saying which pattern they are in.
1059
+ *
1060
+ * Restricted to a button role because that is the only role `aria-pressed` is
1061
+ * valid on, so this cannot emit ARIA that a validator would reject.
1062
+ */
1063
+ function reflectPaused(): void {
1064
+ for (const control of document.querySelectorAll('[data-polite-pause-control][aria-pressed]')) {
1065
+ if (control.matches('button, [role="button"]')) {
1066
+ control.setAttribute('aria-pressed', String(userPaused));
1067
+ }
1068
+ }
1069
+ }
1070
+
1071
+ /**
1072
+ * Where a pause is remembered across a navigation.
1073
+ *
1074
+ * `sessionStorage` rather than `localStorage`: the defect this fixes is a pause
1075
+ * being forgotten on the visitor's very next click, which is one visit. A
1076
+ * preference that silently outlived the visit by weeks would be a different and
1077
+ * larger promise, and not one a visitor made.
1078
+ */
1079
+ const PAUSE_KEY = 'polite-media:paused';
1080
+
1081
+ /**
1082
+ * Storage is wrapped because it throws rather than degrading. Access raises
1083
+ * `SecurityError` where a policy denies it, which covers storage turned off in
1084
+ * the browser's settings and a page embedded in a context where third-party
1085
+ * storage is blocked; `setItem` additionally throws once a quota is reached. The
1086
+ * global is also simply absent under SSR, which the same `catch` covers.
1087
+ *
1088
+ * A remembered pause is worth strictly less than the page working, so every
1089
+ * failure here is silent and the pause just stays page-local.
1090
+ */
1091
+ function readStoredPause(): boolean {
1092
+ try {
1093
+ return sessionStorage.getItem(PAUSE_KEY) === '1';
1094
+ } catch {
1095
+ return false;
1096
+ }
1097
+ }
1098
+
1099
+ function writeStoredPause(paused: boolean): void {
1100
+ try {
1101
+ if (paused) sessionStorage.setItem(PAUSE_KEY, '1');
1102
+ else sessionStorage.removeItem(PAUSE_KEY);
1103
+ } catch {
1104
+ // Deliberately empty: the pause still applies to this page, it just will not
1105
+ // survive the next navigation.
1106
+ }
1107
+ }
1108
+
1109
+ /**
1110
+ * Re-applies a pause the visitor set before navigating here.
1111
+ *
1112
+ * Deliberately not routed through `setPaused`: nothing has changed from the
1113
+ * visitor's point of view, so there is no transition to announce, and a
1114
+ * `pausechange` fired during the first `register()` would reach only the hosts
1115
+ * that happened to bind a listener before it. No `reconcile()` either, because
1116
+ * no video is observed yet -- the observer's first batch reads `userPaused` and
1117
+ * holds every video on its poster, which is the outcome this wants.
1118
+ */
1119
+ function restorePaused(): void {
1120
+ if (userPaused || !readStoredPause()) return;
1121
+ userPaused = true;
1122
+ document.documentElement.setAttribute('data-polite-paused', '');
1123
+ reflectPaused();
1124
+ }
1125
+
1126
+ /**
1127
+ * Starts managing a video: reveals it on its first genuinely painted frame,
1128
+ * plays it only while it is visible, falls through its `<source>` list when one
1129
+ * cannot be decoded, and stops it when a gate closes.
1130
+ *
1131
+ * The video and its poster must already share a box carrying `data-polite-media`
1132
+ * in the authored markup, and the video should be `muted loop playsinline
1133
+ * preload="none"`. Calling this twice on the same element is a no-op.
1134
+ *
1135
+ * @param video the element to manage
1136
+ * @param options see {@link RegisterOptions}
1137
+ */
1138
+ export function register(video: HTMLVideoElement, options: RegisterOptions = {}): void {
1139
+ if (entries.has(video)) return;
1140
+
1141
+ const target = options.observe ?? video;
1142
+ // Entries are keyed by video but looked up by observed target, so two videos
1143
+ // sharing one cannot both be tracked. Refused rather than silently overwritten:
1144
+ // the second video would have taken the first's slot, leaving it without
1145
+ // ratios and making either unregister release the other's observation.
1146
+ if (byTarget.has(target)) {
1147
+ console.warn(
1148
+ 'polite-media: this target is already observed for another video, so the video ' +
1149
+ 'below was not registered. Give each video its own observe target.',
1150
+ video
1151
+ );
1152
+ return;
1153
+ }
1154
+ const entry: Entry = {
1155
+ video,
1156
+ target,
1157
+ host: video.parentElement ?? video,
1158
+ ratio: 0,
1159
+ gated: Boolean(options.until),
1160
+ startWhen: options.startWhen,
1161
+ seenConnected: video.isConnected,
1162
+ prepared: false,
1163
+ started: false,
1164
+ retryArmed: false,
1165
+ listeners: new AbortController(),
1166
+ };
1167
+
1168
+ entries.set(video, entry);
1169
+ byTarget.set(target, entry);
1170
+ attachLifecycle();
1171
+ getObserver().observe(target);
1172
+ getPrefetchObserver()?.observe(target);
1173
+
1174
+ if (options.until) {
1175
+ const release = (): void => {
1176
+ // It may have been unregistered while the gate was open.
1177
+ if (entries.get(video) !== entry) return;
1178
+ entry.gated = false;
1179
+ reconcile();
1180
+ };
1181
+ // Settled, not fulfilled: a rejected gate should still release the video
1182
+ // rather than strand it on its poster forever.
1183
+ //
1184
+ // `then(release, release)` rather than `finally(release)` because `finally`
1185
+ // forwards the rejection to the promise it returns, which nothing here
1186
+ // awaits -- so a host passing a gate that rejects would get an unhandled
1187
+ // rejection reported against a path this library documents as supported.
1188
+ void options.until.then(release, release);
1189
+ }
1190
+ }
1191
+
1192
+ /** Anything that names one or more videos. See {@link Target}. */
1193
+ export type VideoTarget = Target<HTMLVideoElement>;
1194
+
1195
+ /**
1196
+ * Registers every video a target names, so the common case is one line and
1197
+ * matches `revealImages` on the image side rather than being a second idea.
1198
+ *
1199
+ * `observe` is deliberately not accepted. Each observed element maps to exactly
1200
+ * one entry, so handing the same wrapper to several videos would silently
1201
+ * discard all but the last. Anything needing it, or a different gate per video,
1202
+ * goes through {@link register} one at a time.
1203
+ *
1204
+ * Idempotent, because `register` is: safe to call on every navigation of a
1205
+ * client-side router, where module scripts do not re-run.
1206
+ */
1207
+ export function registerAll(
1208
+ target: VideoTarget,
1209
+ options: Omit<RegisterOptions, 'observe'> = {}
1210
+ ): void {
1211
+ for (const video of resolveTargets(target)) register(video, options);
1212
+ }
1213
+
1214
+ /**
1215
+ * Stops managing a video and releases everything it owned: the observer entry,
1216
+ * any pending pause timer, its listeners, and the page-level listeners once it
1217
+ * was the last one. Safe to call for a video that was never registered.
1218
+ */
1219
+ export function unregister(video: HTMLVideoElement): void {
1220
+ const entry = entries.get(video);
1221
+ if (!entry) return;
1222
+
1223
+ cancelPause(entry);
1224
+ entry.cancelReveal?.();
1225
+ entry.listeners.abort();
1226
+ // Both, or the entry leaks into whichever observer was missed -- the same leak
1227
+ // the disconnected sweep exists to prevent, reached through the back door.
1228
+ observer?.unobserve(entry.target);
1229
+ prefetchObserver?.unobserve(entry.target);
1230
+ entries.delete(video);
1231
+ byTarget.delete(entry.target);
1232
+
1233
+ // Releasing the observers and listeners on the last video is what stops a
1234
+ // client-router site accumulating one of each per page visited.
1235
+ if (entries.size === 0) {
1236
+ observer?.disconnect();
1237
+ observer = null;
1238
+ prefetchObserver?.disconnect();
1239
+ prefetchObserver = null;
1240
+ detachLifecycle();
1241
+ }
1242
+ }
1243
+
1244
+ /**
1245
+ * Stops every managed video and keeps them stopped.
1246
+ *
1247
+ * WCAG 2.2.2 applies to content that moves automatically, runs for more than
1248
+ * five seconds, and sits alongside other content -- which a looping background
1249
+ * video does. Honouring `prefers-reduced-motion` is necessary but, per the W3C
1250
+ * understanding document, is not listed as satisfying the criterion, so a
1251
+ * mechanism the user can actually operate has to exist.
1252
+ *
1253
+ * The host supplies the button and its styling; the library ships no markup and
1254
+ * no CSS for it. A `<button>` carrying `data-polite-pause-control` toggles this.
1255
+ *
1256
+ * It has to be a real `<button>`. The binding is a delegated `click`, and a
1257
+ * browser only synthesises that from Enter and Space for a native button, so a
1258
+ * `div[role="button"][tabindex="0"]` responds to a mouse and not to a keyboard.
1259
+ */
1260
+ /**
1261
+ * The only place `userPaused` changes, so the attribute, `aria-pressed` and the
1262
+ * event cannot drift apart. Returns early when nothing actually changed: calling
1263
+ * `pauseAll()` twice is idempotent, and announcing a transition that did not
1264
+ * happen would make a host's own state wrong.
1265
+ */
1266
+ function setPaused(paused: boolean): void {
1267
+ if (userPaused === paused) return;
1268
+ userPaused = paused;
1269
+
1270
+ if (paused) document.documentElement.setAttribute('data-polite-paused', '');
1271
+ else document.documentElement.removeAttribute('data-polite-paused');
1272
+
1273
+ reflectPaused();
1274
+ reconcile();
1275
+
1276
+ // Announced last, once the videos have actually stopped or restarted. Firing
1277
+ // before `reconcile()` would hand a listener reading `video.paused` the state
1278
+ // the event says has just ended.
1279
+ document.dispatchEvent(
1280
+ new CustomEvent<PolitePauseEventDetail>(POLITE_VIDEO_PAUSECHANGE, { detail: { paused } })
1281
+ );
1282
+ }
1283
+
1284
+ /*
1285
+ * The stored value is written here rather than inside `setPaused`, which returns
1286
+ * early when the flag already matches. Two things depend on that:
1287
+ *
1288
+ * - `resumeAll()` on a page that is already playing can still clear a pause the
1289
+ * visitor set earlier. Inside `setPaused` it wrote nothing, so a host calling
1290
+ * it before the first `register()` was silently overridden by the restore.
1291
+ * - `unregisterAll()` resumes as it tears down, and that must not be read as the
1292
+ * visitor changing their mind. Leaving the record intact is what carries a pause
1293
+ * across a client-side router's unregister/register cycle as well as a real
1294
+ * navigation.
1295
+ */
1296
+ export function pauseAll(): void {
1297
+ writeStoredPause(true);
1298
+ setPaused(true);
1299
+ }
1300
+
1301
+ /** Lets playback resume, undoing {@link pauseAll}. */
1302
+ export function resumeAll(): void {
1303
+ writeStoredPause(false);
1304
+ setPaused(false);
1305
+ }
1306
+
1307
+ /**
1308
+ * Releases every video, for a host tearing down the whole page. Configuration
1309
+ * survives: it describes the page's setup rather than the videos currently on
1310
+ * it, and a client-side router calling this per navigation would otherwise have
1311
+ * its settings quietly reverted on the first swap. The once-per-page warnings do
1312
+ * reset, because the markup they judge is about to be replaced.
1313
+ */
1314
+ export function unregisterAll(): void {
1315
+ for (const video of [...entries.keys()]) unregister(video);
1316
+ setPaused(false);
1317
+ warnedNothingToReveal = false;
1318
+ warnedUnreachable = false;
1319
+ pauseControlChecked = false;
1320
+ resetSourceWarnings();
1321
+ }
1322
+
1323
+ /** Internal reset for tests. Not exported from the package entry point. */
1324
+ export function resetForTests(): void {
1325
+ unregisterAll();
1326
+ config = { ...defaults };
1327
+ interacted = false;
1328
+ // unregisterAll resumes, which clears the key on the way through, but only when
1329
+ // it was actually paused: a restored pause that no test ever toggled would
1330
+ // otherwise leak into the next one.
1331
+ writeStoredPause(false);
1332
+ }
1333
+
1334
+ /** Internal view for tests. Not exported from the package entry point. */
1335
+ export function inspect(): { tracked: number; observing: boolean; lifecycle: boolean } {
1336
+ return { tracked: entries.size, observing: observer !== null, lifecycle: lifecycle !== null };
1337
+ }