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.
- package/CHANGELOG.md +113 -0
- package/LICENSE +21 -0
- package/README.md +675 -0
- package/dist/coordinator.d.ts +231 -0
- package/dist/coordinator.d.ts.map +1 -0
- package/dist/coordinator.js +1017 -0
- package/dist/coordinator.js.map +1 -0
- package/dist/env.d.ts +30 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +49 -0
- package/dist/env.js.map +1 -0
- package/dist/events.d.ts +70 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +42 -0
- package/dist/events.js.map +1 -0
- package/dist/image.css +1 -0
- package/dist/image.d.ts +45 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +127 -0
- package/dist/image.js.map +1 -0
- package/dist/layer.css +1 -0
- package/dist/reveal.d.ts +34 -0
- package/dist/reveal.d.ts.map +1 -0
- package/dist/reveal.js +72 -0
- package/dist/reveal.js.map +1 -0
- package/dist/sources.d.ts +22 -0
- package/dist/sources.d.ts.map +1 -0
- package/dist/sources.js +148 -0
- package/dist/sources.js.map +1 -0
- package/dist/targets.d.ts +20 -0
- package/dist/targets.d.ts.map +1 -0
- package/dist/targets.js +17 -0
- package/dist/targets.js.map +1 -0
- package/dist/video.css +1 -0
- package/dist/video.d.ts +15 -0
- package/dist/video.d.ts.map +1 -0
- package/dist/video.js +17 -0
- package/dist/video.js.map +1 -0
- package/dist/warm.d.ts +13 -0
- package/dist/warm.d.ts.map +1 -0
- package/dist/warm.js +12 -0
- package/dist/warm.js.map +1 -0
- package/dist/warming.d.ts +62 -0
- package/dist/warming.d.ts.map +1 -0
- package/dist/warming.js +136 -0
- package/dist/warming.js.map +1 -0
- package/package.json +96 -0
- package/src/coordinator.ts +1337 -0
- package/src/env.ts +56 -0
- package/src/events.ts +78 -0
- package/src/image.css +74 -0
- package/src/image.ts +160 -0
- package/src/layer.css +60 -0
- package/src/reveal.ts +75 -0
- package/src/sources.ts +164 -0
- package/src/targets.ts +27 -0
- package/src/video.css +74 -0
- package/src/video.ts +32 -0
- package/src/warm.ts +12 -0
- 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
|
+
}
|