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