polite-media 0.4.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,13 @@
1
1
  import { connectionAllowsMedia, mediaQuery, motionAllowed } from './env.js';
2
2
  import {
3
+ POLITE_VIDEO_BLOCKED,
3
4
  POLITE_VIDEO_PAUSECHANGE,
4
5
  POLITE_VIDEO_FAILED,
5
6
  POLITE_VIDEO_READY,
6
7
  type PolitePauseEventDetail,
7
8
  type PoliteVideoEventDetail,
8
9
  } from './events.js';
10
+ import { warnIfFadeInvalid } from './fade.js';
9
11
  import { revealWhenPainted } from './reveal.js';
10
12
  import { isUnusable, manageSources, resetSourceWarnings, type SourceManager } from './sources.js';
11
13
  import { resolveTargets, type Target } from './targets.js';
@@ -66,8 +68,13 @@ export interface ConfigureOptions {
66
68
  */
67
69
  prefetchMargin?: string;
68
70
  /**
69
- * Which viewports count as small. Configurable because 767px is one project's
71
+ * Which viewports count as small. Configurable because 768px is one project's
70
72
  * breakpoint, not a fact about phones.
73
+ *
74
+ * The default stops at 767.98px, leaving 0.02px beside a `(min-width: 768px)`
75
+ * rather than the whole pixel a zoomed page's fractional width can fall into.
76
+ * Range syntax would close the gap, but Safari before 16.4 never matches it, so
77
+ * phones there would count as large.
71
78
  */
72
79
  smallViewport?: string;
73
80
  /**
@@ -163,7 +170,7 @@ type ResolvedConfig = Required<ConfigureOptions>;
163
170
 
164
171
  const defaults: ResolvedConfig = {
165
172
  prefetchMargin: '0px',
166
- smallViewport: '(max-width: 767px)',
173
+ smallViewport: '(max-width: 767.98px)',
167
174
  atOnce: { small: 1, large: 'all' },
168
175
  pauseBelow: 0.5,
169
176
  startWhen: 'page-loaded',
@@ -222,8 +229,8 @@ const HAVE_ENOUGH_DATA = 4;
222
229
  * ladder was fixed at construction, so a late 0.4 takes effect at 0.25, the
223
230
  * nearest crossing the observer still reports. That is the exact silent lie
224
231
  * `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.
232
+ * - `smallViewport` strands a listener. `mediaQuery` memoises by string, so detaching resolves a
233
+ * different MediaQueryList than attaching did.
227
234
  * - `prefetchMargin` builds the prefetch observer, at that same first `register()`.
228
235
  * A late patch would not reach the one already built.
229
236
  */
@@ -261,12 +268,14 @@ export function configure(patch: ConfigureOptions): void {
261
268
  * `register()`, arbitrarily far from the `configure()` call responsible. So these
262
269
  * checks relocate the browser's own error to the call that caused it.
263
270
  *
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.
271
+ * `smallViewport` is not checked. An invalid media query does not throw and does
272
+ * not normalise to something recognisable: Chromium echoes the malformed text
273
+ * straight back through `MediaQueryList.media` and simply never matches. So
274
+ * `smallViewport: '(max-width: 767)'`, one missing unit, means arbitration
275
+ * silently never engages and phones behave like desktops. Chromium also never
276
+ * matches `not (query)` for such a query, which could detect it, but how Safari
277
+ * before 16.4 parses that form is not known, and a check that warned on valid
278
+ * queries there would be worse than none. Only the empty case is caught.
270
279
  */
271
280
  function validate(patch: ConfigureOptions): void {
272
281
  const { pauseBelow } = patch;
@@ -313,6 +322,9 @@ export interface RegisterOptions {
313
322
  * at scroll-top is reported visible in the observer's very first batch, so
314
323
  * without a gate it starts before whatever the page is waiting on (a splash
315
324
  * screen, a consent dialog) has finished.
325
+ *
326
+ * Settling, fulfilled or rejected, is the only release: a promise that never
327
+ * settles holds the video on its poster for the life of the page.
316
328
  */
317
329
  until?: Promise<unknown>;
318
330
  /**
@@ -328,12 +340,26 @@ export interface RegisterOptions {
328
340
  * the same rule buys nothing.
329
341
  */
330
342
  startWhen?: StartWhen;
343
+ /**
344
+ * Let the video download while `until` holds it, so a splash buys it load time.
345
+ * Only playback waits for the gate; the download still waits for `startWhen`,
346
+ * Save-Data, reduced motion and a user pause, and starts once the video is on
347
+ * screen or within `prefetchMargin`.
348
+ *
349
+ * Opt-in, because a gate holding the fetch too is what `until` has always
350
+ * done, and a page gating a video behind a consent dialog may rely on it.
351
+ */
352
+ prefetchWhileGated?: boolean;
331
353
  }
332
354
 
333
355
  interface Entry {
334
356
  video: HTMLVideoElement;
335
357
  target: Element;
336
- /** Element carrying reveal state, so CSS can drive poster and video together. */
358
+ /**
359
+ * Element carrying reveal state, so CSS can drive poster and video together: the nearest
360
+ * `[data-polite-media]` ancestor, or the parent where none is authored so the warning below has
361
+ * an element to check.
362
+ */
337
363
  host: Element;
338
364
  /** How much of the target is visible; 0 when offscreen. */
339
365
  ratio: number;
@@ -341,6 +367,8 @@ interface Entry {
341
367
  gated: boolean;
342
368
  /** Whether a gate was ever supplied. `gated` clears on settle, so it cannot answer this. */
343
369
  hadGate: boolean;
370
+ /** May download while `gated`. */
371
+ prefetchWhileGated: boolean;
344
372
  /**
345
373
  * This element has been in the document at least once.
346
374
  *
@@ -513,9 +541,8 @@ function pauseNow(entry: Entry): void {
513
541
  /**
514
542
  * Everything `startWhen` is still waiting for, for this video.
515
543
  *
516
- * One function rather than the condition written out at each site: it was
517
- * duplicated in reconcile() and prefetch(), and the prefetch copy was added a
518
- * commit later than the other, having originally been forgotten.
544
+ * One function rather than the condition written out at each site, because reconcile() and
545
+ * prefetch() have to apply the same gate and two copies of it drift.
519
546
  */
520
547
  function waitingToStart(entry: Entry): boolean {
521
548
  const startWhen = entry.startWhen ?? config.startWhen;
@@ -543,12 +570,12 @@ function pauseAfterGrace(entry: Entry): void {
543
570
  * Events, so a host can react without observing attributes or forking. Bubbling
544
571
  * because the useful listener is usually on a container, not on each video.
545
572
  */
546
- function emit(entry: Entry, type: 'ready' | 'failed'): void {
573
+ function emit(
574
+ entry: Entry,
575
+ type: typeof POLITE_VIDEO_READY | typeof POLITE_VIDEO_FAILED | typeof POLITE_VIDEO_BLOCKED
576
+ ): void {
547
577
  entry.video.dispatchEvent(
548
- new CustomEvent<PoliteVideoEventDetail>(
549
- type === 'ready' ? POLITE_VIDEO_READY : POLITE_VIDEO_FAILED,
550
- { bubbles: true, detail: { video: entry.video } }
551
- )
578
+ new CustomEvent<PoliteVideoEventDetail>(type, { bubbles: true, detail: { video: entry.video } })
552
579
  );
553
580
  }
554
581
 
@@ -559,7 +586,7 @@ function emit(entry: Entry, type: 'ready' | 'failed'): void {
559
586
  */
560
587
  function markReady(entry: Entry): void {
561
588
  entry.host.setAttribute('data-polite-ready', '');
562
- emit(entry, 'ready');
589
+ emit(entry, POLITE_VIDEO_READY);
563
590
  }
564
591
 
565
592
  function clearReady(entry: Entry): void {
@@ -569,10 +596,32 @@ function clearReady(entry: Entry): void {
569
596
  }
570
597
 
571
598
  function armReveal(entry: Entry): void {
599
+ // Still revealed from before it scrolled away, so the frame is on screen and re-arming would
600
+ // only announce `ready` a second time. Only a retraction, which removes the attribute, re-arms.
601
+ if (entry.host.hasAttribute('data-polite-ready')) return;
572
602
  entry.cancelReveal?.();
573
603
  entry.cancelReveal = revealWhenPainted(entry.video, () => markReady(entry));
574
604
  }
575
605
 
606
+ /**
607
+ * One shot per page for each report that would otherwise repeat on every video, in one record so
608
+ * a teardown cannot reset three of four. `pauseControl` is spent when its check is scheduled, the
609
+ * rest when the warning fires.
610
+ */
611
+ const warned = {
612
+ unreachableStart: false,
613
+ nothingToReveal: false,
614
+ droppedOptions: false,
615
+ pauseControl: false,
616
+ notDecorative: false,
617
+ invalidFade: false,
618
+ };
619
+
620
+ function resetWarnings(): void {
621
+ for (const key of Object.keys(warned) as (keyof typeof warned)[]) warned[key] = false;
622
+ resetSourceWarnings();
623
+ }
624
+
576
625
  /**
577
626
  * A video taller than the viewport can never be fully intersecting, because
578
627
  * `intersectionRatio` is a fraction of the *element*. So a `pauseBelow` it cannot
@@ -585,7 +634,7 @@ function armReveal(entry: Entry): void {
585
634
  * first report instead of waiting for a scroll that can never help.
586
635
  */
587
636
  function warnIfStartUnreachable(entry: Entry): void {
588
- if (warnedUnreachable) return;
637
+ if (warned.unreachableStart) return;
589
638
 
590
639
  const { pauseBelow } = config;
591
640
  if (pauseBelow === 0) return;
@@ -598,7 +647,7 @@ function warnIfStartUnreachable(entry: Entry): void {
598
647
  const ceiling = Math.min(1, window.innerHeight / height);
599
648
  if (ceiling > pauseBelow) return;
600
649
 
601
- warnedUnreachable = true;
650
+ warned.unreachableStart = true;
602
651
  console.warn(
603
652
  'polite-media: this video is too tall to ever be visible enough to play. ' +
604
653
  `pauseBelow is ${pauseBelow}, but its highest possible visible fraction is ` +
@@ -608,10 +657,6 @@ function warnIfStartUnreachable(entry: Entry): void {
608
657
  );
609
658
  }
610
659
 
611
- let warnedUnreachable = false;
612
-
613
- let pauseControlChecked = false;
614
-
615
660
  /**
616
661
  * The package's headline claim is that it never autoplays without a way to stop
617
662
  * it, and that is the one part it cannot deliver alone: the hook ships, the
@@ -624,8 +669,8 @@ let pauseControlChecked = false;
624
669
  * clip that ends on its own is outside the criterion.
625
670
  */
626
671
  function warnIfNoPauseControl(video: HTMLVideoElement): void {
627
- if (pauseControlChecked || !video.loop) return;
628
- pauseControlChecked = true;
672
+ if (warned.pauseControl || !video.loop) return;
673
+ warned.pauseControl = true;
629
674
 
630
675
  setTimeout(() => {
631
676
  // Nothing is moving any more, so there is nothing to demand a control for.
@@ -639,16 +684,13 @@ function warnIfNoPauseControl(video: HTMLVideoElement): void {
639
684
  }, 5000);
640
685
  }
641
686
 
642
- let warnedNothingToReveal = false;
643
-
644
687
  /**
645
688
  * The one misconfiguration that is otherwise undetectable.
646
689
  *
647
- * `host` is derived as the video's parent, while video.css keys off
648
- * `data-polite-media` authored on that same element. Nothing forces the two to
649
- * agree, so putting the attribute one level too high leaves every rule
650
- * unmatched: the video is visible from the start, the poster never hides, and
651
- * the library looks installed while doing nothing at all.
690
+ * video.css hides a video only inside an authored `[data-polite-media]` box. With
691
+ * none above the video, `host` falls back to its parent, every rule misses, the
692
+ * video is visible from the start, the poster never hides, and the library looks
693
+ * installed while doing nothing at all.
652
694
  *
653
695
  * Checked here rather than at registration because stylesheets have certainly
654
696
  * applied by the time a video starts. The visual test is what separates a genuine
@@ -659,31 +701,69 @@ let warnedNothingToReveal = false;
659
701
  * saves: it teaches people to ignore the one that matters.
660
702
  */
661
703
  function warnIfNothingToReveal(entry: Entry): void {
662
- if (warnedNothingToReveal) return;
704
+ if (warned.nothingToReveal) return;
663
705
  if (entry.host.hasAttribute('data-polite-media')) return;
664
706
 
665
707
  const style = getComputedStyle(entry.video);
666
708
  if (style.opacity !== '1' || style.visibility !== 'visible') return;
667
709
 
668
- warnedNothingToReveal = true;
710
+ warned.nothingToReveal = true;
669
711
  console.warn(
670
- "polite-media: no data-polite-media on this video's parent, so revealing it does " +
671
- 'nothing. Put the attribute there, or hide the video with your own CSS.',
712
+ 'polite-media: no data-polite-media on any box around this video, so revealing it does ' +
713
+ 'nothing. Put the attribute on the box holding poster and video, or hide the video with ' +
714
+ 'your own CSS.',
672
715
  entry.video
673
716
  );
674
717
  }
675
718
 
719
+ /**
720
+ * The markup contract makes a managed video decorative, and missing that is otherwise silent:
721
+ * measured in Firefox, twelve background videos sat in the tab order ahead of the pause button.
722
+ *
723
+ * The attribute rather than `tabIndex`, which reads 0 in Chromium and Firefox but -1 in WebKit when
724
+ * none is authored, so it would judge the same markup per engine. Any `aria-hidden` ancestor counts,
725
+ * since it removes the video from the tree as surely as its own.
726
+ */
727
+ function warnIfNotDecorative(video: HTMLVideoElement): void {
728
+ if (warned.notDecorative) return;
729
+ if (video.getAttribute('tabindex') === '-1' && video.closest('[aria-hidden="true"]')) return;
730
+
731
+ warned.notDecorative = true;
732
+ console.warn(
733
+ 'polite-media: this video is in the tab order or exposed to screen readers. A background ' +
734
+ 'video is decorative: give it tabindex="-1" aria-hidden="true", and put any meaning in the ' +
735
+ "poster's alt.",
736
+ video
737
+ );
738
+ }
739
+
676
740
  /**
677
741
  * Nothing decodable is left. The poster stays and the host is told, rather than
678
742
  * leaving a permanently black box and no way to know about it.
679
743
  */
680
744
  function markFailed(entry: Entry): void {
681
745
  clearReady(entry);
746
+ clearBlocked(entry);
682
747
  entry.host.setAttribute('data-polite-failed', '');
683
- emit(entry, 'failed');
748
+ emit(entry, POLITE_VIDEO_FAILED);
684
749
  unregister(entry.video);
685
750
  }
686
751
 
752
+ /**
753
+ * The browser refused to start playback until a gesture, which MDN reports as `NotAllowedError`.
754
+ * The retry rungs handle recovery; this tells the host, the only party that can offer a play
755
+ * affordance. The other rejection, nothing buffered yet, is transient and not announced.
756
+ */
757
+ function markBlocked(entry: Entry): void {
758
+ if (entry.host.hasAttribute('data-polite-blocked')) return;
759
+ entry.host.setAttribute('data-polite-blocked', '');
760
+ emit(entry, POLITE_VIDEO_BLOCKED);
761
+ }
762
+
763
+ function clearBlocked(entry: Entry): void {
764
+ entry.host.removeAttribute('data-polite-blocked');
765
+ }
766
+
687
767
  /**
688
768
  * Waits for the next user gesture, then re-runs the arbiter.
689
769
  *
@@ -734,20 +814,24 @@ function tryPlay(entry: Entry): void {
734
814
  // muted is the only condition it can rely on. Set rather than trusted, which
735
815
  // also covers markup whose property was changed after parse.
736
816
  entry.video.muted = true;
737
- void entry.video.play().catch(() => {
738
- armGestureRetry();
739
-
740
- if (entry.retryArmed) return;
741
- entry.retryArmed = true;
742
- entry.video.addEventListener(
743
- 'canplay',
744
- () => {
745
- entry.retryArmed = false;
746
- reconcile();
747
- },
748
- { once: true, signal: entry.listeners.signal }
749
- );
750
- });
817
+ void entry.video.play().then(
818
+ () => clearBlocked(entry),
819
+ (error: unknown) => {
820
+ if ((error as DOMException | null)?.name === 'NotAllowedError') markBlocked(entry);
821
+ armGestureRetry();
822
+
823
+ if (entry.retryArmed) return;
824
+ entry.retryArmed = true;
825
+ entry.video.addEventListener(
826
+ 'canplay',
827
+ () => {
828
+ entry.retryArmed = false;
829
+ reconcile();
830
+ },
831
+ { once: true, signal: entry.listeners.signal }
832
+ );
833
+ }
834
+ );
751
835
  }
752
836
 
753
837
  function onMediaError(entry: Entry): void {
@@ -759,6 +843,8 @@ function onMediaError(entry: Entry): void {
759
843
  markFailed(entry);
760
844
  return;
761
845
  }
846
+ // Prefetched before anything asked it to play, so start() will reveal and play it.
847
+ if (!entry.started) return;
762
848
  armReveal(entry);
763
849
  tryPlay(entry);
764
850
  }
@@ -799,7 +885,9 @@ function prepare(entry: Entry): boolean {
799
885
  * fetching on the promotion alone.
800
886
  */
801
887
  function prefetch(entry: Entry): void {
802
- if (entry.gated || !videoAllowed()) return;
888
+ // A user pause holds the bytes too. The prefetch observer calls in here directly rather than
889
+ // through reconcile(), which stops at a pause; resuming reconciles, and that retries.
890
+ if ((entry.gated && !entry.prefetchWhileGated) || userPaused || !videoAllowed()) return;
803
891
  // The same page gate reconcile applies. Without it a prefetchMargin defeats
804
892
  // startWhen entirely, because the fetch this triggers lands inside page load,
805
893
  // which is the contention `'page-loaded'` exists to avoid. Measured on
@@ -813,6 +901,7 @@ function start(entry: Entry): void {
813
901
  entry.started = true;
814
902
 
815
903
  warnIfNothingToReveal(entry);
904
+ warned.invalidFade ||= warnIfFadeInvalid(entry.host);
816
905
  warnIfNoPauseControl(entry.video);
817
906
  if (!prepare(entry)) return;
818
907
 
@@ -865,6 +954,7 @@ function pickWinners(candidates: Entry[]): Set<Entry> {
865
954
 
866
955
  export function reconcile(): void {
867
956
  const pass = ++generation;
957
+ reflectActive();
868
958
 
869
959
  // A client-side router swaps the whole body and does not re-run module
870
960
  // scripts, so nothing calls unregister for the elements it discarded. Left
@@ -944,10 +1034,15 @@ export function reconcile(): void {
944
1034
  }
945
1035
 
946
1036
  // Retried here because the prefetch observer reports a target once, and a
947
- // refusal may since have been lifted: page load, or the first interaction.
1037
+ // refusal may since have been lifted: page load, or the first interaction. A
1038
+ // gated video that may download is fetched from here once on screen, since at
1039
+ // the default prefetchMargin no prefetch observer exists to report it.
948
1040
  // Last, so it can never influence the decisions this pass just made.
949
1041
  for (const entry of [...entries.values()]) {
950
- if (entry.nearby && !entry.prepared) prefetch(entry);
1042
+ if (entries.get(entry.video) !== entry || entry.prepared) continue;
1043
+ if (entry.nearby || (entry.gated && entry.prefetchWhileGated && entry.ratio > 0)) {
1044
+ prefetch(entry);
1045
+ }
951
1046
  }
952
1047
  }
953
1048
 
@@ -1023,12 +1118,10 @@ function onPauseControlClick(event: Event): void {
1023
1118
  * One controller for every page-level listener, rather than six hand-mirrored
1024
1119
  * add/remove pairs.
1025
1120
  *
1026
- * This is not only tidier, it removes a leak. `detachLifecycle` used to call
1027
- * `mediaQuery(config.smallViewport)` a second time, and `mediaQuery` memoises by
1028
- * query string -- so a `configure({ smallViewport })` between register and
1029
- * unregister meant detaching from a *different* MediaQueryList and stranding the
1030
- * listener on the original. Aborting cannot re-resolve the config, so the whole
1031
- * failure mode stops existing rather than being remembered about.
1121
+ * Aborting is what makes it safe rather than merely tidy: it cannot re-resolve the config.
1122
+ * `mediaQuery` memoises by query string, so a hand-written detach after a
1123
+ * `configure({ smallViewport })` would resolve a different MediaQueryList and strand the listener
1124
+ * on the original.
1032
1125
  */
1033
1126
  function attachLifecycle(): void {
1034
1127
  if (lifecycle) return;
@@ -1067,19 +1160,22 @@ function detachLifecycle(): void {
1067
1160
  }
1068
1161
 
1069
1162
  /**
1070
- * Keeps `aria-pressed` current on any pause control that already declares it.
1163
+ * Puts `<html>` and every declared pause control in step with `userPaused`.
1071
1164
  *
1072
- * Maintained rather than added, because MDN describes two valid patterns and
1073
- * setting it unconditionally would break one of them: `aria-pressed` is for a
1074
- * control whose label stays constant, while a control that swaps its label
1075
- * between "Pause" and "Play" should not carry it at all -- a screen reader would
1076
- * announce "Play, pressed". Declaring the attribute in markup is the author
1165
+ * `aria-pressed` is maintained rather than added, because MDN describes two valid
1166
+ * patterns and setting it unconditionally would break one of them: `aria-pressed`
1167
+ * is for a control whose label stays constant, while a control that swaps its
1168
+ * label between "Pause" and "Play" should not carry it at all -- a screen reader
1169
+ * would announce "Play, pressed". Declaring the attribute in markup is the author
1077
1170
  * saying which pattern they are in.
1078
1171
  *
1079
1172
  * Restricted to a button role because that is the only role `aria-pressed` is
1080
1173
  * valid on, so this cannot emit ARIA that a validator would reject.
1081
1174
  */
1082
1175
  function reflectPaused(): void {
1176
+ if (userPaused) document.documentElement.setAttribute('data-polite-paused', '');
1177
+ else document.documentElement.removeAttribute('data-polite-paused');
1178
+
1083
1179
  for (const control of document.querySelectorAll('[data-polite-pause-control][aria-pressed]')) {
1084
1180
  if (control.matches('button, [role="button"]')) {
1085
1181
  control.setAttribute('aria-pressed', String(userPaused));
@@ -1138,24 +1234,9 @@ function writeStoredPause(paused: boolean): void {
1138
1234
  function restorePaused(): void {
1139
1235
  if (userPaused || !readStoredPause()) return;
1140
1236
  userPaused = true;
1141
- document.documentElement.setAttribute('data-polite-paused', '');
1142
1237
  reflectPaused();
1143
1238
  }
1144
1239
 
1145
- /**
1146
- * Starts managing a video: reveals it on its first genuinely painted frame,
1147
- * plays it only while it is visible, falls through its `<source>` list when one
1148
- * cannot be decoded, and stops it when a gate closes.
1149
- *
1150
- * The video and its poster must already share a box carrying `data-polite-media`
1151
- * in the authored markup, and the video should be `muted loop playsinline
1152
- * preload="none"`. Calling this twice on the same element is a no-op.
1153
- *
1154
- * @param video the element to manage
1155
- * @param options see {@link RegisterOptions}
1156
- */
1157
- let warnedDroppedOptions = false;
1158
-
1159
1240
  /**
1160
1241
  * A second `register()` for a live video keeps the first registration, so whatever the second call
1161
1242
  * asked for is discarded. Silence is right where the values match, which is how a client-side
@@ -1171,15 +1252,17 @@ function warnIfOptionsDropped(
1171
1252
  video: HTMLVideoElement,
1172
1253
  options: RegisterOptions
1173
1254
  ): void {
1174
- if (warnedDroppedOptions) return;
1255
+ if (warned.droppedOptions) return;
1175
1256
 
1176
1257
  const differs =
1177
1258
  (options.until !== undefined && !entry.hadGate) ||
1178
1259
  (options.startWhen !== undefined && options.startWhen !== entry.startWhen) ||
1179
- (options.observe !== undefined && options.observe !== entry.target);
1260
+ (options.observe !== undefined && options.observe !== entry.target) ||
1261
+ (options.prefetchWhileGated !== undefined &&
1262
+ options.prefetchWhileGated !== entry.prefetchWhileGated);
1180
1263
  if (!differs) return;
1181
1264
 
1182
- warnedDroppedOptions = true;
1265
+ warned.droppedOptions = true;
1183
1266
  console.warn(
1184
1267
  'polite-media: this video is already registered, so the options passed to this second ' +
1185
1268
  'register() call were discarded and the first registration still stands. Register it once ' +
@@ -1188,6 +1271,18 @@ function warnIfOptionsDropped(
1188
1271
  );
1189
1272
  }
1190
1273
 
1274
+ /**
1275
+ * Starts managing a video: reveals it on its first genuinely painted frame,
1276
+ * plays it only while it is visible, falls through its `<source>` list when one
1277
+ * cannot be decoded, and stops it when a gate closes.
1278
+ *
1279
+ * The video and its poster must already share a box carrying `data-polite-media`
1280
+ * in the authored markup, and the video should be `muted loop playsinline
1281
+ * preload="none"`. Calling this twice on the same element is a no-op.
1282
+ *
1283
+ * @param video the element to manage
1284
+ * @param options see {@link RegisterOptions}
1285
+ */
1191
1286
  export function register(video: HTMLVideoElement, options: RegisterOptions = {}): void {
1192
1287
  const registered = entries.get(video);
1193
1288
  if (registered) {
@@ -1208,13 +1303,16 @@ export function register(video: HTMLVideoElement, options: RegisterOptions = {})
1208
1303
  );
1209
1304
  return;
1210
1305
  }
1306
+ warnIfNotDecorative(video);
1307
+
1211
1308
  const entry: Entry = {
1212
1309
  video,
1213
1310
  target,
1214
- host: video.parentElement ?? video,
1311
+ host: video.closest('[data-polite-media]') ?? video.parentElement ?? video,
1215
1312
  ratio: 0,
1216
1313
  gated: Boolean(options.until),
1217
1314
  hadGate: Boolean(options.until),
1315
+ prefetchWhileGated: Boolean(options.prefetchWhileGated),
1218
1316
  startWhen: options.startWhen,
1219
1317
  seenConnected: video.isConnected,
1220
1318
  prepared: false,
@@ -1227,6 +1325,9 @@ export function register(video: HTMLVideoElement, options: RegisterOptions = {})
1227
1325
  byTarget.set(target, entry);
1228
1326
  reflectActive();
1229
1327
  attachLifecycle();
1328
+ // A client-side router replaces <html>'s attributes and the control with the next page's while
1329
+ // this module, and a pause made before the swap, survive. So it is re-asserted per registration.
1330
+ if (userPaused) reflectPaused();
1230
1331
  getObserver().observe(target);
1231
1332
  getPrefetchObserver()?.observe(target);
1232
1333
 
@@ -1253,7 +1354,9 @@ export type VideoTarget = Target<HTMLVideoElement>;
1253
1354
 
1254
1355
  /**
1255
1356
  * Registers every video a target names, so the common case is one line and
1256
- * matches `revealImages` on the image side rather than being a second idea.
1357
+ * matches `revealImages` on the image side rather than being a second idea. With
1358
+ * no target it takes every video inside a `[data-polite-media]` box, which is
1359
+ * what every known caller passed by hand.
1257
1360
  *
1258
1361
  * `observe` is deliberately not accepted. Each observed element maps to exactly
1259
1362
  * one entry, so handing the same wrapper to several videos would silently
@@ -1264,7 +1367,7 @@ export type VideoTarget = Target<HTMLVideoElement>;
1264
1367
  * client-side router, where module scripts do not re-run.
1265
1368
  */
1266
1369
  export function registerAll(
1267
- target: VideoTarget,
1370
+ target: VideoTarget = '[data-polite-media] video',
1268
1371
  options: Omit<RegisterOptions, 'observe'> = {}
1269
1372
  ): void {
1270
1373
  for (const video of resolveTargets(target)) register(video, options);
@@ -1302,48 +1405,28 @@ export function unregister(video: HTMLVideoElement): void {
1302
1405
  }
1303
1406
 
1304
1407
  /**
1305
- * Stops every managed video and keeps them stopped.
1306
- *
1307
- * WCAG 2.2.2 applies to content that moves automatically, runs for more than
1308
- * five seconds, and sits alongside other content -- which a looping background
1309
- * video does. Honouring `prefers-reduced-motion` is necessary but, per the W3C
1310
- * understanding document, is not listed as satisfying the criterion, so a
1311
- * mechanism the user can actually operate has to exist.
1408
+ * Marks the document while a managed video may play, so a pause control can hide when there is
1409
+ * nothing to stop: nothing registered, reduced motion, Save-Data or 2g, or `atOnce: 0`. CSS can see
1410
+ * only the motion preference. Both known consumers had invented the same attribute, which is what
1411
+ * makes this the library's job. A user pause keeps it, since the control is then what resumes.
1312
1412
  *
1313
- * The host supplies the button and its styling; the library ships no markup and
1314
- * no CSS for it. A `<button>` carrying `data-polite-pause-control` toggles this.
1315
- *
1316
- * It has to be a real `<button>`. The binding is a delegated `click`, and a
1317
- * browser only synthesises that from Enter and Space for a native button, so a
1318
- * `div[role="button"][tabindex="0"]` responds to a mouse and not to a keyboard.
1413
+ * Driven from where `entries` changes and from every reconcile, which is where the gates are read.
1319
1414
  */
1415
+ function reflectActive(): void {
1416
+ const active = entries.size > 0 && videoAllowed() && slots() !== 0;
1417
+ document.documentElement.toggleAttribute('data-polite-active', active);
1418
+ }
1419
+
1320
1420
  /**
1321
1421
  * The only place `userPaused` changes, so the attribute, `aria-pressed` and the
1322
1422
  * event cannot drift apart. Returns early when nothing actually changed: calling
1323
1423
  * `pauseAll()` twice is idempotent, and announcing a transition that did not
1324
1424
  * happen would make a host's own state wrong.
1325
1425
  */
1326
- /**
1327
- * Marks the document while at least one video is managed.
1328
- *
1329
- * A pause control is markup on every page, but it must not offer to stop something that was never
1330
- * registered, and a host cannot answer that in CSS on its own. Both known consumers had invented
1331
- * the same attribute in their own namespace to do it, which is what makes this the library's job.
1332
- *
1333
- * Driven from the only two places `entries` changes, so it cannot drift from what is tracked.
1334
- */
1335
- function reflectActive(): void {
1336
- if (entries.size > 0) document.documentElement.setAttribute('data-polite-active', '');
1337
- else document.documentElement.removeAttribute('data-polite-active');
1338
- }
1339
-
1340
1426
  function setPaused(paused: boolean): void {
1341
1427
  if (userPaused === paused) return;
1342
1428
  userPaused = paused;
1343
1429
 
1344
- if (paused) document.documentElement.setAttribute('data-polite-paused', '');
1345
- else document.documentElement.removeAttribute('data-polite-paused');
1346
-
1347
1430
  reflectPaused();
1348
1431
  reconcile();
1349
1432
 
@@ -1367,6 +1450,23 @@ function setPaused(paused: boolean): void {
1367
1450
  * across a client-side router's unregister/register cycle as well as a real
1368
1451
  * navigation.
1369
1452
  */
1453
+
1454
+ /**
1455
+ * Stops every managed video and keeps them stopped.
1456
+ *
1457
+ * WCAG 2.2.2 applies to content that moves automatically, runs for more than
1458
+ * five seconds, and sits alongside other content -- which a looping background
1459
+ * video does. Honouring `prefers-reduced-motion` is necessary but, per the W3C
1460
+ * understanding document, is not listed as satisfying the criterion, so a
1461
+ * mechanism the user can actually operate has to exist.
1462
+ *
1463
+ * The host supplies the button and its styling; the library ships no markup and
1464
+ * no CSS for it. A `<button>` carrying `data-polite-pause-control` toggles this.
1465
+ *
1466
+ * It has to be a real `<button>`. The binding is a delegated `click`, and a
1467
+ * browser only synthesises that from Enter and Space for a native button, so a
1468
+ * `div[role="button"][tabindex="0"]` responds to a mouse and not to a keyboard.
1469
+ */
1370
1470
  export function pauseAll(): void {
1371
1471
  writeStoredPause(true);
1372
1472
  setPaused(true);
@@ -1388,11 +1488,7 @@ export function resumeAll(): void {
1388
1488
  export function unregisterAll(): void {
1389
1489
  for (const video of [...entries.keys()]) unregister(video);
1390
1490
  setPaused(false);
1391
- warnedNothingToReveal = false;
1392
- warnedUnreachable = false;
1393
- warnedDroppedOptions = false;
1394
- pauseControlChecked = false;
1395
- resetSourceWarnings();
1491
+ resetWarnings();
1396
1492
  }
1397
1493
 
1398
1494
  /** Internal reset for tests. Not exported from the package entry point. */