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
package/src/env.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Environment gates: the conditions under which video is allowed to play at all.
3
+ * The poster is a complete fallback for every one of them, so a gate that says no
4
+ * costs the user nothing.
5
+ */
6
+
7
+ const queries = new Map<string, MediaQueryList>();
8
+
9
+ /**
10
+ * Memoised `matchMedia`, and the reason every media query in this package goes
11
+ * through one function: a module-scope `matchMedia(...)` throws the moment the
12
+ * package is imported anywhere without a DOM (SSR, prerender, a Node test), and
13
+ * would contradict the `sideEffects` declaration in package.json.
14
+ *
15
+ * Memoising also lets callers attach `change` listeners to the same object the
16
+ * predicates read, so "honour it live" costs no extra plumbing.
17
+ */
18
+ export function mediaQuery(query: string): MediaQueryList {
19
+ let mql = queries.get(query);
20
+ if (!mql) {
21
+ mql = matchMedia(query);
22
+ queries.set(query, mql);
23
+ }
24
+ return mql;
25
+ }
26
+
27
+ export function motionAllowed(): boolean {
28
+ return !mediaQuery('(prefers-reduced-motion: reduce)').matches;
29
+ }
30
+
31
+ interface NetworkInformation {
32
+ saveData?: boolean;
33
+ effectiveType?: string;
34
+ }
35
+
36
+ /**
37
+ * Video is an enhancement over a poster that already stands on its own, so it is
38
+ * skipped on a metered or very slow connection.
39
+ *
40
+ * Absence must mean *allow*. The Network Information API is not Baseline: Safari
41
+ * and Firefox never expose it, and Brave disables it as a fingerprinting surface.
42
+ * Reading absence as "block" would fail closed for most of the web, and fail
43
+ * closed in the one direction nobody would catch: Chrome is the browser that has
44
+ * the API, so a Chrome-only developer never sees it.
45
+ */
46
+ export function connectionAllowsMedia(): boolean {
47
+ const conn = (navigator as Navigator & { connection?: NetworkInformation }).connection;
48
+ if (!conn) return true;
49
+ if (conn.saveData) return false;
50
+ return conn.effectiveType !== 'slow-2g' && conn.effectiveType !== '2g';
51
+ }
52
+
53
+ /** Drops memoised queries so a test can swap `matchMedia`. Not part of the public API. */
54
+ export function resetEnv(): void {
55
+ queries.clear();
56
+ }
package/src/events.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The events both halves dispatch, and the typing that makes them usable.
3
+ *
4
+ * Three pieces, because no one of them is sufficient on its own:
5
+ *
6
+ * - The **constants**, because a mistyped event name still compiles. lib.dom
7
+ * declares a fallback `addEventListener(type: string, ...)` overload, so
8
+ * `'polite-video:redy'` type-checks happily however the maps are augmented.
9
+ * - The **detail**, because these events bubble by design and the useful
10
+ * listener sits on a container, which makes `event.target` the wrong element
11
+ * or a bare `EventTarget` needing a cast.
12
+ * - The **augmentation**, so a listener gets `CustomEvent<Detail>` rather than
13
+ * `Event`.
14
+ *
15
+ * `ElementEventMap` and `DocumentEventMap` are the two that have to be patched,
16
+ * and not the more obvious `HTMLElementEventMap`. lib.dom declares
17
+ * `HTMLElementEventMap extends ElementEventMap`, so augmenting the base reaches
18
+ * `HTMLElement`, `HTMLMediaElement` and `HTMLVideoElement` through inheritance,
19
+ * while augmenting the derived one reaches none of its ancestors. `document` is
20
+ * a separate branch entirely -- `DocumentEventMap extends
21
+ * GlobalEventHandlersEventMap` -- so it needs its own entry.
22
+ */
23
+
24
+ /** Dispatched on the video once a frame has genuinely painted. Bubbles. */
25
+ export const POLITE_VIDEO_READY = 'polite-video:ready';
26
+
27
+ /** Dispatched on the video when no source could be decoded. Bubbles. */
28
+ export const POLITE_VIDEO_FAILED = 'polite-video:failed';
29
+
30
+ /** Dispatched on the image once it has decoded. Bubbles. */
31
+ export const POLITE_IMAGE_READY = 'polite-image:ready';
32
+
33
+ /**
34
+ * Dispatched on `document` when the user pauses or resumes everything.
35
+ *
36
+ * The odd one out, and deliberately: a user pause is page-wide rather than about
37
+ * one video, so there is no element to dispatch it at and nothing for it to
38
+ * bubble through. Only `DocumentEventMap` is augmented for it.
39
+ *
40
+ * It exists because the alternative for a host is watching `data-polite-paused`
41
+ * on `<html>` with a MutationObserver, which is the same unreasonable ask this
42
+ * library avoids by maintaining `aria-pressed` itself. Without it, the
43
+ * label-swapping control the README offers as an option cannot be built.
44
+ */
45
+ export const POLITE_VIDEO_PAUSECHANGE = 'polite-video:pausechange';
46
+
47
+ export interface PoliteVideoEventDetail {
48
+ /**
49
+ * The managed video. Saves casting `event.target`, which is the container
50
+ * rather than the video whenever the listener is on an ancestor.
51
+ */
52
+ video: HTMLVideoElement;
53
+ }
54
+
55
+ export interface PoliteImageEventDetail {
56
+ /** The managed image, for the same reason. */
57
+ image: HTMLImageElement;
58
+ }
59
+
60
+ export interface PolitePauseEventDetail {
61
+ /** True once the user has paused everything, false once they resume. */
62
+ paused: boolean;
63
+ }
64
+
65
+ declare global {
66
+ interface ElementEventMap {
67
+ 'polite-video:ready': CustomEvent<PoliteVideoEventDetail>;
68
+ 'polite-video:failed': CustomEvent<PoliteVideoEventDetail>;
69
+ 'polite-image:ready': CustomEvent<PoliteImageEventDetail>;
70
+ }
71
+
72
+ interface DocumentEventMap {
73
+ 'polite-video:ready': CustomEvent<PoliteVideoEventDetail>;
74
+ 'polite-video:failed': CustomEvent<PoliteVideoEventDetail>;
75
+ 'polite-image:ready': CustomEvent<PoliteImageEventDetail>;
76
+ 'polite-video:pausechange': CustomEvent<PolitePauseEventDetail>;
77
+ }
78
+ }
package/src/image.css ADDED
@@ -0,0 +1,74 @@
1
+ /*
2
+ * polite-media/image.css -- optional stylesheet.
3
+ *
4
+ * Timing only, like the video half: no size, no position, no aspect ratio.
5
+ *
6
+ * The opt-in is `data-polite-reveal`, ON THE IMAGE, not on its container.
7
+ * That placement is load-bearing. A container-wide rule hides every image
8
+ * inside it, including ones the library then declines to manage -- which leaves
9
+ * them invisible forever rather than merely unfaded. Per image, an image is
10
+ * hidden only if something is going to reveal it.
11
+ *
12
+ * A REQUIREMENT rather than a suggestion: the container needs its own visible
13
+ * backdrop, usually a background-color. Unlike video, an image has no poster
14
+ * behind it, so a hidden image over a transparent container shows nothing.
15
+ *
16
+ * <div class="card" style-hook> <-- background-color lives here
17
+ * <img src="photo.avif" alt="" loading="lazy" data-polite-reveal>
18
+ * </div>
19
+ *
20
+ * The fade exists because otherwise an image cuts from the backdrop to the photo
21
+ * on whatever frame the async decode lands, which reads as a flicker rather than
22
+ * a load. That is also why this keeps a 350ms default where video.css cuts at
23
+ * 0s: an image has no second moving picture to ghost against. Both read
24
+ * --polite-fade, so setting it overrides both.
25
+ *
26
+ * Unlike the video half, a hidden image degrades to nothing rather than to a
27
+ * poster, so hiding it is gated on scripting being available. Baseline since
28
+ * December 2023, and it needs nothing from the host: with scripting off the rule
29
+ * never applies and the images simply arrive unfaded.
30
+ *
31
+ * No media query can see a bundle that fails to load while scripting is on, so
32
+ * that case is covered by the failsafe below instead.
33
+ */
34
+
35
+ @media (scripting: enabled) {
36
+ img[data-polite-reveal] {
37
+ opacity: 0;
38
+ transition: opacity var(--polite-fade, 350ms) ease;
39
+ /*
40
+ * Nothing may stay hidden forever. Marking an image and never passing it to
41
+ * revealImages() used to leave it invisible permanently, and so did a bundle
42
+ * that never arrived; both now resolve to merely unfaded.
43
+ *
44
+ * Deliberately universal rather than cancelled for images the library owns.
45
+ * An earlier version marked those and set `animation: none`, which reverts
46
+ * the filled end state: measured in all three engines, an image the failsafe
47
+ * had already revealed jumped back to opacity 0 the moment it was claimed.
48
+ * Applying to everything removes that case, and costs nothing, since a
49
+ * managed image reaches opacity 1 through `data-polite-ready` long before
50
+ * the delay and the two agree on the value.
51
+ */
52
+ animation: polite-reveal-failsafe 0s var(--polite-failsafe, 5s) forwards;
53
+ }
54
+
55
+ img[data-polite-reveal][data-polite-ready] {
56
+ opacity: 1;
57
+ }
58
+
59
+ @keyframes polite-reveal-failsafe {
60
+ to {
61
+ opacity: 1;
62
+ }
63
+ }
64
+ }
65
+
66
+ /*
67
+ * The image still reveals under reduced motion, it just arrives instead of
68
+ * fading. Suppressing the reveal entirely would leave a blank card.
69
+ */
70
+ @media (prefers-reduced-motion: reduce) {
71
+ img[data-polite-reveal] {
72
+ transition: none;
73
+ }
74
+ }
package/src/image.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @module
3
+ * Image reveal. Independent of the video module by design: an image needs no
4
+ * IntersectionObserver, no source negotiation and no playback arbitration, and
5
+ * it is not skipped on a metered connection the way video is -- you still have
6
+ * to show the picture.
7
+ *
8
+ * The point is the same as the video module's, applied to a different signal:
9
+ * reveal when pixels are ready, not when bytes arrived. `load` fires *before*
10
+ * decode, so fading on it can start against an undecoded bitmap and hitch.
11
+ * `HTMLImageElement.decode()` resolves when the image is actually paintable,
12
+ * which is the image analogue of requestVideoFrameCallback.
13
+ *
14
+ * Reduced motion is handled entirely in image.css: the image still reveals, it
15
+ * just does so instantly. No JS gate, because "show the picture without
16
+ * animating" is a pure styling concern.
17
+ */
18
+
19
+ import { POLITE_IMAGE_READY, type PoliteImageEventDetail } from './events.js';
20
+ import { resolveTargets, type Target } from './targets.js';
21
+
22
+ /** Anything that names one or more images. See {@link Target}. */
23
+ export type ImageTarget = Target<HTMLImageElement>;
24
+
25
+ export interface RevealImagesOptions {
26
+ /**
27
+ * Manage images that are not `loading="lazy"`.
28
+ *
29
+ * Off by default because LCP excludes elements at `opacity: 0`, and revealing
30
+ * one does not restore its candidacy -- so fading an eager, above-the-fold
31
+ * image can forfeit the metric it was meant to improve. A lazy image was never
32
+ * an LCP candidate, so the default is risk-free.
33
+ *
34
+ * Turning this on is legitimate and sometimes right: an eager grid that would
35
+ * otherwise cut from its backdrop to the photo on whatever frame the async
36
+ * decode lands looks worse without a fade. It just has to be a decision rather
37
+ * than an accident.
38
+ */
39
+ allowEager?: boolean;
40
+ }
41
+
42
+ const READY = 'data-polite-ready';
43
+
44
+ function markReady(image: HTMLImageElement): void {
45
+ image.setAttribute(READY, '');
46
+ image.dispatchEvent(
47
+ new CustomEvent<PoliteImageEventDetail>(POLITE_IMAGE_READY, {
48
+ bubbles: true,
49
+ detail: { image },
50
+ })
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Every image this module has taken responsibility for.
56
+ *
57
+ * A WeakSet rather than an attribute: the stylesheet no longer needs to know
58
+ * which images are managed, so writing it into the DOM would be state kept for
59
+ * nobody. Weak so a released image is not pinned by the bookkeeping.
60
+ */
61
+ const managed = new WeakSet<HTMLImageElement>();
62
+
63
+ let unmanagedCheck: ReturnType<typeof setTimeout> | undefined;
64
+
65
+ /**
66
+ * The failsafe in image.css keeps a stray image from vanishing, but silently.
67
+ * This is the half a developer can act on: it names the element and says which
68
+ * of the two mistakes was made.
69
+ *
70
+ * Debounced rather than run per call, because several `revealImages()` calls
71
+ * with different selectors are a normal way to set a page up and an image is
72
+ * only stray once all of them have had their chance. Rescheduling also means a
73
+ * client-side navigation gets its own check instead of one per page lifetime,
74
+ * which a once-only flag would have given.
75
+ */
76
+ function scheduleUnmanagedCheck(): void {
77
+ clearTimeout(unmanagedCheck);
78
+ unmanagedCheck = setTimeout(() => {
79
+ for (const image of document.querySelectorAll<HTMLImageElement>('img[data-polite-reveal]')) {
80
+ if (managed.has(image)) continue;
81
+ console.warn(
82
+ 'polite-media: no revealImages() call manages this image, so the failsafe revealed ' +
83
+ 'it late and unfaded. Widen the selector, or drop data-polite-reveal.',
84
+ image
85
+ );
86
+ return;
87
+ }
88
+ }, 1000);
89
+ }
90
+
91
+ /**
92
+ * Reveals each matching image once it has decoded.
93
+ *
94
+ * Returns a function that stops any reveals still pending, for a client-side
95
+ * router tearing the page down before the images resolved.
96
+ */
97
+ export function revealImages(target: ImageTarget, options: RevealImagesOptions = {}): () => void {
98
+ const controller = new AbortController();
99
+ const { signal } = controller;
100
+
101
+ scheduleUnmanagedCheck();
102
+
103
+ for (const image of resolveTargets(target)) {
104
+ managed.add(image);
105
+ // Eager images are revealed at once rather than skipped.
106
+ //
107
+ // Skipping looks like the cautious choice and is the opposite: image.css
108
+ // has already hidden anything carrying data-polite-reveal, so declining to
109
+ // manage it leaves it invisible permanently instead of merely unfaded.
110
+ // Revealing immediately keeps the LCP candidate visible from its first
111
+ // paint, which is the whole reason eager images are treated differently.
112
+ //
113
+ // Tested against 'lazy' rather than for 'eager' deliberately. Engines
114
+ // disagree on what an absent or invalid attribute reports: MDN documents
115
+ // only 'eager' and 'lazy', while happy-dom returns 'auto'. Only "lazy" has
116
+ // one agreed spelling, so asking whether it is lazy is answerable
117
+ // everywhere, and everything else correctly falls into the eager branch.
118
+ if (!options.allowEager && image.loading !== 'lazy') {
119
+ markReady(image);
120
+ continue;
121
+ }
122
+
123
+ // A cached image is frequently already decoded before this module runs at
124
+ // all -- these are deferred scripts. Revealing on the next frame rather than
125
+ // synchronously lets the browser paint the hidden state first, so the
126
+ // transition still runs instead of snapping.
127
+ if (image.complete && image.naturalWidth > 0) {
128
+ requestAnimationFrame(() => {
129
+ if (!signal.aborted) markReady(image);
130
+ });
131
+ continue;
132
+ }
133
+
134
+ image
135
+ .decode()
136
+ .then(() => {
137
+ if (!signal.aborted) markReady(image);
138
+ })
139
+ .catch(() => {
140
+ // decode() rejects with EncodingError when `src` changes mid-flight,
141
+ // which a responsive `srcset` genuinely does on resize, and on a real
142
+ // decode failure. Either way the image must not be left hidden: `load`
143
+ // is the weaker signal, and no reveal at all is worse than an early one.
144
+ if (signal.aborted) return;
145
+ if (image.complete) {
146
+ markReady(image);
147
+ return;
148
+ }
149
+ image.addEventListener('load', () => markReady(image), { once: true, signal });
150
+ image.addEventListener('error', () => markReady(image), { once: true, signal });
151
+ });
152
+ }
153
+
154
+ return () => controller.abort();
155
+ }
156
+
157
+ // Re-exported so `polite-media/image` carries the ElementEventMap and
158
+ // DocumentEventMap augmentation too: it only reaches a consumer whose program
159
+ // includes the module that declares it.
160
+ export { POLITE_IMAGE_READY, type PoliteImageEventDetail } from './events.js';
package/src/layer.css ADDED
@@ -0,0 +1,60 @@
1
+ /*
2
+ * polite-media/layer.css -- optional stylesheet, and the only one that touches
3
+ * layout.
4
+ *
5
+ * video.css owns *when* the swap happens and deliberately owns no geometry, so
6
+ * that the package drops into any design at any size. That left one thing every
7
+ * consumer has to write for themselves: stacking the poster and the video in the
8
+ * same box. Both of this project's first two consumers wrote the same five
9
+ * declarations, twice each, and both then reimplemented video.css wholesale
10
+ * rather than import it, because adding `z-index` to those elements felt like
11
+ * taking ownership of the file.
12
+ *
13
+ * So this is the standard stack, opt-in. Import it and write only what is
14
+ * genuinely yours:
15
+ *
16
+ * import 'polite-media/video.css';
17
+ * import 'polite-media/layer.css';
18
+ *
19
+ * .hero__video { z-index: -2; } <-- yours: this sits behind a scrim
20
+ *
21
+ * Skip it and nothing changes: the package still imposes no geometry on anyone
22
+ * who has their own arrangement, and `object-fit: cover` stays a choice you made
23
+ * rather than one made for you.
24
+ *
25
+ * The box needs a size of its own. This positions the media inside it and says
26
+ * nothing about how tall it is, which is still the host's decision: an
27
+ * aspect-ratio, a fixed height, a grid cell.
28
+ */
29
+
30
+ [data-polite-media] {
31
+ position: relative;
32
+ }
33
+
34
+ /*
35
+ * The image is positioned, never the `<picture>` around it.
36
+ *
37
+ * Two reasons, both learned the hard way against a real page. A `<picture>` is
38
+ * commonly `display: contents`, which generates no box at all, so positioning it
39
+ * does nothing and the image's `height: 100%` has no definite containing block
40
+ * to resolve against: it silently falls back to `auto` and letterboxes. And
41
+ * forcing `display: contents` here to normalise that would be worse than the
42
+ * bug, since browsers drop such elements from the accessibility tree, taking the
43
+ * poster's `alt` with them.
44
+ *
45
+ * Positioning the image instead works for both shapes without caring: an
46
+ * absolutely positioned element resolves against its nearest *positioned*
47
+ * ancestor, skipping a static or box-less `<picture>` to land on the container
48
+ * above.
49
+ *
50
+ * object-fit needs the image for its own reason: it "Applies to: replaced
51
+ * elements", and a `<picture>` is a container rather than one.
52
+ */
53
+ [data-polite-media] > :is(img, video),
54
+ [data-polite-media] > picture > img {
55
+ position: absolute;
56
+ inset: 0;
57
+ width: 100%;
58
+ height: 100%;
59
+ object-fit: cover;
60
+ }
package/src/reveal.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The library's core primitive: fire once a frame has genuinely reached the
3
+ * compositor.
4
+ *
5
+ * The obvious signal, the `playing` event, is wrong -- and measurably wrong in
6
+ * both directions rather than merely early. On one machine in one run
7
+ * H.264 presented its first frame 1.6 ms *before* `playing`,
8
+ * while AV1 presented 0.8 ms *after*. So revealing on `playing` either flashes
9
+ * (poster removed before anything painted) or lingers (poster held over a frame
10
+ * that already painted), and which one you get depends on the codec, so no delay
11
+ * can tune it away.
12
+ *
13
+ * `requestVideoFrameCallback` is specified in terms of a frame being sent to the
14
+ * compositor, making it correct by definition rather than by timing.
15
+ */
16
+
17
+ /**
18
+ * `HTMLMediaElement.HAVE_CURRENT_DATA`. Inlined rather than read off the global
19
+ * so this module never touches `HTMLMediaElement`, which does not exist in Node.
20
+ */
21
+ const HAVE_CURRENT_DATA = 2;
22
+
23
+ /**
24
+ * Calls `onPainted` once, when a frame is on screen.
25
+ *
26
+ * Returns a cancel function. Cancelling matters: a video can be unregistered
27
+ * (scrolled out of view, a router navigation) before its first frame ever
28
+ * arrives, and a reveal firing afterwards would un-hide a layer whose element is
29
+ * already gone.
30
+ *
31
+ * Cancellation does two things on the two rungs that can still be pending. The
32
+ * `done` flag is the correctness guarantee, so `cancel()` means "will not fire"
33
+ * however the underlying mechanism behaves; the platform call beside it
34
+ * (`cancelVideoFrameCallback`, `removeEventListener`) is resource release. They
35
+ * overlap on the `loadeddata` rung, where removing the listener would be enough
36
+ * on its own -- the price of `cancel()` meaning one thing rather than three
37
+ * subtly different things. On the `readyState` rung there is nothing to cancel:
38
+ * it has already fired synchronously by the time the caller holds the function.
39
+ */
40
+ export function revealWhenPainted(video: HTMLVideoElement, onPainted: () => void): () => void {
41
+ let done = false;
42
+ const fire = (): void => {
43
+ if (done) return;
44
+ done = true;
45
+ onPainted();
46
+ };
47
+
48
+ // lib.dom declares requestVideoFrameCallback as always present, but it is only
49
+ // Baseline as of October 2024, so the types are more optimistic than reality
50
+ // and the runtime check is doing real work on older browsers.
51
+ if (typeof video.requestVideoFrameCallback === 'function') {
52
+ const handle = video.requestVideoFrameCallback(fire);
53
+ return () => {
54
+ done = true;
55
+ video.cancelVideoFrameCallback(handle);
56
+ };
57
+ }
58
+
59
+ // Both fallbacks are strictly weaker signals. HAVE_CURRENT_DATA means "data
60
+ // exists for the current playback position", which is a decode-side fact and
61
+ // says nothing about whether pixels have been presented. Accepted only because
62
+ // the alternative on these browsers is no reveal at all.
63
+ if (video.readyState >= HAVE_CURRENT_DATA) {
64
+ fire();
65
+ return () => {
66
+ done = true;
67
+ };
68
+ }
69
+
70
+ video.addEventListener('loadeddata', fire, { once: true });
71
+ return () => {
72
+ done = true;
73
+ video.removeEventListener('loadeddata', fire);
74
+ };
75
+ }
package/src/sources.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { mediaQuery } from './env.js';
2
+
3
+ /**
4
+ * Picks which `<source>` a video actually loads, and moves on when one turns out
5
+ * to be undecodable.
6
+ *
7
+ * Two problems, one mechanism.
8
+ *
9
+ * `<source media>` is evaluated once, during resource selection, and never
10
+ * again -- in every engine, not just Safari. Verified in Chromium: after loading
11
+ * at 1200px, resizing to 500px left `currentSrc` on the wide source even though
12
+ * the narrow query then matched. The spec has no hook for it, since resource
13
+ * selection only re-runs when `src` or the `<source>` children change. So a
14
+ * source list alone cannot respond to a viewport that moved since parse.
15
+ * Resolving the list here and assigning `video.src` directly sidesteps that
16
+ * entirely: one assignment, no negotiation left to go stale.
17
+ *
18
+ * And `canPlayType` is only ever a claim. The HTML Standard sets the bar at the
19
+ * user agent being "confident that the type represents a media resource that it
20
+ * can render", and confidence is not a guarantee: in this project's own fixtures
21
+ * Chromium answered "probably" for AV1 and then failed at
22
+ * `dav1d_send_data()`. So the codec check only drops the flat "no" answers;
23
+ * among what survives, document order sets the try order and the `error` event
24
+ * decides the outcome.
25
+ *
26
+ * Deliberately *not* re-selecting on resize. Reassigning `src` restarts
27
+ * playback from frame 0, so a phone rotating mid-scroll would visibly rewind the
28
+ * video. The first choice sticks for the page's lifetime.
29
+ */
30
+
31
+ /** `MediaError.MEDIA_ERR_DECODE` and `MEDIA_ERR_SRC_NOT_SUPPORTED`. */
32
+ const MEDIA_ERR_DECODE = 3;
33
+ const MEDIA_ERR_SRC_NOT_SUPPORTED = 4;
34
+
35
+ /**
36
+ * Whether an error means "this file is unusable, try another" as opposed to
37
+ * "something happened to this attempt".
38
+ *
39
+ * Codes matter here. `MEDIA_ERR_ABORTED` (1) is what the element reports when
40
+ * its `src` is reassigned -- which this module does itself -- so treating every
41
+ * error as fatal would cascade through the whole candidate list on the first
42
+ * assignment. `MEDIA_ERR_NETWORK` (2) means the fetch failed on a resource that
43
+ * was previously fine; another candidate is unlikely to fare better on the same
44
+ * connection, and burning the list would leave nothing to retry with.
45
+ */
46
+ export function isUnusable(error: MediaError | null): boolean {
47
+ if (!error) return false;
48
+ return error.code === MEDIA_ERR_DECODE || error.code === MEDIA_ERR_SRC_NOT_SUPPORTED;
49
+ }
50
+
51
+ export interface SourceManager {
52
+ /** Loads the best remaining candidate. False when none are left to try. */
53
+ select(): boolean;
54
+ /** Discards the current candidate and loads the next. False when exhausted. */
55
+ advance(): boolean;
56
+ }
57
+
58
+ let warnedMediaGap = false;
59
+
60
+ /**
61
+ * Which `<source>` this video could actually load, best first.
62
+ *
63
+ * `media` is treated as a *preference*, not an exclusion. Two queries meant to
64
+ * partition the viewport often do not quite meet: `(max-width: 50rem)` beside
65
+ * `(min-width: 50.001rem)` leaves 0.016px matching neither at a 16px root, and
66
+ * a root font-size that is not 16px moves the boundary somewhere else again.
67
+ * Widths in the gap select nothing and the video has nothing to play. The
68
+ * browser behaves that way too, which is no help to anyone.
69
+ *
70
+ * So when no source claims the current viewport, every decodable one is a
71
+ * candidate and document order decides. The alternative was a markup rule, "the
72
+ * last <source> must carry no media attribute", which every consumer had to
73
+ * remember and which a real project had already got wrong.
74
+ *
75
+ * The cost is that `media` can no longer mean "and otherwise play nothing". It
76
+ * never reliably could, since the markup contract already required an
77
+ * unconditional fallback, and `atOnce: { small: 0 }` says that properly.
78
+ */
79
+ function candidatesFor(video: HTMLVideoElement): { declared: number; playable: string[] } {
80
+ // `:scope >` so a <source> belonging to some nested media element is never
81
+ // mistaken for this one's.
82
+ const sources = [...video.querySelectorAll<HTMLSourceElement>(':scope > source')];
83
+
84
+ const decodable = sources.filter((source) => {
85
+ // The attribute, not the property: `.src` resolves against the document
86
+ // base, so `<source src="">` comes back as the page's own URL -- truthy,
87
+ // and it would then be handed to the video as if it were a media file.
88
+ if (!source.getAttribute('src')) return false;
89
+ // Empty string is the only definite "no" canPlayType offers. "maybe" is
90
+ // kept, because "maybe" is what browsers say about most things.
91
+ return !source.type || video.canPlayType(source.type) !== '';
92
+ });
93
+
94
+ const claiming = decodable.filter((source) => {
95
+ const media = source.getAttribute('media');
96
+ return !media || mediaQuery(media).matches;
97
+ });
98
+
99
+ if (claiming.length === 0 && decodable.length > 0) warnMediaGap(video);
100
+
101
+ return {
102
+ declared: sources.length,
103
+ playable: (claiming.length > 0 ? claiming : decodable).map((source) => source.src),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Fires only when the gap actually opens, rather than whenever the markup looks
109
+ * capable of it. A static check would have to nag every page whose sources are
110
+ * all conditional, including the overwhelming majority whose queries really do
111
+ * cover every width they will ever see.
112
+ */
113
+ function warnMediaGap(video: HTMLVideoElement): void {
114
+ if (warnedMediaGap) return;
115
+ warnedMediaGap = true;
116
+ console.warn(
117
+ 'polite-media: no <source> claims this viewport, so the first decodable one was used ' +
118
+ 'instead of nothing. Give the last <source> no media attribute to choose the fallback ' +
119
+ 'yourself.',
120
+ video
121
+ );
122
+ }
123
+
124
+ export function manageSources(video: HTMLVideoElement): SourceManager {
125
+ const { declared, playable: candidates } = candidatesFor(video);
126
+ let index = -1;
127
+
128
+ const load = (): boolean => {
129
+ const next = candidates[index];
130
+ if (next === undefined) return false;
131
+ video.src = next;
132
+ // Required: assigning `src` alone does not restart resource selection on an
133
+ // element that has already loaded.
134
+ video.load();
135
+ return true;
136
+ };
137
+
138
+ return {
139
+ select(): boolean {
140
+ // "No <source> children at all" and "sources were declared but none
141
+ // qualified" are different situations and must not share a branch. The
142
+ // first is a video authored with a plain `src` -- somebody else's
143
+ // arrangement, left exactly as it is. The second is a video with nothing
144
+ // playable, which has to report failure so the poster stays and the host
145
+ // is told.
146
+ if (declared === 0) return Boolean(video.currentSrc || video.getAttribute('src'));
147
+ // Re-checked rather than assumed: a first select() that found nothing
148
+ // playable still advanced the index, so answering a later call with a bare
149
+ // `true` would report success for a video that never loaded anything.
150
+ if (index >= 0) return candidates[index] !== undefined;
151
+ index = 0;
152
+ return load();
153
+ },
154
+ advance(): boolean {
155
+ index += 1;
156
+ return load();
157
+ },
158
+ };
159
+ }
160
+
161
+ /** Internal reset for tests. Not exported from the package entry point. */
162
+ export function resetSourceWarnings(): void {
163
+ warnedMediaGap = false;
164
+ }