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.
- package/CHANGELOG.md +123 -0
- package/README.md +167 -57
- package/dist/coordinator.d.ts +51 -3
- package/dist/coordinator.d.ts.map +1 -1
- package/dist/coordinator.js +172 -105
- package/dist/coordinator.js.map +1 -1
- package/dist/events.d.ts +8 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +6 -0
- package/dist/events.js.map +1 -1
- package/dist/fade.d.ts +11 -0
- package/dist/fade.d.ts.map +1 -0
- package/dist/fade.js +17 -0
- package/dist/fade.js.map +1 -0
- package/dist/image.css +1 -1
- package/dist/image.d.ts +6 -13
- package/dist/image.d.ts.map +1 -1
- package/dist/image.js +38 -20
- package/dist/image.js.map +1 -1
- package/dist/layer.css +1 -1
- package/dist/sources.js +4 -4
- package/dist/targets.d.ts.map +1 -1
- package/dist/targets.js +0 -2
- package/dist/targets.js.map +1 -1
- package/dist/video.css +1 -1
- package/dist/video.d.ts +1 -1
- package/dist/video.d.ts.map +1 -1
- package/dist/video.js +1 -1
- package/dist/video.js.map +1 -1
- package/dist/warming.d.ts +4 -1
- package/dist/warming.d.ts.map +1 -1
- package/dist/warming.js +3 -0
- package/dist/warming.js.map +1 -1
- package/package.json +13 -13
- package/src/coordinator.ts +221 -125
- package/src/events.ts +9 -0
- package/src/fade.ts +19 -0
- package/src/image.css +13 -6
- package/src/image.ts +49 -31
- package/src/layer.css +3 -2
- package/src/sources.ts +4 -4
- package/src/targets.ts +0 -2
- package/src/video.css +32 -19
- package/src/video.ts +1 -0
- package/src/warming.ts +6 -1
package/src/coordinator.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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`
|
|
226
|
-
*
|
|
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
|
|
265
|
-
* not
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
517
|
-
*
|
|
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(
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
628
|
-
|
|
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
|
-
*
|
|
648
|
-
* `
|
|
649
|
-
*
|
|
650
|
-
*
|
|
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 (
|
|
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
|
-
|
|
710
|
+
warned.nothingToReveal = true;
|
|
669
711
|
console.warn(
|
|
670
|
-
|
|
671
|
-
'nothing. Put the attribute
|
|
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,
|
|
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().
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
1027
|
-
* `mediaQuery
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
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
|
-
*
|
|
1163
|
+
* Puts `<html>` and every declared pause control in step with `userPaused`.
|
|
1071
1164
|
*
|
|
1072
|
-
*
|
|
1073
|
-
* setting it unconditionally would break one of them: `aria-pressed`
|
|
1074
|
-
* control whose label stays constant, while a control that swaps its
|
|
1075
|
-
* between "Pause" and "Play" should not carry it at all -- a screen reader
|
|
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 (
|
|
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
|
-
|
|
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
|
-
*
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1308
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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. */
|