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/README.md
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
# polite-media
|
|
2
|
+
|
|
3
|
+
Background video, image reveals and next-page image warming. No dependencies, no framework.
|
|
4
|
+
|
|
5
|
+
Three independent entry points, imported separately, because they share almost
|
|
6
|
+
nothing. Bundled, minified and gzipped, which is what `pnpm size` enforces:
|
|
7
|
+
|
|
8
|
+
| | JavaScript | stylesheet | total |
|
|
9
|
+
| -------------------- | ---------- | ---------- | ---------- |
|
|
10
|
+
| `polite-media/video` | 3,926 B | 194 B | **4.1 KB** |
|
|
11
|
+
| `polite-media/image` | 630 B | 202 B | **830 B** |
|
|
12
|
+
| `polite-media/warm` | 615 B | none | **615 B** |
|
|
13
|
+
|
|
14
|
+
`polite-media/layer.css` is a third, optional stylesheet: **137 B** for the
|
|
15
|
+
standard poster-over-video stack. An image-only page never pays for the video
|
|
16
|
+
coordinator.
|
|
17
|
+
|
|
18
|
+
It doesn't flash, doesn't hog the decoder, doesn't eat data on a metered
|
|
19
|
+
connection, doesn't ignore reduced motion, and doesn't autoplay without giving
|
|
20
|
+
anyone a way to stop it.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install polite-media
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```html
|
|
27
|
+
<!-- data-polite-media goes on the video's direct parent -->
|
|
28
|
+
<div class="your-own-box" data-polite-media>
|
|
29
|
+
<img src="poster.avif" alt="" />
|
|
30
|
+
<!-- decorative: the video is not in the tab order, the poster's alt carries any meaning -->
|
|
31
|
+
<video muted loop playsinline preload="none" tabindex="-1" aria-hidden="true">
|
|
32
|
+
<source src="hero.mp4" type="video/mp4" />
|
|
33
|
+
</video>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<!-- looping video needs a way to stop it: WCAG 2.2.2. You style it; this ships no CSS -->
|
|
37
|
+
<button type="button" data-polite-pause-control aria-pressed="false">Pause background video</button>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import { register } from 'polite-media/video';
|
|
42
|
+
import 'polite-media/video.css';
|
|
43
|
+
|
|
44
|
+
register(document.querySelector('[data-polite-media] video'));
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Images are the other half, and a separate import:
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<div class="card">
|
|
51
|
+
<!-- needs its own background-color -->
|
|
52
|
+
<img src="photo.avif" alt="" loading="lazy" data-polite-reveal />
|
|
53
|
+
</div>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { revealImages } from 'polite-media/image';
|
|
58
|
+
import 'polite-media/image.css';
|
|
59
|
+
|
|
60
|
+
revealImages('.card img');
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Recipes
|
|
64
|
+
|
|
65
|
+
Each is a demo page in this repo that the end-to-end suite drives, so the markup
|
|
66
|
+
is known to work rather than merely plausible.
|
|
67
|
+
|
|
68
|
+
### A hero video behind a title
|
|
69
|
+
|
|
70
|
+
Load the poster eagerly; it is the Largest Contentful Paint element. Nothing
|
|
71
|
+
needs configuring, since the video fetches once the page has loaded and never
|
|
72
|
+
competes with the page's own bytes.
|
|
73
|
+
|
|
74
|
+
If Lighthouse scores matter to you, add `{ startWhen: 'interaction' }`. The video
|
|
75
|
+
then waits for the visitor's first scroll, tap or keypress, which keeps it out of
|
|
76
|
+
the LCP measurement entirely.
|
|
77
|
+
|
|
78
|
+
```html
|
|
79
|
+
<div class="hero" data-polite-media>
|
|
80
|
+
<img src="poster.avif" alt="" fetchpriority="high" decoding="async" />
|
|
81
|
+
<video muted loop playsinline preload="none" tabindex="-1" aria-hidden="true">
|
|
82
|
+
<source src="hero.mp4" type="video/mp4" />
|
|
83
|
+
</video>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<button type="button" data-polite-pause-control aria-pressed="false">Pause background video</button>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
import { register } from 'polite-media/video';
|
|
91
|
+
import 'polite-media/video.css';
|
|
92
|
+
import 'polite-media/layer.css'; // optional: stacks poster over video so you needn't
|
|
93
|
+
|
|
94
|
+
register(document.querySelector('.hero video'));
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### A grid or feed of videos
|
|
98
|
+
|
|
99
|
+
Cap how many decode at once, and give the observer the card rather than the
|
|
100
|
+
video, which is what you want when the video is `inset: 0` inside it.
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
import { configure, register } from 'polite-media/video';
|
|
104
|
+
import 'polite-media/video.css';
|
|
105
|
+
|
|
106
|
+
configure({ atOnce: 1, prefetchMargin: '200px' });
|
|
107
|
+
|
|
108
|
+
for (const card of document.querySelectorAll('.card')) {
|
|
109
|
+
register(card.querySelector('video'), { observe: card });
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`atOnce: 1` plays one video at a time everywhere; the default is one on small
|
|
114
|
+
viewports and all of them elsewhere. `prefetchMargin` starts buffering the next
|
|
115
|
+
one before it arrives.
|
|
116
|
+
|
|
117
|
+
### Images that appear when they are actually decoded
|
|
118
|
+
|
|
119
|
+
`load` fires before the pixels exist, so fading on it can hitch. This waits for
|
|
120
|
+
`decode()`. The container needs its own `background-color`.
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
import { revealImages } from 'polite-media/image';
|
|
124
|
+
import 'polite-media/image.css';
|
|
125
|
+
|
|
126
|
+
revealImages('.card img');
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Warming the next page's hero
|
|
130
|
+
|
|
131
|
+
Document prefetchers fetch the HTML and stop, so the image inside it is
|
|
132
|
+
discovered only once that document parses. This fetches it on hover, focus or
|
|
133
|
+
touch.
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
import { warmOnIntent } from 'polite-media/warm';
|
|
137
|
+
|
|
138
|
+
warmOnIntent('a[data-hero]', (link) => ({
|
|
139
|
+
sources: [{ type: 'image/avif', srcset: link.dataset.hero }],
|
|
140
|
+
src: '/fallback.jpg',
|
|
141
|
+
sizes: '(min-width: 50rem) 800px, 100vw',
|
|
142
|
+
}));
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## The attributes
|
|
146
|
+
|
|
147
|
+
Six in total. The distinction that catches people is the middle column: two of
|
|
148
|
+
the ones you write are live on their own, and one is inert until you call
|
|
149
|
+
something.
|
|
150
|
+
|
|
151
|
+
| attribute | goes on | live on its own? |
|
|
152
|
+
| --------------------------- | --------------------------- | --------------------------------------------------------- |
|
|
153
|
+
| `data-polite-media` | the box around poster+video | **yes**, for the CSS. The video still needs `register()` |
|
|
154
|
+
| `data-polite-reveal` | the `<img>` | **no**, without `revealImages()` it reveals late, unfaded |
|
|
155
|
+
| `data-polite-pause-control` | your `<button>` | **yes**, no call anywhere on the page |
|
|
156
|
+
| `data-polite-ready` | the box, or the image | written by the library |
|
|
157
|
+
| `data-polite-failed` | the box | written by the library |
|
|
158
|
+
| `data-polite-paused` | `<html>` | written by the library |
|
|
159
|
+
|
|
160
|
+
`data-polite-reveal` is the one to be careful with. `image.css` hides a marked
|
|
161
|
+
image immediately, so marking one you never pass to `revealImages()` leaves it
|
|
162
|
+
hidden until the failsafe shows it, five seconds later and without a fade. The
|
|
163
|
+
console names any image in that state, so widen the selector or drop the
|
|
164
|
+
attribute.
|
|
165
|
+
|
|
166
|
+
The bottom three are yours to style against and never to write yourself. They are
|
|
167
|
+
the public CSS API, along with `--polite-fade` and `--polite-failsafe`.
|
|
168
|
+
|
|
169
|
+
## Markup contract
|
|
170
|
+
|
|
171
|
+
1. Poster and video share one box, arranged by your CSS.
|
|
172
|
+
2. `data-polite-media` goes on that box **in your markup**, not from script. A
|
|
173
|
+
`<video preload="none">` paints an empty box, so if the hidden state only
|
|
174
|
+
arrived once JS ran, a page whose bundle failed would stack that over the
|
|
175
|
+
poster. Authored, the safe state is the default.
|
|
176
|
+
|
|
177
|
+
That box must be the video's **direct parent**. This is the one rule with a
|
|
178
|
+
silent failure mode: the library writes `data-polite-ready` to
|
|
179
|
+
`video.parentElement`, while the stylesheet matches
|
|
180
|
+
`[data-polite-media][data-polite-ready] > video`. Put the attribute a level
|
|
181
|
+
too high and the two never meet, every rule misses, and the video is visible
|
|
182
|
+
from the start with no error anywhere. The library warns on the console when
|
|
183
|
+
it can detect this.
|
|
184
|
+
|
|
185
|
+
3. The poster is ideally the video's **frame 0**, which is what makes the handoff
|
|
186
|
+
invisible, but only together with cutting rather than fading. See
|
|
187
|
+
[The fade](#the-fade).
|
|
188
|
+
4. Poster and video are direct children of the box. The box may hold anything
|
|
189
|
+
else it likes, a scrim, a caption, a pause control, and those are left alone.
|
|
190
|
+
But **every** direct-child `img` or `picture` is treated as the poster and
|
|
191
|
+
hidden on reveal, so a logo or badge belongs deeper, not beside the video.
|
|
192
|
+
5. Order several `<source>` elements narrowest first: the first one that claims
|
|
193
|
+
the viewport wins. **You do not need an unconditional fallback.** Two queries
|
|
194
|
+
meant to partition the viewport often do not quite meet, and
|
|
195
|
+
`(max-width: 50rem)` beside `(min-width: 50.001rem)` leaves 0.016px matching
|
|
196
|
+
neither at a 16px root. So `media` is treated as a preference: when no source
|
|
197
|
+
claims the current viewport, every decodable one is a candidate and document
|
|
198
|
+
order decides. The console says so once when it happens, because the file it
|
|
199
|
+
picks may be meant for a different screen.
|
|
200
|
+
|
|
201
|
+
The trade is that `media` cannot mean "and otherwise play nothing".
|
|
202
|
+
`atOnce: { small: 0 }` says that properly.
|
|
203
|
+
|
|
204
|
+
6. The video carries `tabindex="-1" aria-hidden="true"`. It is decorative, and
|
|
205
|
+
without this it lands in the tab order: measured in Firefox, twelve background
|
|
206
|
+
videos sat ahead of the pause button, so a keyboard user reached it on the
|
|
207
|
+
thirteenth Tab.
|
|
208
|
+
|
|
209
|
+
## API
|
|
210
|
+
|
|
211
|
+
```js
|
|
212
|
+
// polite-media/video
|
|
213
|
+
register(video, { until, observe, startWhen }); // manage a video
|
|
214
|
+
registerAll(target, { until, startWhen }); // manage everything a selector names
|
|
215
|
+
unregister(video); // stop managing it, release everything
|
|
216
|
+
unregisterAll(); // tear down the whole page
|
|
217
|
+
configure({ ... }); // before the first register, or it throws
|
|
218
|
+
pauseAll(); resumeAll(); // WCAG 2.2.2 control, emits pausechange
|
|
219
|
+
|
|
220
|
+
// polite-media/image
|
|
221
|
+
const stop = revealImages(target, { allowEager }); // reveal on decode
|
|
222
|
+
stop(); // cancel anything pending
|
|
223
|
+
|
|
224
|
+
// polite-media/warm
|
|
225
|
+
warm(options); // warm one image
|
|
226
|
+
const off = warmOnIntent(sel, fn); // warm on hover, focus or touch
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
There is no root import. Use `polite-media/video`, `polite-media/image` or
|
|
230
|
+
`polite-media/warm`; the resolution error for the bare package name does not name
|
|
231
|
+
them.
|
|
232
|
+
|
|
233
|
+
Types: `ConfigureOptions`, `RegisterOptions`, `RevealImagesOptions`,
|
|
234
|
+
`WarmOptions`, `WarmSource`, `VideoTarget`, `ImageTarget`, `AtOnce`,
|
|
235
|
+
`PoliteVideoEventDetail`, `PoliteImageEventDetail`, `PolitePauseEventDetail`.
|
|
236
|
+
Event names ship as constants
|
|
237
|
+
(`POLITE_VIDEO_READY`, `POLITE_VIDEO_FAILED`, `POLITE_IMAGE_READY`,
|
|
238
|
+
`POLITE_VIDEO_PAUSECHANGE`), because a mistyped event string still compiles
|
|
239
|
+
against lib.dom's `type: string` overload.
|
|
240
|
+
|
|
241
|
+
`polite-video:pausechange` is the odd one out: a user pause is page-wide rather
|
|
242
|
+
than about one video, so it is dispatched on `document` with
|
|
243
|
+
`detail: { paused: boolean }` and does not bubble from any element.
|
|
244
|
+
|
|
245
|
+
### Options
|
|
246
|
+
|
|
247
|
+
Six, answering three questions. Nothing needs setting; the defaults were measured
|
|
248
|
+
rather than guessed.
|
|
249
|
+
|
|
250
|
+
**How visible must it be?**
|
|
251
|
+
|
|
252
|
+
| option | default | reach for it when |
|
|
253
|
+
| ------------ | ------- | ---------------------------------------------------------------- |
|
|
254
|
+
| `pauseBelow` | `0.5` | a video should run longer, or stop sooner, than "half on screen" |
|
|
255
|
+
|
|
256
|
+
**How many may run?**
|
|
257
|
+
|
|
258
|
+
| option | default | reach for it when |
|
|
259
|
+
| --------------- | ---------------------------- | ---------------------------------------------------------- |
|
|
260
|
+
| `atOnce` | `{ small: 1, large: 'all' }` | a feed wants one at a time everywhere, or phones want none |
|
|
261
|
+
| `smallViewport` | `'(max-width: 767px)'` | your breakpoint is not ours |
|
|
262
|
+
|
|
263
|
+
**When may it begin?**
|
|
264
|
+
|
|
265
|
+
| option | default | reach for it when |
|
|
266
|
+
| ----------------- | --------------- | ----------------------------------------------------------------------------- |
|
|
267
|
+
| `startWhen` | `'page-loaded'` | you want the video out of the LCP measurement, and accept it waiting |
|
|
268
|
+
| `requireBuffered` | `false` | your visitors are on connections where video plays while it is still arriving |
|
|
269
|
+
| `prefetchMargin` | `'0px'` | the next card should be buffered before it arrives |
|
|
270
|
+
|
|
271
|
+
Two numbers that shape playback are deliberately not options. A video leaving the
|
|
272
|
+
viewport waits 400ms before stopping, so a jittery scroll cannot stutter it, and
|
|
273
|
+
an incumbent holding the single slot keeps it until a rival is 15% more visible,
|
|
274
|
+
so a carousel's peeking neighbour cannot flap it back and forth. Both are
|
|
275
|
+
tolerances rather than policies anyone has a view on, and a value picked without
|
|
276
|
+
watching a real carousel does not fail loudly, it just reintroduces the flapping.
|
|
277
|
+
|
|
278
|
+
`configure()` throws if `prefetchMargin`, `pauseBelow` or `smallViewport` is
|
|
279
|
+
patched while videos are registered. Those three are read when the observer and
|
|
280
|
+
the lifecycle listeners are built, so a late change does not merely fail to
|
|
281
|
+
apply: `pauseBelow` half-applies, because eligibility reads it live while the
|
|
282
|
+
threshold ladder does not. `atOnce`, `startWhen` and `requireBuffered` take
|
|
283
|
+
effect on the next pass.
|
|
284
|
+
|
|
285
|
+
`pauseBelow` is range-checked by `configure()`, since TypeScript cannot express
|
|
286
|
+
"a number between 0 and 1", and `atOnce` is checked at runtime for JavaScript
|
|
287
|
+
callers: a `2` would otherwise fall through to the single-slot branch and quietly
|
|
288
|
+
mean `1`.
|
|
289
|
+
|
|
290
|
+
**A malformed `smallViewport` cannot be detected.** An invalid media query does
|
|
291
|
+
not throw and does not normalise to anything recognisable; Chromium echoes the
|
|
292
|
+
text straight back and never matches. So `smallViewport: '(max-width: 767)'`,
|
|
293
|
+
missing its unit, means arbitration silently never engages and phones behave like
|
|
294
|
+
desktops. Check that value by eye.
|
|
295
|
+
|
|
296
|
+
### Per-video gates
|
|
297
|
+
|
|
298
|
+
`register(video, { until: promise })` holds a video back until the promise
|
|
299
|
+
settles: for a splash screen, a consent dialog, or protecting your LCP. A hero at
|
|
300
|
+
scroll-top is reported visible in the observer's very first batch, so without
|
|
301
|
+
this it starts before whatever the page is waiting on has finished.
|
|
302
|
+
|
|
303
|
+
```js
|
|
304
|
+
// If the poster is your LCP element, gate the video on it having loaded.
|
|
305
|
+
register(video, {
|
|
306
|
+
until: new Promise((done) => {
|
|
307
|
+
const poster = document.querySelector('img.hero-poster');
|
|
308
|
+
if (!poster || poster.complete) done();
|
|
309
|
+
else poster.addEventListener('load', () => done(), { once: true });
|
|
310
|
+
}),
|
|
311
|
+
});
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
`register(video, { observe: box })` observes a wrapper instead of the video, for
|
|
315
|
+
when the video is `inset: 0` inside the element that carries the layout.
|
|
316
|
+
|
|
317
|
+
`registerAll(target)` takes the same shapes `revealImages` does (a selector, an
|
|
318
|
+
element, or any collection) and registers each. It does not accept `observe`:
|
|
319
|
+
each observed element maps to exactly one video, and `register` refuses a second
|
|
320
|
+
video on a target it already watches, with a warning.
|
|
321
|
+
|
|
322
|
+
### `startWhen`
|
|
323
|
+
|
|
324
|
+
A genuine ladder: each rung waits for everything the one before it did, then
|
|
325
|
+
something more.
|
|
326
|
+
|
|
327
|
+
| value | waits for |
|
|
328
|
+
| --------------- | -------------------------------------- |
|
|
329
|
+
| `'visible'` | nothing but being on screen |
|
|
330
|
+
| `'page-loaded'` | + `window`'s `load` event. **Default** |
|
|
331
|
+
| `'interaction'` | + the first pointer, key or scroll |
|
|
332
|
+
|
|
333
|
+
`'page-loaded'` is the default because a deferred module script otherwise fetches
|
|
334
|
+
inside the tail of page load: measured on a demo with one resource held back,
|
|
335
|
+
`'visible'` began the video at 106ms against a `load` at 1560ms, taking 1.45
|
|
336
|
+
seconds of bandwidth the page still needed.
|
|
337
|
+
|
|
338
|
+
**`'interaction'` is the one setting with a real trade-off.** The browser stops
|
|
339
|
+
updating Largest Contentful Paint on "a tap, scroll, or keypress"
|
|
340
|
+
([web.dev][lcp]), so a video revealed after that signal can never become the LCP
|
|
341
|
+
element, and a synthetic audit, which never interacts, never starts it at all.
|
|
342
|
+
The cost is yours to weigh: a visitor who lands and never scrolls or taps sees a
|
|
343
|
+
still. On a phone that is usually a second, since any flick counts; on a desktop
|
|
344
|
+
it can last as long as they read without moving. Below the fold it barely matters
|
|
345
|
+
either way, since scrolling is the interaction.
|
|
346
|
+
|
|
347
|
+
**Override it per video** when one video is special, which is usually the hero:
|
|
348
|
+
|
|
349
|
+
```js
|
|
350
|
+
// The grid autoplays on the default; only the LCP candidate holds back.
|
|
351
|
+
register(hero, { startWhen: 'interaction' });
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
`requireBuffered` is the separate axis: it holds playback until the video can
|
|
355
|
+
play through without stalling. It raises `preload` to `'auto'` when it prepares,
|
|
356
|
+
which is necessary because `preload="none"` means the browser buffers nothing
|
|
357
|
+
until playback is requested, so waiting for `canplaythrough` without the
|
|
358
|
+
promotion would wait forever. All three engines honour the promotion. If the
|
|
359
|
+
buffer never fills, the poster stays, the same outcome as reduced motion or
|
|
360
|
+
Save-Data. It is deliberately not a fourth `startWhen` value; as one it competed
|
|
361
|
+
with `'interaction'`, so "wait for the visitor, and also wait for the buffer"
|
|
362
|
+
could not be expressed at all.
|
|
363
|
+
|
|
364
|
+
Two consequences. A page whose `load` never fires never starts its videos, and
|
|
365
|
+
`requireBuffered` is one of two places the library changes markup you authored:
|
|
366
|
+
`prefetch()` also promotes `preload` to `'auto'` for any video within a
|
|
367
|
+
configured `prefetchMargin`. `until` composes with all of it, and a video waits
|
|
368
|
+
for every gate that applies to it.
|
|
369
|
+
|
|
370
|
+
### `pauseBelow`
|
|
371
|
+
|
|
372
|
+
Ships at `0.5`: a video runs while more than half of it is on screen and stops
|
|
373
|
+
once less is. Set it to `0` to play while any part shows at all, which in
|
|
374
|
+
practice means a video hanging on by a sliver never stops.
|
|
375
|
+
|
|
376
|
+
**A tall video may never reach a high `pauseBelow`.** `intersectionRatio` is a
|
|
377
|
+
fraction of the _element_, so a box taller than the viewport can never be fully
|
|
378
|
+
intersecting. Measured in Chromium at a 953px viewport, on the observer that
|
|
379
|
+
decides playback:
|
|
380
|
+
|
|
381
|
+
| element height | highest ratio it reaches |
|
|
382
|
+
| -------------- | ------------------------ |
|
|
383
|
+
| 1x viewport | 1.0 |
|
|
384
|
+
| 1.5x viewport | 0.667 |
|
|
385
|
+
| 3x viewport | 0.333 |
|
|
386
|
+
|
|
387
|
+
The ceiling is `viewport / height`, which is why it ships at `0.5`: that is out
|
|
388
|
+
of reach only past twice the viewport, where `0.75` already fails at one and a
|
|
389
|
+
half. **The library warns when it detects a threshold a box can never reach.**
|
|
390
|
+
Whatever you set is also added to the observer's threshold list, because the
|
|
391
|
+
browser only reports at crossings it was told about.
|
|
392
|
+
|
|
393
|
+
### The pause control
|
|
394
|
+
|
|
395
|
+
A `<button>` carrying `data-polite-pause-control` toggles playback. You supply it
|
|
396
|
+
and its styling; this ships no markup and no CSS for it. The listener is
|
|
397
|
+
delegated on `document`, so it can live anywhere, and several controls stay in
|
|
398
|
+
step automatically.
|
|
399
|
+
|
|
400
|
+
It must be a real `<button>`. The binding is a delegated `click`, and browsers
|
|
401
|
+
only synthesise that from Enter and Space for a native button; a
|
|
402
|
+
`div[role="button"][tabindex="0"]` answers a mouse and ignores a keyboard, which
|
|
403
|
+
is a WCAG 2.1.1 failure.
|
|
404
|
+
|
|
405
|
+
**A pause follows the visitor to the next page**, kept in `sessionStorage`, so on
|
|
406
|
+
a multi-page site the motion stays stopped as they click through and a declared
|
|
407
|
+
`aria-pressed` comes back in the right state. `sessionStorage` rather than
|
|
408
|
+
`localStorage` deliberately, so it lasts the visit rather than silently outliving
|
|
409
|
+
it by weeks. Where storage is denied, which throws rather than returning nothing,
|
|
410
|
+
the pause still applies to the current page and simply does not carry.
|
|
411
|
+
|
|
412
|
+
**If you forget the control, the console says so.** Five seconds after a looping
|
|
413
|
+
video starts, WCAG 2.2.2's own threshold, so a short clip that ends by itself is
|
|
414
|
+
never asked about. If you drive `pauseAll()` from your own UI instead, put
|
|
415
|
+
`data-polite-pause-control` on that control too: it silences the warning and gets
|
|
416
|
+
you `aria-pressed` maintenance for free.
|
|
417
|
+
|
|
418
|
+
There are two ways to convey the state, and you pick one. **A constant label plus
|
|
419
|
+
`aria-pressed="false"`**: declare the attribute and the library keeps it current.
|
|
420
|
+
Or **a label that swaps between "Pause" and "Play"**: leave `aria-pressed` off
|
|
421
|
+
entirely, since a screen reader announcing "Play, pressed" is worse than either
|
|
422
|
+
half, and listen for the state instead.
|
|
423
|
+
|
|
424
|
+
```js
|
|
425
|
+
import { POLITE_VIDEO_PAUSECHANGE } from 'polite-media/video';
|
|
426
|
+
|
|
427
|
+
document.addEventListener(POLITE_VIDEO_PAUSECHANGE, (event) => {
|
|
428
|
+
control.textContent = event.detail.paused ? 'Play' : 'Pause';
|
|
429
|
+
});
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
For an **icon** you need no JavaScript at all, since the state is on the root
|
|
433
|
+
element, so the icon cannot drift from what the video is doing. That pairs with
|
|
434
|
+
`aria-pressed` and a constant accessible name, since an icon is not a label.
|
|
435
|
+
|
|
436
|
+
```css
|
|
437
|
+
.icon-play,
|
|
438
|
+
[data-polite-paused] .icon-pause {
|
|
439
|
+
display: none;
|
|
440
|
+
}
|
|
441
|
+
[data-polite-paused] .icon-play {
|
|
442
|
+
display: inline-block;
|
|
443
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
## The fade
|
|
447
|
+
|
|
448
|
+
One custom property controls it, read by both stylesheets: `--polite-fade`.
|
|
449
|
+
**Video defaults to `0s`, a cut. Images default to `350ms`.**
|
|
450
|
+
|
|
451
|
+
They differ because they solve different problems. An image fades in over a
|
|
452
|
+
backdrop with nothing else moving, so the fade only covers the flicker of an
|
|
453
|
+
async decode landing. A video is a second picture that keeps changing.
|
|
454
|
+
|
|
455
|
+
The trap is assuming a frame-0 poster makes a video fade free. It does not,
|
|
456
|
+
because the video does not wait for the fade: playback starts at the reveal, so
|
|
457
|
+
the poster stays frozen on frame 0 while the video underneath advances, and the
|
|
458
|
+
crossfade blends a still against a frame that has moved on. Measured on a real
|
|
459
|
+
hero, SSIM against the video's own frame 0:
|
|
460
|
+
|
|
461
|
+
| elapsed | similarity to frame 0 |
|
|
462
|
+
| ------- | --------------------- |
|
|
463
|
+
| 250ms | 0.72 |
|
|
464
|
+
| 400ms | 0.69 |
|
|
465
|
+
| 1s | 0.64 |
|
|
466
|
+
|
|
467
|
+
Its poster matched frame 0 at 0.994, so the poster was never the problem. At the
|
|
468
|
+
old `400ms` default the layers were already ~31% apart, and at 1s you see a clear
|
|
469
|
+
double exposure of a moving scene.
|
|
470
|
+
|
|
471
|
+
**That measurement does not transfer to an art-directed poster.** The ghost it
|
|
472
|
+
describes is a double exposure of one scene slightly advanced, which is what you
|
|
473
|
+
get when the two layers are nearly the same picture. Blend two genuinely
|
|
474
|
+
different pictures and you get an ordinary dissolve.
|
|
475
|
+
|
|
476
|
+
| poster | at `0s`, the default | with a fade |
|
|
477
|
+
| ------------------------------- | --------------------------------- | ---------------------------------- |
|
|
478
|
+
| the video's frame 0 | seamless, nothing visibly happens | ghosts, and worse the longer it is |
|
|
479
|
+
| a different, art-directed image | a visible jump | an ordinary crossfade |
|
|
480
|
+
|
|
481
|
+
The default cuts because the library cannot tell which you have, and guessing
|
|
482
|
+
wrong on a frame-0 poster is the worse failure. **Turn it on for the videos that
|
|
483
|
+
want it**, one container at a time; `--polite-fade` is an ordinary custom
|
|
484
|
+
property, so it inherits. `demo/art-directed.html` shows both side by side.
|
|
485
|
+
|
|
486
|
+
```css
|
|
487
|
+
/* this hero dissolves; every other video on the page still cuts */
|
|
488
|
+
.hero[data-polite-media] {
|
|
489
|
+
--polite-fade: 600ms;
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
**A hard cut is safe here**, which is not true of background-video code in
|
|
494
|
+
general. The reveal fires from `requestVideoFrameCallback`, so a frame has
|
|
495
|
+
already reached the compositor before the swap. Code that reveals on `playing`
|
|
496
|
+
needs a fade to cover the window where nothing has painted yet; this doesn't.
|
|
497
|
+
|
|
498
|
+
Two things that surprise people:
|
|
499
|
+
|
|
500
|
+
- **Both halves share the token.** Setting it on `:root` changes video and images
|
|
501
|
+
together. Scope it to a selector to split them.
|
|
502
|
+
- **An invalid value computes to `0s`, not to the stylesheet's default.**
|
|
503
|
+
`--polite-fade: 0` is invalid, since CSS `<time>` requires a unit, and so is any
|
|
504
|
+
typo. Measured in Chromium: both give an instant swap, because a substitution
|
|
505
|
+
that is invalid at computed-value time falls back to the property's initial
|
|
506
|
+
value, and the `var()` fallback only applies when the property is undefined.
|
|
507
|
+
Harmless on video, which cuts anyway; on images it silently removes the 350ms
|
|
508
|
+
fade.
|
|
509
|
+
|
|
510
|
+
Reduced motion overrides you either way: the stylesheet sets `transition: none`
|
|
511
|
+
there, so a fade you asked for never runs for someone who asked not to see one.
|
|
512
|
+
|
|
513
|
+
## Warming the next page's image
|
|
514
|
+
|
|
515
|
+
`polite-media/warm` fetches the image the _next_ page will show, while the
|
|
516
|
+
visitor is still deciding to go there. Every document prefetcher stops at the
|
|
517
|
+
HTML: Astro's `data-astro-prefetch`, Next's `<Link>` and quicklink all fetch the
|
|
518
|
+
document, and the hero inside it is discovered only once that document parses.
|
|
519
|
+
|
|
520
|
+
```js
|
|
521
|
+
import { warmOnIntent } from 'polite-media/warm';
|
|
522
|
+
|
|
523
|
+
// One listener for a whole grid, delegated on the document.
|
|
524
|
+
warmOnIntent('a[data-hero]', (link) => ({
|
|
525
|
+
sources: [{ type: 'image/avif', srcset: link.dataset.hero }],
|
|
526
|
+
src: '/fallback.jpg',
|
|
527
|
+
sizes: '(min-width: 50rem) 800px, 100vw',
|
|
528
|
+
}));
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
**Nothing in this package parses a media query**, which is the point: the
|
|
532
|
+
candidates are assembled as a detached `<picture>` and the browser picks, running
|
|
533
|
+
the same algorithm it will run on the destination. Hand-rolling that selection
|
|
534
|
+
drifts the moment `sizes` changes in one place and not the other.
|
|
535
|
+
|
|
536
|
+
`warm(options)` warms one image directly, for navigation that isn't a link.
|
|
537
|
+
`warmOnIntent` returns a teardown; call it if you re-bind per navigation, since
|
|
538
|
+
listeners on `document` survive a `<ClientRouter />` swap.
|
|
539
|
+
|
|
540
|
+
**Why not a `<link>` hint.** `imagesrcset` and `imagesizes` do responsive
|
|
541
|
+
selection, but only for `rel="preload"` with `as="image"`, and preload is for
|
|
542
|
+
resources "[your page will need very soon][preload]" rather than the next page's.
|
|
543
|
+
`prefetch` has the right timing and ignores those attributes. A preload link
|
|
544
|
+
injected on hover does select correctly in all three engines, measured, so it is
|
|
545
|
+
a real alternative; what it costs is a Chromium console warning on every warmed
|
|
546
|
+
image, because the resource is by definition never used by the page that
|
|
547
|
+
preloaded it. Going the other way, to `prefetch`, is worse: Safari doesn't
|
|
548
|
+
support `<link rel="prefetch">` and Firefox aborts it with `NS_BINDING_ABORTED`
|
|
549
|
+
without an explicit cache header. A detached image both selects and fetches, so
|
|
550
|
+
none of it applies.
|
|
551
|
+
|
|
552
|
+
**Skipped on Save-Data and 2g**, since nobody asked for these bytes yet. Deduped,
|
|
553
|
+
so repeated hovering warms once. Fetched at `fetchpriority="low"`.
|
|
554
|
+
|
|
555
|
+
**When you don't need this.** If your audience is Chromium and you already use
|
|
556
|
+
Speculation Rules `prerender`, that loads the whole destination including its
|
|
557
|
+
images and does strictly more. It is [not Baseline][prerender] and covers neither
|
|
558
|
+
Safari nor Firefox, which is the gap this fills.
|
|
559
|
+
|
|
560
|
+
## Client-side routers
|
|
561
|
+
|
|
562
|
+
If your pages are replaced without a reload (Astro's `<ClientRouter />`, or any
|
|
563
|
+
SPA router), **registration has to be re-run on every navigation**. Astro's docs
|
|
564
|
+
are explicit that ["bundled module scripts … are only ever executed once. After
|
|
565
|
+
initial execution they will be ignored, even if the script exists on the new page
|
|
566
|
+
after a transition"][astro-scripts], while the swap "completely replaces" the
|
|
567
|
+
body.
|
|
568
|
+
|
|
569
|
+
```js
|
|
570
|
+
import { registerAll } from 'polite-media/video';
|
|
571
|
+
|
|
572
|
+
document.addEventListener('astro:page-load', () => registerAll('[data-polite-media] video'));
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
`registerAll()` is idempotent, so a video that survived the swap is not
|
|
576
|
+
registered twice. Videos that did not survive need no cleanup: the coordinator
|
|
577
|
+
drops any entry whose element has left the document on its next pass.
|
|
578
|
+
|
|
579
|
+
## What it does
|
|
580
|
+
|
|
581
|
+
- Reveals on a genuinely presented frame, never on `playing`.
|
|
582
|
+
- Plays only what's on screen; stops what isn't.
|
|
583
|
+
- Caps how many videos run at once, on any viewport, not just small ones.
|
|
584
|
+
- Falls through to the next `<source>` when one can't be decoded.
|
|
585
|
+
- Honours `prefers-reduced-motion` live, and Save-Data on the next reconcile.
|
|
586
|
+
- Recovers from bfcache restores, tab refocus and blocked autoplay.
|
|
587
|
+
- Ships a pause control hook for [WCAG 2.2.2][wcag].
|
|
588
|
+
- Emits `polite-video:ready`, `polite-video:failed` and `polite-video:pausechange`.
|
|
589
|
+
|
|
590
|
+
## What it deliberately doesn't do
|
|
591
|
+
|
|
592
|
+
**It never sets a width, height or aspect ratio.** It owns _when_ media appears;
|
|
593
|
+
your CSS owns _where_. That is why it drops into an existing design at any video
|
|
594
|
+
size, and it would be undone by one dimension declaration.
|
|
595
|
+
|
|
596
|
+
It's also not an image pipeline (no srcset or poster generation: that's a build
|
|
597
|
+
step), not a lazy-loader for images (`loading="lazy"` is native), not a player,
|
|
598
|
+
not a lightbox, and not a scroll-animation library. `polite-media/warm` warms
|
|
599
|
+
candidates you already build, and generates none.
|
|
600
|
+
|
|
601
|
+
**Images are opt-in per image, not per container.** A container-wide rule hides
|
|
602
|
+
every image inside it, including ones the library then declines to fade, so each
|
|
603
|
+
one waits out the failsafe instead of fading.
|
|
604
|
+
|
|
605
|
+
**Eager images are revealed instantly rather than faded.** LCP excludes elements
|
|
606
|
+
at `opacity: 0` and revealing one doesn't restore its candidacy. Pass
|
|
607
|
+
`{ allowEager: true }` to fade anyway.
|
|
608
|
+
|
|
609
|
+
**An image needs a backdrop.** Video degrades to its poster; a lone image
|
|
610
|
+
degrades to nothing, so its container must carry a visible `background-color`.
|
|
611
|
+
|
|
612
|
+
**Images are only hidden where scripting can reveal them.** `image.css` puts the
|
|
613
|
+
hiding rule behind `@media (scripting: enabled)`, Baseline since December 2023,
|
|
614
|
+
so with scripting off the photos arrive unfaded instead of never arriving. And
|
|
615
|
+
because no media query can see a bundle that fails while scripting is on, the
|
|
616
|
+
stylesheet reveals any marked image after `--polite-failsafe` (default `5s`)
|
|
617
|
+
regardless. A missed selector or a dead bundle costs you the fade, not the
|
|
618
|
+
picture. The failsafe applies to every marked image, including managed ones,
|
|
619
|
+
because an earlier design that exempted them could send an already-revealed image
|
|
620
|
+
back to hidden.
|
|
621
|
+
|
|
622
|
+
## Why it exists
|
|
623
|
+
|
|
624
|
+
Every bug it fixes is one you can't see in development.
|
|
625
|
+
|
|
626
|
+
**`playing` is not when the picture appears.** The usual advice is to swap the
|
|
627
|
+
poster for the video on the `playing` event. Measured on one machine in one run,
|
|
628
|
+
H.264 presented its first frame **1.6 ms before** `playing` fired, while AV1
|
|
629
|
+
presented **0.8 ms after**. It isn't early, it's _unordered_, so no delay tunes
|
|
630
|
+
it away. `requestVideoFrameCallback` is specified in terms of a frame reaching
|
|
631
|
+
the compositor, so it's right by definition.
|
|
632
|
+
|
|
633
|
+
**`canPlayType` lies.** In this repo's own fixtures, Chromium answered
|
|
634
|
+
`"probably"` for `sample-truncated-av1.mp4` and then failed with
|
|
635
|
+
`PIPELINE_ERROR_DECODE: dav1d_send_data() failed with error -22`. That is the bar
|
|
636
|
+
the specification sets, not a quirk: the [HTML Standard][html-canplaytype] says
|
|
637
|
+
the method returns `"probably"` only "if the user agent is confident that the
|
|
638
|
+
type represents a media resource that it can render". Confidence is not a
|
|
639
|
+
guarantee, so codec checks only _order_ the candidates here and the `error` event
|
|
640
|
+
decides.
|
|
641
|
+
|
|
642
|
+
**Absence of `navigator.connection` means allow, not block.** Safari and Firefox
|
|
643
|
+
never expose the Network Information API and Brave disables it as a
|
|
644
|
+
fingerprinting surface. Read absence as "block" and you silently kill video for
|
|
645
|
+
most of the web, while every test on Chrome still passes.
|
|
646
|
+
|
|
647
|
+
**Video comes back frozen after a back-navigation.** Scripts don't re-run on a
|
|
648
|
+
bfcache restore, and mobile browsers pause video while the tab is hidden and
|
|
649
|
+
leave it paused on return.
|
|
650
|
+
|
|
651
|
+
## Status
|
|
652
|
+
|
|
653
|
+
Framework-agnostic by construction: no framework dependency, standard DOM only,
|
|
654
|
+
developed against Astro projects.
|
|
655
|
+
|
|
656
|
+
**Tested on Chromium, Firefox and WebKit**, all three driven by Playwright
|
|
657
|
+
against real media on every change, alongside 211 unit tests. Other engines are
|
|
658
|
+
unexercised rather than unsupported.
|
|
659
|
+
|
|
660
|
+
**iOS Safari is outside the matrix**, and Playwright's WebKit does not stand in
|
|
661
|
+
for it. The path that rests on it is the retry after a refused `play()`, which
|
|
662
|
+
rejects with `NotAllowedError`; the library follows MDN's documented remedy of
|
|
663
|
+
surfacing a control and waiting for a gesture.
|
|
664
|
+
|
|
665
|
+
**`0.x`**, so a minor bump may still change behaviour; `CHANGELOG.md` says when
|
|
666
|
+
it does. Nothing runs it in production so far.
|
|
667
|
+
|
|
668
|
+
Contributing, and how to run the suite: [CONTRIBUTING.md](CONTRIBUTING.md). MIT.
|
|
669
|
+
|
|
670
|
+
[astro-scripts]: https://docs.astro.build/en/guides/view-transitions/#script-re-execution
|
|
671
|
+
[html-canplaytype]: https://html.spec.whatwg.org/multipage/media.html
|
|
672
|
+
[prerender]: https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API
|
|
673
|
+
[lcp]: https://web.dev/articles/lcp
|
|
674
|
+
[preload]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/preload
|
|
675
|
+
[wcag]: https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html
|