@design-edito/tools 0.5.17 → 0.5.18

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 (55) hide show
  1. package/agnostic/colors/index.d.ts +2 -2
  2. package/agnostic/colors/index.js +2 -2
  3. package/agnostic/html/hyper-json/smart-tags/coalesced/index.d.ts +13 -13
  4. package/agnostic/html/hyper-json/smart-tags/coalesced/index.js +13 -13
  5. package/agnostic/html/hyper-json/smart-tags/isolated/index.d.ts +3 -3
  6. package/agnostic/html/hyper-json/smart-tags/isolated/index.js +3 -3
  7. package/agnostic/html/index.d.ts +2 -2
  8. package/agnostic/html/index.js +2 -2
  9. package/agnostic/index.d.ts +4 -4
  10. package/agnostic/index.js +4 -4
  11. package/agnostic/misc/index.d.ts +4 -4
  12. package/agnostic/misc/index.js +4 -4
  13. package/agnostic/numbers/index.d.ts +2 -2
  14. package/agnostic/numbers/index.js +2 -2
  15. package/agnostic/objects/index.d.ts +2 -2
  16. package/agnostic/objects/index.js +2 -2
  17. package/agnostic/optim/index.d.ts +1 -1
  18. package/agnostic/optim/index.js +1 -1
  19. package/agnostic/sanitization/index.d.ts +1 -1
  20. package/agnostic/sanitization/index.js +1 -1
  21. package/agnostic/strings/index.d.ts +2 -2
  22. package/agnostic/strings/index.js +2 -2
  23. package/agnostic/subtitles/index.d.ts +1 -1
  24. package/agnostic/subtitles/index.js +1 -1
  25. package/agnostic/time/index.d.ts +3 -3
  26. package/agnostic/time/index.js +3 -3
  27. package/components/Image/index.d.ts +6 -0
  28. package/components/Image/index.js +3 -18
  29. package/components/Video/index.controlled.d.ts +5 -22
  30. package/components/Video/index.controlled.js +28 -42
  31. package/components/Video/index.d.ts +28 -37
  32. package/components/Video/index.js +83 -88
  33. package/components/Video/types.d.ts +26 -0
  34. package/components/Video/types.js +1 -0
  35. package/components/Video/utils.d.ts +38 -1
  36. package/components/Video/utils.js +23 -4
  37. package/components/index.d.ts +5 -5
  38. package/components/index.js +5 -5
  39. package/components/utils/index.d.ts +21 -0
  40. package/components/utils/index.js +40 -0
  41. package/components/utils/viewport-behaviours/index.d.ts +54 -0
  42. package/components/utils/viewport-behaviours/index.js +163 -0
  43. package/components/utils/viewport-behaviours/types.d.ts +109 -0
  44. package/components/utils/viewport-behaviours/types.js +14 -0
  45. package/components/utils/viewport-behaviours/utils.d.ts +26 -0
  46. package/components/utils/viewport-behaviours/utils.js +55 -0
  47. package/node/@aws-s3/storage/file/index.d.ts +1 -1
  48. package/node/@aws-s3/storage/file/index.js +1 -1
  49. package/node/@google-cloud/storage/file/index.d.ts +1 -1
  50. package/node/@google-cloud/storage/file/index.js +1 -1
  51. package/node/images/index.d.ts +1 -1
  52. package/node/images/index.js +1 -1
  53. package/node/images/transform/operations/index.d.ts +2 -2
  54. package/node/images/transform/operations/index.js +2 -2
  55. package/package.json +16 -1
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, useRef, useState, useCallback, useEffect } from 'react';
3
3
  import { clss } from '../../agnostic/css/clss/index.js';
4
4
  import { formatDuration } from '../../agnostic/time/duration/format-duration/index.js';
5
- import { mergeClassNames, useChangeDispatch } from '../utils/index.js';
5
+ import { mergeClassNames, parseSourceList, useChangeDispatch } from '../utils/index.js';
6
6
  import cssModule from './styles.module.css';
7
7
  import { video as publicClassName } from '../public-classnames.js';
8
8
  import { Subtitles } from '../Subtitles/index.js';
@@ -57,8 +57,9 @@ subtitlesOn = true, mute, muted, volume = 1, playbackRate = 1, currentTimeMs: gi
57
57
  // Milliseconds by default, which is what this component has always rendered — a
58
58
  // default that changes under a consumer is a change nobody asked for. An article
59
59
  // usually wants `'{{mm}}:{{ss}}'` and says so.
60
- timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, onSubtitlesButtonClicked, onTimelineClicked, onIsPlayingChanged, onIsFullscreenChanged, onIsLoudChanged, onIsSubtitlesOnChanged, onIsEndedChanged, onVolumeChanged, onPlaybackRateChanged, onCurrentTimeMsChanged, onFullscreenChange, children, className, rootRef, ...intrinsicVideoAttributes }) => {
61
- const videoRef = useRef(null);
60
+ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, onSubtitlesButtonClicked, onTimelineClicked, onVideoClicked, togglePlayOnClick, onIsPlayingChanged, onIsFullscreenChanged, onIsLoudChanged, onIsSubtitlesOnChanged, onIsEndedChanged, onVolumeChanged, onPlaybackRateChanged, onCurrentTimeMsChanged, onFullscreenChange, children, className, rootRef, videoRef: givenVideoRef, ...intrinsicVideoAttributes }) => {
61
+ const ownVideoRef = useRef(null);
62
+ const videoRef = givenVideoRef ?? ownVideoRef;
62
63
  const [totalTime, setTotalTime] = useState(0);
63
64
  const totalTimeMs = useMemo(() => secondsToMs(totalTime), [totalTime]);
64
65
  const [internalCurrentTimeMs, setInternalCurrentTimeMs] = useState(0);
@@ -121,6 +122,20 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
121
122
  const wasPlaying = videoRef.current?.paused === false;
122
123
  onPlayButtonClicked?.(e, wasPlaying, videoRef.current);
123
124
  }, [onPlayButtonClicked]);
125
+ // The picture as a play/pause surface. It reports and nothing more, like every other
126
+ // control here: the uncontrolled layer above owns the play state.
127
+ //
128
+ // The listener sits on the element and not on the `<figure>`, so the controls painted
129
+ // over it are not part of the surface — a click on pause would otherwise toggle twice
130
+ // and cancel itself. No `role` and no `tabIndex` either: this is a **second** way to
131
+ // do what the play button already does, and giving it a focus stop would put the same
132
+ // action twice in the tab order.
133
+ const handleVideoClick = useCallback((e) => {
134
+ if (togglePlayOnClick !== true)
135
+ return;
136
+ const wasPlaying = videoRef.current?.paused === false;
137
+ onVideoClicked?.(e, wasPlaying, videoRef.current);
138
+ }, [togglePlayOnClick, onVideoClicked]);
124
139
  const handlePauseButtonClick = useCallback((e) => {
125
140
  const wasPlaying = videoRef.current?.paused === false;
126
141
  onPauseButtonClicked?.(e, wasPlaying, videoRef.current);
@@ -170,7 +185,11 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
170
185
  // caché, c'est l'absence d'un état. Une feuille qui veut viser ce cas le reconnaît à
171
186
  // ce qu'aucun des deux modifieurs n'est là.
172
187
  'subtitles-on': subtitles !== undefined && subtitlesOn,
173
- 'subtitles-off': subtitles !== undefined && !subtitlesOn
188
+ 'subtitles-off': subtitles !== undefined && !subtitlesOn,
189
+ // A capability rather than a state, and it is here for the same reason the lightbox
190
+ // carries `--open-on-click`: a surface that answers a click has to be able to say so
191
+ // with a cursor.
192
+ 'toggle-play-on-click': togglePlayOnClick === true
174
193
  }), className);
175
194
  // Guarded: the duration is unknown until the metadata lands, and an unguarded
176
195
  // division would expose the string 'NaN' on every render until then.
@@ -190,7 +209,8 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
190
209
  'data-playback-rate': playbackRate,
191
210
  'data-current-time-ms': currentTimeMs.toFixed(2),
192
211
  'data-current-time-ratio': currentTimeRatio.toFixed(8),
193
- 'data-total-time-ms': totalTimeMs
212
+ 'data-total-time-ms': totalTimeMs,
213
+ 'data-toggle-play-on-click': togglePlayOnClick === true ? '' : undefined
194
214
  };
195
215
  const rootStyles = {
196
216
  '--lm-video-current-time': `${currentTimeMs}ms`,
@@ -201,38 +221,8 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
201
221
  '--lm-video-volume-ratio': `${volume}`,
202
222
  '--lm-video-playback-rate': `${playbackRate}`
203
223
  };
204
- const parsedSources = useMemo(() => {
205
- if (sources === undefined)
206
- return [];
207
- if (typeof sources === 'string')
208
- return [{ src: sources }];
209
- if (Array.isArray(sources)) {
210
- if (sources.length === 0)
211
- return [];
212
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- first element sampled just above; array is expected to be homogeneous
213
- if (typeof sources[0] === 'string')
214
- return sources.map(src => ({ src }));
215
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- first element was checked not to be a string just above; array is expected to be homogeneous
216
- return sources;
217
- }
218
- return [];
219
- }, [sources]);
220
- const parsedTracks = useMemo(() => {
221
- if (tracks === undefined)
222
- return [];
223
- if (typeof tracks === 'string')
224
- return [{ src: tracks }];
225
- if (Array.isArray(tracks)) {
226
- if (tracks.length === 0)
227
- return [];
228
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- first element sampled just above; array is expected to be homogeneous
229
- if (typeof tracks[0] === 'string')
230
- return tracks.map(src => ({ src }));
231
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- first element was checked not to be a string just above; array is expected to be homogeneous
232
- return tracks;
233
- }
234
- return [];
235
- }, [tracks]);
224
+ const parsedSources = useMemo(() => parseSourceList(sources, 'src'), [sources]);
225
+ const parsedTracks = useMemo(() => parseSourceList(tracks, 'src'), [tracks]);
236
226
  const videoClss = c('video');
237
227
  const videoControlsClss = c('video-controls');
238
228
  const playBtnClss = c('play-btn');
@@ -318,9 +308,5 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
318
308
  useChangeDispatch(isEnded, onIsEndedChanged);
319
309
  useChangeDispatch(volume, onVolumeChanged);
320
310
  useChangeDispatch(playbackRate, onPlaybackRateChanged);
321
- return _jsxs("figure", { ref: rootRef, className: rootClss, style: rootStyles, ...rootAttributes, children: [_jsxs("video", { ref: videoRef, className: videoClss, ...intrinsicVideoAttributes, autoPlay: isTimeControlled ? false : intrinsicVideoAttributes.autoPlay, onLoadedMetadata: handleMetadataLoadEvent, onTimeUpdate: handleOnTimeUpdateEvent, onEnded: handleEndedEvent, onPlay: handlePlayEvent, onPause: handlePauseEvent, children: [parsedSources.map((source, index) => typeof source === 'string'
322
- ? _jsx("source", { src: source }, index)
323
- : _jsx("source", { src: source.src, type: source.type }, index)), parsedTracks.map((track, index) => typeof track === 'string'
324
- ? _jsx("track", { src: track }, index)
325
- : _jsx("track", { src: track.src, kind: track.kind, srcLang: track.srclang, label: track.label, default: track.default }, index)), children] }), _jsxs("div", { className: videoControlsClss, children: [_jsx("button", { className: playBtnClss, onClick: handlePlayButtonClick, children: playBtnContent }), _jsx("button", { className: pauseBtnClss, onClick: handlePauseButtonClick, children: pauseBtnContent }), _jsx("button", { className: loudBtnClss, onClick: handleLoudButtonClick, children: loudBtnContent }), _jsx("button", { className: muteBtnClss, onClick: handleMuteButtonClick, children: muteBtnContent }), _jsx("input", { type: 'range', className: volumeRangeClss, value: volumePercent, onChange: handleVolumeRangeChange, min: 0, max: 100, step: 1 }), _jsx("span", { className: volumePcntClss, children: Math.round(volumePercent) }), _jsx("button", { className: fullscreenBtnClss, onClick: handleFullscreenButtonClick, children: fullscreenBtnContent }), subtitles !== undefined && _jsx("button", { className: subtitlesBtnClss, onClick: handleSubtitlesButtonClick, children: subtitlesBtnContent }), _jsx("input", { type: 'range', className: playbackRateRangeClss, value: playbackRate, onChange: handleRateRangeChange, min: 0.25, max: 4, step: 0.25 }), _jsx("span", { className: playbackRateClss, children: playbackRate })] }), _jsxs("div", { className: timeControlsClss, children: [_jsx("span", { className: currentTimeClss, children: formatDuration(currentTimeMs, timeFormat) }), _jsx("span", { className: totalTimeClss, children: formatDuration(totalTimeMs, timeFormat) }), _jsx("div", { className: timelineClss, onClick: handleTimelineClick })] }), subtitles !== undefined && _jsx(Subtitles, { ...subtitles, timecodeMs: currentTimeMs, isEnded: isEnded })] });
311
+ return _jsxs("figure", { ref: rootRef, className: rootClss, style: rootStyles, ...rootAttributes, children: [_jsxs("video", { ref: videoRef, className: videoClss, ...intrinsicVideoAttributes, autoPlay: isTimeControlled ? false : intrinsicVideoAttributes.autoPlay, onClick: handleVideoClick, onLoadedMetadata: handleMetadataLoadEvent, onTimeUpdate: handleOnTimeUpdateEvent, onEnded: handleEndedEvent, onPlay: handlePlayEvent, onPause: handlePauseEvent, children: [parsedSources.map((source, index) => _jsx("source", { src: source.src, type: source.type }, index)), parsedTracks.map((track, index) => _jsx("track", { src: track.src, kind: track.kind, srcLang: track.srclang, label: track.label, default: track.default }, index)), children] }), _jsxs("div", { className: videoControlsClss, children: [_jsx("button", { className: playBtnClss, onClick: handlePlayButtonClick, children: playBtnContent }), _jsx("button", { className: pauseBtnClss, onClick: handlePauseButtonClick, children: pauseBtnContent }), _jsx("button", { className: loudBtnClss, onClick: handleLoudButtonClick, children: loudBtnContent }), _jsx("button", { className: muteBtnClss, onClick: handleMuteButtonClick, children: muteBtnContent }), _jsx("input", { type: 'range', className: volumeRangeClss, value: volumePercent, onChange: handleVolumeRangeChange, min: 0, max: 100, step: 1 }), _jsx("span", { className: volumePcntClss, children: Math.round(volumePercent) }), _jsx("button", { className: fullscreenBtnClss, onClick: handleFullscreenButtonClick, children: fullscreenBtnContent }), subtitles !== undefined && _jsx("button", { className: subtitlesBtnClss, onClick: handleSubtitlesButtonClick, children: subtitlesBtnContent }), _jsx("input", { type: 'range', className: playbackRateRangeClss, value: playbackRate, onChange: handleRateRangeChange, min: 0.25, max: 4, step: 0.25 }), _jsx("span", { className: playbackRateClss, children: playbackRate })] }), _jsxs("div", { className: timeControlsClss, children: [_jsx("span", { className: currentTimeClss, children: formatDuration(currentTimeMs, timeFormat) }), _jsx("span", { className: totalTimeClss, children: formatDuration(totalTimeMs, timeFormat) }), _jsx("div", { className: timelineClss, onClick: handleTimelineClick })] }), subtitles !== undefined && _jsx(Subtitles, { ...subtitles, timecodeMs: currentTimeMs, isEnded: isEnded })] });
326
312
  };
@@ -1,35 +1,27 @@
1
1
  import { type FunctionComponent } from 'react';
2
- import type { WithViewportObservation } from '../utils/types.js';
2
+ import { type ViewportBehaviours } from '../utils/viewport-behaviours/index.js';
3
+ import type { VideoAction } from './types.js';
3
4
  import { type Props as ControlledProps } from './index.controlled.js';
4
5
  /**
5
6
  * Props for the {@link Video} component.
6
7
  *
7
8
  * Extends all ControlledVideo props except play, mute, fullscreen, volume, playbackRate, and their associated event handlers
8
- * @property autoPlayWhenVisible - When `true`, starts playback every time the
9
- * component enters the viewport.
10
- * @property autoPlayOnceVisible - Same, but only the first time it does.
11
- * @property autoPauseWhenHidden - When `true`, pauses playback every time the
12
- * component leaves the viewport.
13
- * @property autoPauseOnceHidden - Same, but only the first time it does.
14
- * @property autoLoudWhenVisible - When `true`, unmutes every time the component
15
- * enters the viewport.
16
- * @property autoLoudOnceVisible - Same, but only the first time it does.
17
- * @property autoMuteWhenHidden - When `true`, mutes every time the component leaves
18
- * the viewport.
19
- * @property autoMuteOnceHidden - Same, but only the first time it does.
20
- * @property threshold - How much of the component has to be in view before it
21
- * counts as visible, forwarded to the internal {@link IntersectionObserver}. `0.3`
22
- * to start on a third of it; omitted, a single pixel is enough.
23
- * @property root - The observer's root. Defaults to the viewport.
24
- * @property rootMargin - Grows or shrinks that root before measuring.
25
- * @property onVisibilityChanged - Called on every crossing with the new value,
26
- * whether or not an `auto…` behaviour is bound to it. Never on mount.
9
+ * @property visibilityThreshold - How much of the video has to be on screen to count as
10
+ * seen. See {@link VisibilityOptions} for this and the four that follow it.
11
+ * @property whenVisible - What to do each time it becomes visible.
12
+ * @property whenHidden - The same, on the way out.
13
+ * @property onVisibilityChanged - Called with the new value once it has settled — the
14
+ * delays included, so this and the instructions are told the same story. Never on mount.
27
15
  * @property currentTimeMs - When provided, hands ownership of the current time
28
16
  * (in milliseconds) to the parent, which is then responsible for updating it —
29
17
  * typically to scrub the video from scroll position. A controlled time implies a
30
18
  * stopped video, since a playing element would advance a value it does not own:
31
- * `autoPlay`, `autoPlayWhenVisible` and the play button have no effect for as
19
+ * `autoPlay`, a `'play'` instruction and the play button have no effect for as
32
20
  * long as this prop is provided.
21
+ * @property togglePlayOnClick - When `true`, a click on the picture plays or pauses it.
22
+ * A **second** way to reach what the play and pause buttons already do, never the only
23
+ * one: the element takes no focus stop, so the keyboard keeps using the buttons. The
24
+ * controls painted over the picture are not part of the surface.
33
25
  * @property defaultSubtitlesOn - Whether the subtitles start shown. `true` by default —
34
26
  * an article that supplies cues means them to be read. The reader's button takes it from
35
27
  * there, so this is a starting point and not a setting: `default…` and never `initial…`,
@@ -38,16 +30,8 @@ import { type Props as ControlledProps } from './index.controlled.js';
38
30
  * @property children - React children rendered inside the `<video>` element itself
39
31
  * (e.g. fallback content).
40
32
  */
41
- export type Props = WithViewportObservation<Omit<ControlledProps, 'play' | 'fullscreen' | 'volume' | 'mute' | 'playbackRate' | 'subtitlesOn' | 'rootRef'>> & {
33
+ export type Props = Omit<ControlledProps, 'play' | 'fullscreen' | 'volume' | 'mute' | 'playbackRate' | 'subtitlesOn' | 'rootRef' | 'videoRef'> & ViewportBehaviours<VideoAction> & {
42
34
  defaultSubtitlesOn?: boolean;
43
- autoPlayWhenVisible?: boolean;
44
- autoPlayOnceVisible?: boolean;
45
- autoPauseWhenHidden?: boolean;
46
- autoPauseOnceHidden?: boolean;
47
- autoLoudWhenVisible?: boolean;
48
- autoLoudOnceVisible?: boolean;
49
- autoMuteWhenHidden?: boolean;
50
- autoMuteOnceHidden?: boolean;
51
35
  };
52
36
  /**
53
37
  * Full-featured video player component. Wraps a native `<video>` element with
@@ -70,11 +54,18 @@ export type Props = WithViewportObservation<Omit<ControlledProps, 'play' | 'full
70
54
  * label: a screen reader announces a video player. Worth a `tag` prop the day that
71
55
  * matters more than autoplay — not before.
72
56
  *
73
- * Each viewport-driven behaviour comes in two flavours: `…When…` fires on every
74
- * crossing, `…Once…` only on the first one. A `…Once…` flag is armed by its own
75
- * automatic trigger and by nothing else — pressing play does not spend the one
76
- * automatic play the component still owed. Setting both flavours of the same
77
- * behaviour is the same as setting the `…When…` one alone.
57
+ * **Viewport-driven behaviour is declared, not named by a prop.** `whenVisible` and
58
+ * `whenHidden` take a verb or a list of them, out of {@link VideoAction} — `'play'`,
59
+ * `'mute'`, `'jump-to:500'` — each optionally suffixed by `':once'` and `':force'`. See
60
+ * `components/utils/viewport-behaviours` for the grammar and its rules, and the
61
+ * `visibility…` props for what « visible » means and how long
62
+ * it has to have been true.
63
+ *
64
+ * **An instruction yields to the reader by default.** Touching a control surrenders its
65
+ * domain for the rest of the mount: playback covers the play and pause buttons, the
66
+ * picture when it toggles, and the timeline, since seeking is saying where one wants to
67
+ * be. Sound covers the two sound buttons and the volume slider. Fullscreen and playback
68
+ * rate surrender nothing, being neither. `':force'` opts out.
78
69
  *
79
70
  * **`loop` reaches the element untouched**, like every other native media attribute
80
71
  * this component does not drive itself. It was once destructured out of the props and
@@ -83,8 +74,8 @@ export type Props = WithViewportObservation<Omit<ControlledProps, 'play' | 'full
83
74
  * consequence worth knowing: a looping element never fires `ended`, so the `--ended`
84
75
  * modifier and the `isEnded` it feeds to `Subtitles` simply never come.
85
76
  *
86
- * Browsers refuse an unmuted `play()` outside a user gesture, so pairing an
87
- * `autoLoud…` with an `autoPlay…` will usually have the playback rejected. The
77
+ * Browsers refuse an unmuted `play()` outside a user gesture, so pairing a `'loud'`
78
+ * instruction with a `'play'` one will usually have the playback rejected. The
88
79
  * refusal is caught rather than ignored — the element is read back once the attempt
89
80
  * settles, and the play state follows what it says — so the controls stay truthful.
90
81
  * The media still won't play, though: autoplay muted, and leave unmuting to the
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
- import { useIntersectionObserver } from '../IntersectionObserver/index.js';
4
- import { muteAttributeWorkaround, shouldRunAutoBehaviour } from './utils.js';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { useViewportBehaviours } from '../utils/viewport-behaviours/index.js';
4
+ import { forceJumpTo, muteAttributeWorkaround } from './utils.js';
5
5
  import { ControlledVideo } from './index.controlled.js';
6
6
  /**
7
7
  * Full-featured video player component. Wraps a native `<video>` element with
@@ -24,11 +24,18 @@ import { ControlledVideo } from './index.controlled.js';
24
24
  * label: a screen reader announces a video player. Worth a `tag` prop the day that
25
25
  * matters more than autoplay — not before.
26
26
  *
27
- * Each viewport-driven behaviour comes in two flavours: `…When…` fires on every
28
- * crossing, `…Once…` only on the first one. A `…Once…` flag is armed by its own
29
- * automatic trigger and by nothing else — pressing play does not spend the one
30
- * automatic play the component still owed. Setting both flavours of the same
31
- * behaviour is the same as setting the `…When…` one alone.
27
+ * **Viewport-driven behaviour is declared, not named by a prop.** `whenVisible` and
28
+ * `whenHidden` take a verb or a list of them, out of {@link VideoAction} — `'play'`,
29
+ * `'mute'`, `'jump-to:500'` — each optionally suffixed by `':once'` and `':force'`. See
30
+ * `components/utils/viewport-behaviours` for the grammar and its rules, and the
31
+ * `visibility…` props for what « visible » means and how long
32
+ * it has to have been true.
33
+ *
34
+ * **An instruction yields to the reader by default.** Touching a control surrenders its
35
+ * domain for the rest of the mount: playback covers the play and pause buttons, the
36
+ * picture when it toggles, and the timeline, since seeking is saying where one wants to
37
+ * be. Sound covers the two sound buttons and the volume slider. Fullscreen and playback
38
+ * rate surrender nothing, being neither. `':force'` opts out.
32
39
  *
33
40
  * **`loop` reaches the element untouched**, like every other native media attribute
34
41
  * this component does not drive itself. It was once destructured out of the props and
@@ -37,14 +44,14 @@ import { ControlledVideo } from './index.controlled.js';
37
44
  * consequence worth knowing: a looping element never fires `ended`, so the `--ended`
38
45
  * modifier and the `isEnded` it feeds to `Subtitles` simply never come.
39
46
  *
40
- * Browsers refuse an unmuted `play()` outside a user gesture, so pairing an
41
- * `autoLoud…` with an `autoPlay…` will usually have the playback rejected. The
47
+ * Browsers refuse an unmuted `play()` outside a user gesture, so pairing a `'loud'`
48
+ * instruction with a `'play'` one will usually have the playback rejected. The
42
49
  * refusal is caught rather than ignored — the element is read back once the attempt
43
50
  * settles, and the play state follows what it says — so the controls stay truthful.
44
51
  * The media still won't play, though: autoplay muted, and leave unmuting to the
45
52
  * reader.
46
53
  */
47
- export const Video = ({ defaultSubtitlesOn = true, autoPlayWhenVisible, autoPlayOnceVisible, autoPauseWhenHidden, autoPauseOnceHidden, autoMuteWhenHidden, autoMuteOnceHidden, autoLoudWhenVisible, autoLoudOnceVisible, threshold, root, rootMargin, onVisibilityChanged, onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, onSubtitlesButtonClicked, ...controlledProps }) => {
54
+ export const Video = ({ defaultSubtitlesOn = true, visibilityThreshold, visibilityRoot, visibilityRootMargin, visibilityOnAfterMs, visibilityOffAfterMs, whenVisible, whenHidden, onVisibilityChanged, onVideoClicked, onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, onSubtitlesButtonClicked, ...controlledProps }) => {
48
55
  // State & refs
49
56
  const [play, setPlay] = useState(false);
50
57
  const [volume, setVolume] = useState(1);
@@ -52,39 +59,55 @@ export const Video = ({ defaultSubtitlesOn = true, autoPlayWhenVisible, autoPlay
52
59
  const [playbackRate, setPlaybackRate] = useState(1);
53
60
  const [fullscreen, setFullscreen] = useState(false);
54
61
  const [subtitlesOn, setSubtitlesOn] = useState(defaultSubtitlesOn);
55
- // One flag per `…Once…` behaviour, and each is armed by that behaviour's own
56
- // automatic trigger. A single shared flag conflated four questions, and being set
57
- // on any `play` event — a user click included — meant the first press cancelled
58
- // behaviours that had nothing to do with playback.
59
- const hasAutoPlayedOnce = useRef(false);
60
- const hasAutoPausedOnce = useRef(false);
61
- const hasAutoLoudedOnce = useRef(false);
62
- const hasAutoMutedOnce = useRef(false);
63
- // Several paths below ask for playback — the play button, autoPlayWhenVisible,
64
- // autoPlay itself. None of them may win over a parent-owned time, so the
65
- // invariant is applied where the state is forwarded rather than guarded at each
66
- // of those call sites.
62
+ // Several paths below ask for playback — the play button, a `'play'` instruction,
63
+ // `autoPlay` itself. None of them may win over a parent-owned time, so the invariant
64
+ // is applied where the state is forwarded rather than guarded at each call site.
67
65
  const isTimeControlled = controlledProps.currentTimeMs !== undefined;
68
66
  const rootRef = useRef(null);
69
- const needsObserve = useMemo(() => onVisibilityChanged !== undefined
70
- || autoLoudWhenVisible === true
71
- || autoLoudOnceVisible === true
72
- || autoMuteWhenHidden === true
73
- || autoMuteOnceHidden === true
74
- || autoPlayWhenVisible === true
75
- || autoPlayOnceVisible === true
76
- || autoPauseWhenHidden === true
77
- || autoPauseOnceHidden === true, [
78
- autoLoudWhenVisible,
79
- autoLoudOnceVisible,
80
- autoMuteWhenHidden,
81
- autoMuteOnceHidden,
82
- autoPlayWhenVisible,
83
- autoPlayOnceVisible,
84
- autoPauseWhenHidden,
85
- autoPauseOnceHidden,
67
+ const videoRef = useRef(null);
68
+ // Viewport behaviours
69
+ // What each verb does, and what it competes with. Rebuilt on every render — it closes
70
+ // over the setters — which is why the generic layer reads it through a ref rather than
71
+ // a dependency list.
72
+ //
73
+ // **Seeking belongs to `playback`.** A reader who has dragged the timeline has said
74
+ // where they want to be, and an automatic jump would take it back from them.
75
+ const actions = {
76
+ play: { kind: 'start', domain: 'playback', run: () => setPlay(true) },
77
+ pause: { kind: 'stop', domain: 'playback', run: () => setPlay(false) },
78
+ loud: { kind: 'start', domain: 'sound', run: () => setMute(false) },
79
+ mute: { kind: 'stop', domain: 'sound', run: () => setMute(true) },
80
+ // A seek starts nothing on its own — it moves a playhead, playing or not — so a gate
81
+ // has no reason to hold it back.
82
+ 'jump-to': {
83
+ kind: 'stop',
84
+ domain: 'playback',
85
+ run: arg => {
86
+ const targetMs = Number(arg);
87
+ if (!Number.isFinite(targetMs))
88
+ return;
89
+ forceJumpTo(videoRef.current, targetMs);
90
+ }
91
+ },
92
+ 'jump-start': { kind: 'stop', domain: 'playback', run: () => forceJumpTo(videoRef.current, 0) },
93
+ 'jump-end': { kind: 'stop', domain: 'playback', run: () => forceJumpTo(videoRef.current, -1) }
94
+ };
95
+ const { surrender: surrenderAnyDomain } = useViewportBehaviours(rootRef, {
96
+ visibilityThreshold,
97
+ visibilityRoot,
98
+ visibilityRootMargin,
99
+ visibilityOnAfterMs,
100
+ visibilityOffAfterMs,
101
+ whenVisible,
102
+ whenHidden,
86
103
  onVisibilityChanged
87
- ]);
104
+ }, actions);
105
+ // Narrowed back to this component's own domains. The generic layer takes a `string`,
106
+ // having no vocabulary of its own; handing the handlers a typed door means a
107
+ // misspelled domain fails to compile instead of quietly never surrendering anything.
108
+ const surrender = useCallback((domain) => {
109
+ surrenderAnyDomain(domain);
110
+ }, [surrenderAnyDomain]);
88
111
  // Intrisic event handlers
89
112
  const handleOnPlayEvent = useCallback((e) => {
90
113
  controlledProps.onPlay?.(e);
@@ -122,28 +145,41 @@ export const Video = ({ defaultSubtitlesOn = true, autoPlayWhenVisible, autoPlay
122
145
  // User actions
123
146
  const handlePlayButtonClick = useCallback((e, isPlaying, video) => {
124
147
  onPlayButtonClicked?.(e, isPlaying, video);
148
+ surrender('playback');
125
149
  setPlay(true);
126
- }, [onPlayButtonClicked]);
150
+ }, [onPlayButtonClicked, surrender]);
127
151
  const handlePauseButtonClick = useCallback((e, isPlaying, video) => {
128
152
  onPauseButtonClicked?.(e, isPlaying, video);
153
+ surrender('playback');
129
154
  setPlay(false);
130
- }, [onPauseButtonClicked]);
155
+ }, [onPauseButtonClicked, surrender]);
156
+ // The picture toggles, where the two buttons each say one thing. It is the same gesture
157
+ // as pressing one of them — a consumer tracking whether the reader has taken over
158
+ // playback has to count this one too, since it is the same decision made elsewhere.
159
+ const handleVideoClick = useCallback((e, isPlaying, video) => {
160
+ onVideoClicked?.(e, isPlaying, video);
161
+ surrender('playback');
162
+ setPlay(!isPlaying);
163
+ }, [onVideoClicked, surrender]);
131
164
  const handleLoudButtonClick = useCallback((e, isLoud, video) => {
132
165
  onLoudButtonClicked?.(e, isLoud, video);
166
+ surrender('sound');
133
167
  setMute(false);
134
- }, [onLoudButtonClicked]);
168
+ }, [onLoudButtonClicked, surrender]);
135
169
  const handleMuteButtonClick = useCallback((e, isLoud, video) => {
136
170
  onMuteButtonClicked?.(e, isLoud, video);
171
+ surrender('sound');
137
172
  setMute(true);
138
- }, [onMuteButtonClicked]);
173
+ }, [onMuteButtonClicked, surrender]);
139
174
  const handleRateRangeChange = useCallback((e, targetRate, currentRate, video) => {
140
175
  onRateRangeChanged?.(e, targetRate, currentRate, video);
141
176
  setPlaybackRate(targetRate);
142
177
  }, [onRateRangeChanged]);
143
178
  const handleVolumeRangeChange = useCallback((e, targetVolume, currentVolume, video) => {
144
179
  onVolumeRangeChanged?.(e, targetVolume, currentVolume, video);
180
+ surrender('sound');
145
181
  setVolume(targetVolume);
146
- }, [onVolumeRangeChanged]);
182
+ }, [onVolumeRangeChanged, surrender]);
147
183
  const handleFullscreenButtonClick = useCallback((e, isFullscreen, video) => {
148
184
  onFullscreenButtonClicked?.(e, isFullscreen, video);
149
185
  setFullscreen(!isFullscreen);
@@ -156,53 +192,12 @@ export const Video = ({ defaultSubtitlesOn = true, autoPlayWhenVisible, autoPlay
156
192
  onSubtitlesButtonClicked?.(e, isSubtitlesOn, video);
157
193
  setSubtitlesOn(!isSubtitlesOn);
158
194
  }, [onSubtitlesButtonClicked]);
159
- // Intersection Observer
160
- const onIntersected = useCallback(({ ioEntry }) => {
161
- if (ioEntry === undefined)
162
- return;
163
- const { isIntersecting } = ioEntry;
164
- onVisibilityChanged?.(isIntersecting);
165
- if (isIntersecting) {
166
- if (shouldRunAutoBehaviour(autoPlayWhenVisible, autoPlayOnceVisible, hasAutoPlayedOnce.current)) {
167
- hasAutoPlayedOnce.current = true;
168
- setPlay(true);
169
- }
170
- if (shouldRunAutoBehaviour(autoLoudWhenVisible, autoLoudOnceVisible, hasAutoLoudedOnce.current)) {
171
- hasAutoLoudedOnce.current = true;
172
- setMute(false);
173
- }
174
- return;
175
- }
176
- if (shouldRunAutoBehaviour(autoPauseWhenHidden, autoPauseOnceHidden, hasAutoPausedOnce.current)) {
177
- hasAutoPausedOnce.current = true;
178
- setPlay(false);
179
- }
180
- if (shouldRunAutoBehaviour(autoMuteWhenHidden, autoMuteOnceHidden, hasAutoMutedOnce.current)) {
181
- hasAutoMutedOnce.current = true;
182
- setMute(true);
183
- }
184
- }, [
185
- autoPlayWhenVisible,
186
- autoPlayOnceVisible,
187
- autoPauseWhenHidden,
188
- autoPauseOnceHidden,
189
- autoMuteWhenHidden,
190
- autoMuteOnceHidden,
191
- autoLoudWhenVisible,
192
- autoLoudOnceVisible,
193
- onVisibilityChanged
194
- ]);
195
195
  // `autoPlay` is forwarded to the element, but the play state is owned here, so
196
196
  // it has to be seeded once on mount for the controls to agree with the element.
197
197
  useEffect(() => {
198
198
  if (controlledProps.autoPlay === true)
199
199
  setPlay(true);
200
200
  }, []);
201
- // The observer watches the `<figure>` itself. `needsObserve` says whether anything
202
- // asked for it — a hook cannot be skipped, so the condition is passed in rather than
203
- // wrapped around the call, and no `IntersectionObserver` is created without a reason
204
- // to create one.
205
- useIntersectionObserver(rootRef, { threshold, root, rootMargin }, onIntersected, needsObserve);
206
201
  // Render
207
202
  //
208
203
  // **The `<figure>` is the root, and there is nothing above it.** The observer runs on
@@ -210,5 +205,5 @@ export const Video = ({ defaultSubtitlesOn = true, autoPlayWhenVisible, autoPlay
210
205
  // position — the one a consumer lays out — and leave `className` naming an inner
211
206
  // element, which is the wrong way round. So no wrapper, and no second class-name prop
212
207
  // to reach past it.
213
- return _jsx(ControlledVideo, { ...controlledProps, rootRef: rootRef, play: play && !isTimeControlled, volume: volume, mute: mute, playbackRate: playbackRate, fullscreen: fullscreen, subtitlesOn: subtitlesOn, onPlay: handleOnPlayEvent, onPause: handleOnPauseEvent, onIsPlayingChanged: handleIsPlayingChanged, onVolumeChange: handleOnVolumeChangeEvent, onRateChange: handleOnRateChangeEvent, onLoadedMetadata: handleOnLoadedMetadataEvent, onFullscreenChange: handleFullscreenChange, onPlayButtonClicked: handlePlayButtonClick, onPauseButtonClicked: handlePauseButtonClick, onLoudButtonClicked: handleLoudButtonClick, onMuteButtonClicked: handleMuteButtonClick, onVolumeRangeChanged: handleVolumeRangeChange, onRateRangeChanged: handleRateRangeChange, onFullscreenButtonClicked: handleFullscreenButtonClick, onSubtitlesButtonClicked: handleSubtitlesButtonClick });
208
+ return _jsx(ControlledVideo, { ...controlledProps, rootRef: rootRef, videoRef: videoRef, play: play && !isTimeControlled, volume: volume, mute: mute, playbackRate: playbackRate, fullscreen: fullscreen, subtitlesOn: subtitlesOn, onPlay: handleOnPlayEvent, onPause: handleOnPauseEvent, onIsPlayingChanged: handleIsPlayingChanged, onVolumeChange: handleOnVolumeChangeEvent, onRateChange: handleOnRateChangeEvent, onLoadedMetadata: handleOnLoadedMetadataEvent, onFullscreenChange: handleFullscreenChange, onVideoClicked: handleVideoClick, onPlayButtonClicked: handlePlayButtonClick, onPauseButtonClicked: handlePauseButtonClick, onLoudButtonClicked: handleLoudButtonClick, onMuteButtonClicked: handleMuteButtonClick, onVolumeRangeChanged: handleVolumeRangeChange, onRateRangeChanged: handleRateRangeChange, onFullscreenButtonClicked: handleFullscreenButtonClick, onSubtitlesButtonClicked: handleSubtitlesButtonClick });
214
209
  };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The verbs a video answers to, as the table keys them.
3
+ *
4
+ * `jump-to` is the only one taking an argument, and the table is keyed by the verb
5
+ * alone — the argument is parsed out of the instruction before the lookup.
6
+ */
7
+ export type VideoVerb = 'play' | 'pause' | 'loud' | 'mute' | 'jump-to' | 'jump-start' | 'jump-end';
8
+ /**
9
+ * The instructions a consumer writes in `whenVisible` / `whenHidden`.
10
+ *
11
+ * `'jump-to:500'` seeks to 500 ms. **A negative value counts from the end**:
12
+ * `'jump-to:-1'` is the last reachable timecode, and it is `-1` rather than `-0`
13
+ * because `-0 === 0` in JavaScript and would collide with the start. `'jump-start'`
14
+ * and `'jump-end'` are the shorthands for `'jump-to:0'` and `'jump-to:-1'`.
15
+ *
16
+ * Every one of them takes the `':once'` and `':force'` suffixes, which are the generic
17
+ * layer's and are not restated here.
18
+ */
19
+ export type VideoAction = Exclude<VideoVerb, 'jump-to'> | `jump-to:${number}`;
20
+ /**
21
+ * What a verb competes with, so that taking one control over doesn't silence the rest.
22
+ *
23
+ * Seeking belongs to `'playback'`: a reader who drags the timeline has said where they
24
+ * want to be, and an automatic jump would take it back from them.
25
+ */
26
+ export type VideoDomain = 'playback' | 'sound';
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,26 @@
1
+ /**
2
+ * The two list shapes a `<video>` accepts as children, as records.
3
+ *
4
+ * They live here and not next to the component because nothing about them is React:
5
+ * they describe a `<source>` and a `<track>` element.
6
+ *
7
+ * **A video source is not a picture source**, which is why `Image` keeps a shape of its
8
+ * own rather than sharing this one: a `<source>` inside a `<video>` carries `src`, one
9
+ * inside a `<picture>` carries `srcSet`, `media` and `sizes`, and neither element accepts
10
+ * the other's attributes. What the two do share is how a list of them is read — see
11
+ * `parseSourceList` in `components/utils`.
12
+ */
13
+ export type SourceData = {
14
+ src?: string;
15
+ type?: string;
16
+ };
17
+ export type TrackData = {
18
+ src?: string;
19
+ kind?: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata';
20
+ srclang?: string;
21
+ label?: string;
22
+ default?: boolean;
23
+ };
1
24
  export declare const muteAttributeWorkaround: (video: HTMLVideoElement | null, shouldMute: boolean) => void;
2
25
  export declare const forceMute: (video: HTMLVideoElement | null) => void;
3
26
  export declare const forceLoud: (video: HTMLVideoElement | null) => void;
@@ -40,4 +63,18 @@ export declare const getTimelineClickProgress: (event: React.MouseEvent<HTMLDivE
40
63
  * automatic mute the component still owed.
41
64
  * @returns Whether to apply it now.
42
65
  */
43
- export declare const shouldRunAutoBehaviour: (whenCrossed: boolean | undefined, onceOnly: boolean | undefined, hasFired: boolean) => boolean;
66
+ /**
67
+ * Seeks the element, in milliseconds, counting from the end when asked negatively.
68
+ *
69
+ * **A negative target counts back from the duration**, so `-1` is the last reachable
70
+ * timecode. It is `-1` and not `-0` because `-0 === 0` in JavaScript, which would make
71
+ * the end of a video indistinguishable from its start.
72
+ *
73
+ * Nothing happens before the metadata lands: the duration is `NaN` until then, and a
74
+ * seek computed against it would land anywhere. An instruction asking for the end of a
75
+ * video whose length is still unknown is simply not yet answerable.
76
+ *
77
+ * @param video - The element, or `null` before it mounts.
78
+ * @param targetMs - Where to go. Negative counts from the end.
79
+ */
80
+ export declare const forceJumpTo: (video: HTMLVideoElement | null, targetMs: number) => void;
@@ -131,8 +131,27 @@ export const getTimelineClickProgress = (event) => {
131
131
  * automatic mute the component still owed.
132
132
  * @returns Whether to apply it now.
133
133
  */
134
- export const shouldRunAutoBehaviour = (whenCrossed, onceOnly, hasFired) => {
135
- if (whenCrossed === true)
136
- return true;
137
- return onceOnly === true && !hasFired;
134
+ /**
135
+ * Seeks the element, in milliseconds, counting from the end when asked negatively.
136
+ *
137
+ * **A negative target counts back from the duration**, so `-1` is the last reachable
138
+ * timecode. It is `-1` and not `-0` because `-0 === 0` in JavaScript, which would make
139
+ * the end of a video indistinguishable from its start.
140
+ *
141
+ * Nothing happens before the metadata lands: the duration is `NaN` until then, and a
142
+ * seek computed against it would land anywhere. An instruction asking for the end of a
143
+ * video whose length is still unknown is simply not yet answerable.
144
+ *
145
+ * @param video - The element, or `null` before it mounts.
146
+ * @param targetMs - Where to go. Negative counts from the end.
147
+ */
148
+ export const forceJumpTo = (video, targetMs) => {
149
+ if (video === null)
150
+ return;
151
+ const durationMs = secondsToMs(video.duration);
152
+ if (!Number.isFinite(durationMs))
153
+ return;
154
+ const absolute = targetMs < 0 ? durationMs + targetMs : targetMs;
155
+ // eslint-disable-next-line no-param-reassign
156
+ video.currentTime = msToSeconds(Math.min(Math.max(absolute, 0), durationMs));
138
157
  };
@@ -1,11 +1,11 @@
1
1
  export * as beforeAfter from './BeforeAfter/index.js'
2
2
  export * as button from './Button/index.js'
3
- export * as clippable from './Clippable/index.js'
4
3
  export * as disclaimer from './Disclaimer/index.js'
5
- export * as drawer from './Drawer/index.js'
6
- export * as gallery from './Gallery/index.js'
4
+ export * as clippable from './Clippable/index.js'
7
5
  export * as eventListener from './EventListener/index.js'
6
+ export * as drawer from './Drawer/index.js'
8
7
  export * as iframe from './Iframe/index.js'
8
+ export * as gallery from './Gallery/index.js'
9
9
  export * as image from './Image/index.js'
10
10
  export * as input from './Input/index.js'
11
11
  export * as intersectionObserver from './IntersectionObserver/index.js'
@@ -13,15 +13,15 @@ export * as jsonEditor from './JsonEditor/index.js'
13
13
  export * as lightbox from './Lightbox/index.js'
14
14
  export * as listLoader from './ListLoader/index.js'
15
15
  export * as overlayer from './Overlayer/index.js'
16
- export * as paginator from './Paginator/index.js'
17
16
  export * as resizeObserver from './ResizeObserver/index.js'
17
+ export * as paginator from './Paginator/index.js'
18
18
  export * as scrllgngn from './Scrllgngn/index.js'
19
19
  export * as scrollListener from './ScrollListener/index.js'
20
20
  export * as select from './Select/index.js'
21
21
  export * as sequencer from './Sequencer/index.js'
22
22
  export * as shadowRoot from './ShadowRoot/index.js'
23
- export * as subtitles from './Subtitles/index.js'
24
23
  export * as textarea from './Textarea/index.js'
24
+ export * as subtitles from './Subtitles/index.js'
25
25
  export * as uiModule from './UIModule/index.js'
26
26
  export * as video from './Video/index.js'
27
27
  export * as wipAudioQuote from './_WIP_AudioQuote/index.js'