@design-edito/tools 0.5.6 → 0.5.7

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 (79) hide show
  1. package/agnostic/colors/index.d.ts +2 -2
  2. package/agnostic/colors/index.js +2 -2
  3. package/agnostic/css/index.d.ts +1 -1
  4. package/agnostic/css/index.js +1 -1
  5. package/agnostic/errors/index.d.ts +1 -1
  6. package/agnostic/errors/index.js +1 -1
  7. package/agnostic/html/hyper-json/smart-tags/coalesced/index.d.ts +8 -8
  8. package/agnostic/html/hyper-json/smart-tags/coalesced/index.js +8 -8
  9. package/agnostic/html/hyper-json/smart-tags/isolated/index.d.ts +4 -4
  10. package/agnostic/html/hyper-json/smart-tags/isolated/index.js +4 -4
  11. package/agnostic/html/index.d.ts +1 -1
  12. package/agnostic/html/index.js +1 -1
  13. package/agnostic/index.d.ts +1 -0
  14. package/agnostic/index.js +1 -0
  15. package/agnostic/misc/index.d.ts +2 -2
  16. package/agnostic/misc/index.js +2 -2
  17. package/agnostic/numbers/index.d.ts +1 -1
  18. package/agnostic/numbers/index.js +1 -1
  19. package/agnostic/objects/index.d.ts +3 -3
  20. package/agnostic/objects/index.js +3 -3
  21. package/agnostic/random/index.d.ts +1 -1
  22. package/agnostic/random/index.js +1 -1
  23. package/agnostic/strings/index.d.ts +1 -1
  24. package/agnostic/strings/index.js +1 -1
  25. package/agnostic/subtitles/index.d.ts +2 -0
  26. package/agnostic/subtitles/index.js +2 -0
  27. package/agnostic/subtitles/parse-srt/index.d.ts +17 -0
  28. package/agnostic/subtitles/parse-srt/index.js +67 -0
  29. package/agnostic/subtitles/parse-srt/index.test.d.ts +1 -0
  30. package/agnostic/subtitles/parse-srt/index.test.js +94 -0
  31. package/agnostic/subtitles/parse-srt/types.d.ts +22 -0
  32. package/agnostic/subtitles/parse-srt/types.js +1 -0
  33. package/agnostic/subtitles/srt-timecode-to-ms/index.d.ts +14 -0
  34. package/agnostic/subtitles/srt-timecode-to-ms/index.js +36 -0
  35. package/agnostic/subtitles/srt-timecode-to-ms/index.test.d.ts +1 -0
  36. package/agnostic/subtitles/srt-timecode-to-ms/index.test.js +44 -0
  37. package/agnostic/time/dates/index.d.ts +1 -1
  38. package/agnostic/time/dates/index.js +1 -1
  39. package/components/ListLoader/index.controlled.d.ts +8 -3
  40. package/components/ListLoader/index.controlled.js +2 -2
  41. package/components/ListLoader/index.d.ts +1 -1
  42. package/components/ListLoader/index.js +2 -2
  43. package/components/Scrllgngn/index.d.ts +37 -7
  44. package/components/Scrllgngn/index.js +81 -24
  45. package/components/Scrllgngn/styles.module.css +30 -5
  46. package/components/Scrllgngn/utils.d.ts +87 -4
  47. package/components/Scrllgngn/utils.js +142 -8
  48. package/components/ScrollListener/index.d.ts +7 -3
  49. package/components/ScrollListener/index.js +2 -2
  50. package/components/Sequencer/index.d.ts +10 -1
  51. package/components/Sequencer/index.js +7 -3
  52. package/components/Subtitles/index.d.ts +53 -62
  53. package/components/Subtitles/index.js +90 -158
  54. package/components/Subtitles/utils.d.ts +60 -0
  55. package/components/Subtitles/utils.js +76 -0
  56. package/components/Video/index.controlled.d.ts +5 -0
  57. package/components/Video/index.controlled.js +22 -3
  58. package/components/Video/index.d.ts +47 -9
  59. package/components/Video/index.js +71 -20
  60. package/components/Video/utils.d.ts +14 -0
  61. package/components/Video/utils.js +18 -0
  62. package/components/utils/types.d.ts +32 -0
  63. package/index.d.ts +1 -1
  64. package/index.js +1 -1
  65. package/node/@aws-s3/storage/directory/index.d.ts +1 -1
  66. package/node/@aws-s3/storage/directory/index.js +1 -1
  67. package/node/@google-cloud/storage/file/index.d.ts +2 -2
  68. package/node/@google-cloud/storage/file/index.js +2 -2
  69. package/node/cloud-storage/operations/index.d.ts +1 -1
  70. package/node/cloud-storage/operations/index.js +1 -1
  71. package/node/files/index.d.ts +1 -1
  72. package/node/files/index.js +1 -1
  73. package/node/images/index.d.ts +1 -1
  74. package/node/images/index.js +1 -1
  75. package/node/index.d.ts +1 -1
  76. package/node/index.js +1 -1
  77. package/node/sftp/file/index.d.ts +1 -1
  78. package/node/sftp/file/index.js +1 -1
  79. package/package.json +26 -1
@@ -1,188 +1,120 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { clss } from '../../agnostic/css/clss/index.js';
4
4
  import { toError } from '../../agnostic/misc/cast/index.js';
5
- import { unknownToString } from '../../agnostic/errors/unknown-to-string/index.js';
5
+ import { parseSrt } from '../../agnostic/subtitles/parse-srt/index.js';
6
6
  import { mergeClassNames, useChangeDispatch } from '../utils/index.js';
7
7
  import { subtitles as publicClassName } from '../public-classnames.js';
8
+ import { lastElapsedPos as toLastElapsedPos, normalizeGroupBoundaries, toCurrentGroup, toSubGroups } from './utils.js';
8
9
  import cssModule from './styles.module.css';
9
- /** Converts an SRT timecode (hh:mm:ss,ms) to milliseconds. */
10
- const getTimecodeToMs = (timecode) => {
11
- const [hours = '0', minutes = '0', secondsAndMs = '0,0'] = timecode.split(':');
12
- const [seconds = '0', milliseconds = '0'] = secondsAndMs.split(',');
13
- let result = parseInt(hours) * 60 * 60 * 1000;
14
- result += parseInt(minutes) * 60 * 1000;
15
- result += parseInt(seconds) * 1000;
16
- result += parseInt(milliseconds);
17
- return result;
18
- };
19
- /** Parses a raw SRT subtitle text into a list of {@link ParsedSub} entries. */
20
- const parseSubs = (rawSubs) => {
21
- const numberRegex = /^\d+$/v;
22
- const timecodeRegex = /^[0-9]+:[0-9]+:[0-9]+,[0-9]+\s*-->\s*[0-9]+:[0-9]+:[0-9]+,[0-9]+$/v;
23
- const parsedSubs = [];
24
- rawSubs.split('\n').forEach(line => {
25
- if (line.trim() === '')
26
- return;
27
- const lastParsedSub = parsedSubs[parsedSubs.length - 1];
28
- const matchId = line.match(numberRegex);
29
- const matchTimecode = line.match(timecodeRegex);
30
- // id
31
- if (matchId !== null) {
32
- if (lastParsedSub === undefined
33
- || lastParsedSub.content !== undefined) {
34
- const parsedSub = { id: parseInt(line) };
35
- parsedSubs.push(parsedSub);
36
- return;
37
- }
38
- }
39
- // timecode
40
- if (matchTimecode !== null) {
41
- if (lastParsedSub?.id !== undefined) {
42
- const [rawStart = '', rawEnd = ''] = line.split('-->');
43
- const startTime = rawStart.trim();
44
- const endTime = rawEnd.trim();
45
- lastParsedSub.start = getTimecodeToMs(startTime);
46
- lastParsedSub.end = getTimecodeToMs(endTime);
47
- return;
48
- }
49
- }
50
- // content
51
- if (lastParsedSub?.id !== undefined
52
- && lastParsedSub.start !== undefined
53
- && lastParsedSub.end !== undefined) {
54
- if (lastParsedSub.content !== undefined) {
55
- lastParsedSub.content += `\n${line}`;
56
- return;
57
- }
58
- lastParsedSub.content = line;
59
- }
60
- });
61
- return parsedSubs;
62
- };
63
- /** Computes subtitle groups with their inclusive `startId` / `endId` boundaries. */
64
- const computeSubGroupsWithBoundaries = (subsGroups, highestSubId) => {
65
- const fallback = [{ startId: 1, endId: highestSubId }];
66
- if (subsGroups === undefined || subsGroups.length === 0)
67
- return fallback;
68
- const emptySubGroupBoundaries = [];
69
- return subsGroups.reduce((acc, curr, currIndex) => {
70
- const lastInAcc = acc[acc.length - 1];
71
- const startId = lastInAcc === undefined ? 1 : lastInAcc.endId + 1;
72
- const endId = curr;
73
- if (currIndex === subsGroups.length - 1
74
- && endId !== highestSubId) {
75
- return [
76
- ...acc,
77
- { startId, endId },
78
- { startId: endId + 1, endId: highestSubId }
79
- ];
80
- }
81
- return [...acc, { startId, endId }];
82
- }, emptySubGroupBoundaries);
83
- };
84
- /** Returns the group holding the last elapsed subtitle, or the last group once playback ended. */
85
- const getCurrentGroup = (subsGroupsWithBoundaries, lastPrevSubId, isEnded) => {
86
- const previousGroups = subsGroupsWithBoundaries.filter(group => group.startId <= (lastPrevSubId ?? 0));
87
- if (previousGroups.length === 0)
88
- return isEnded === true
89
- ? subsGroupsWithBoundaries[subsGroupsWithBoundaries.length - 1]
90
- : subsGroupsWithBoundaries[0];
91
- return previousGroups[previousGroups.length - 1];
92
- };
93
10
  /**
94
- * Subtitle synchronization component. Fetches or receives an SRT source, parses it,
95
- * and renders subtitle groups whose individual spans are styled according to the
96
- * current media timecode.
11
+ * Subtitle synchronization component. Takes an SRT source — parsed cues, a raw
12
+ * string, or a URL to fetch and renders its cues in groups, marking where the
13
+ * media currently is.
97
14
  *
98
- * ### Group elements
99
- * Each subtitle group is a `<div>` with the following:
100
- * - `--curr` modifier when the group contains the subtitle at the current timecode.
101
- * - `data-start-sub-pos` — ID of the first subtitle in the group.
102
- * - `data-end-sub-pos` — ID of the last subtitle in the group.
15
+ * ### CSS elements
16
+ * - `lm-subtitles` the root. Carries `--loading` while a fetch is in flight, and
17
+ * `--error` once one failed. Both are cleared when a new source starts loading.
18
+ * - `lm-subtitles__group` — one per group. Carries `--curr` when the reader is in it.
19
+ * - `lm-subtitles__sub` — one per cue. Carries `--prev` once the playhead has passed
20
+ * its start, and `--curr` while the playhead is inside its interval.
103
21
  *
104
- * ### Subtitle span elements
105
- * Each individual subtitle is a `<span>` with the following:
106
- * - `--prev` modifier when the subtitle's start time is at or before the last elapsed subtitle.
107
- * - `--curr` modifier when `timecodeMs` falls within the subtitle's `[start, end]` interval.
108
- * - `data-sub-pos` — the subtitle's numeric ID from the SRT source.
22
+ * ### Data attributes
23
+ * On a group: `data-start-sub-pos` and `data-end-sub-pos`, its bounds as positions
24
+ * in the cue array. On a cue: `data-sub-pos`, its position, and `data-sub-id`, the
25
+ * number written in the file.
109
26
  *
110
27
  * @param props - Component properties.
111
28
  * @see {@link Props}
112
- * @returns A root `<div>` containing the rendered subtitle groups, or an empty `<div>`
113
- * when `timecodeMs` is undefined or no subtitles have been parsed yet.
29
+ * @returns A root `<div>` holding the subtitle groups, empty while there is no
30
+ * timecode or no cue.
31
+ *
32
+ * @remarks
33
+ * Uncontrolled only. The cues are state, fed by whichever source is set: there is no
34
+ * controlled variant, because a consumer holding its own cues passes them as `cues`
35
+ * and owns them outright.
36
+ *
37
+ * Cues are rendered back to back with no separator — the space between two of them
38
+ * is presentation, and belongs in a stylesheet (`lm-subtitles__sub + …::before`, a
39
+ * `word-spacing`, whatever suits) rather than in the text content, where it would
40
+ * come back out of `textContent`.
114
41
  */
115
- export const Subtitles = ({ src, srtFileContent, subsGroups, timecodeMs, isEnded, className, onLoaded, onParsed, onLoadFailed }) => {
42
+ export const Subtitles = ({ cues, srtFileContent, src, subsGroups, timecodeMs, isEnded, className, onLoaded, onParsed, onLoadFailed }) => {
116
43
  // State
117
44
  const [isLoading, setIsLoading] = useState(false);
118
45
  const [loadError, setLoadError] = useState(null);
119
46
  const [parsedSubs, setParsedSubs] = useState([]);
47
+ // The load handlers are read through refs so the effect below can depend on the
48
+ // source alone. Depending on their identity re-fetches on every parent render —
49
+ // and `Video` re-renders several times a second as the timecode moves.
50
+ const onLoadedRef = useRef(onLoaded);
51
+ const onLoadFailedRef = useRef(onLoadFailed);
52
+ useEffect(() => {
53
+ onLoadedRef.current = onLoaded;
54
+ onLoadFailedRef.current = onLoadFailed;
55
+ });
120
56
  // State dispatch
121
57
  useChangeDispatch(parsedSubs, onParsed);
122
- // Effects
123
- const fetchAndParseSubs = useCallback(async (src, srtFileContent) => {
58
+ // Fx. dep. cues, srtFileContent, src - Load from whichever source is set
59
+ useEffect(() => {
60
+ setIsLoading(false);
61
+ setLoadError(null);
62
+ if (cues !== undefined)
63
+ return setParsedSubs(cues);
124
64
  if (srtFileContent !== undefined)
125
- return setParsedSubs(parseSubs(srtFileContent));
65
+ return setParsedSubs(parseSrt(srtFileContent));
126
66
  if (src === undefined)
127
- return;
67
+ return setParsedSubs([]);
68
+ let isCurrent = true;
128
69
  setIsLoading(true);
129
- setLoadError(null);
130
- try {
131
- const response = await fetch(src);
132
- const srtContent = await response.text();
133
- onLoaded?.(srtContent);
134
- const parsedSubs = parseSubs(srtContent);
135
- setParsedSubs(parsedSubs);
136
- }
137
- catch (error) {
138
- setLoadError(error instanceof Error
139
- ? error
140
- : new Error(unknownToString(error)));
141
- // eslint-disable-next-line no-console
142
- console.error(error);
143
- onLoadFailed?.(toError(error));
144
- }
145
- finally {
146
- setIsLoading(false);
147
- }
148
- }, [onLoadFailed, onLoaded]);
149
- useEffect(() => {
150
- fetchAndParseSubs(src, srtFileContent)
151
- // eslint-disable-next-line no-console
152
- .catch((error) => { console.error(error); });
153
- }, [fetchAndParseSubs, src, srtFileContent]);
70
+ const load = async () => {
71
+ try {
72
+ const response = await fetch(src);
73
+ // A 404 serves an HTML page: `text()` resolves, the parse yields nothing, and
74
+ // without this the component would report a success it never had.
75
+ if (!response.ok)
76
+ throw new Error(`Could not load ${src}: ${response.status} ${response.statusText}`);
77
+ const srtContent = await response.text();
78
+ if (!isCurrent)
79
+ return;
80
+ onLoadedRef.current?.(srtContent);
81
+ setParsedSubs(parseSrt(srtContent));
82
+ }
83
+ catch (error) {
84
+ if (!isCurrent)
85
+ return;
86
+ const asError = toError(error);
87
+ setLoadError(asError);
88
+ setParsedSubs([]);
89
+ onLoadFailedRef.current?.(asError);
90
+ }
91
+ finally {
92
+ if (isCurrent)
93
+ setIsLoading(false);
94
+ }
95
+ };
96
+ void load();
97
+ return () => { isCurrent = false; };
98
+ }, [cues, srtFileContent, src]);
154
99
  // Rendering
155
100
  const c = clss(publicClassName, { cssModule });
156
101
  const rootClss = mergeClassNames(c(null, {
157
102
  loading: isLoading,
158
103
  error: loadError !== null
159
104
  }), className);
160
- const prevSubs = parsedSubs.filter(({ start }) => start !== undefined && start < (timecodeMs ?? 0));
161
- const lastPrevSub = prevSubs[prevSubs.length - 1];
162
- const highestSubId = Math.max(...parsedSubs.map(sub => sub.id));
163
- const subsGroupsWithBoundaries = computeSubGroupsWithBoundaries(subsGroups, highestSubId);
164
- const currentGroup = getCurrentGroup(subsGroupsWithBoundaries, lastPrevSub?.id, isEnded);
165
- return _jsx("div", { className: rootClss, children: timecodeMs !== undefined
166
- && parsedSubs.length > 0
167
- && subsGroupsWithBoundaries.map(group => {
168
- const groupSubs = parsedSubs.filter(sub => sub.id >= group.startId && sub.id <= group.endId);
169
- const totalSubs = groupSubs.length;
170
- const groupClass = c('group', { curr: currentGroup?.startId === group.startId });
171
- const subsNodes = groupSubs.map((sub, subIndex) => {
172
- let subText = sub.content?.trim() ?? '';
173
- if (subIndex !== totalSubs - 1)
174
- subText += ' ';
175
- const subClass = c('sub', {
176
- prev: sub.start !== undefined
177
- && lastPrevSub?.start !== undefined
178
- && sub.start <= lastPrevSub.start,
179
- curr: sub.start !== undefined
180
- && timecodeMs >= sub.start
181
- && sub.end !== undefined
182
- && timecodeMs <= sub.end
105
+ const lastElapsedPos = toLastElapsedPos(parsedSubs, timecodeMs);
106
+ const boundaries = normalizeGroupBoundaries(subsGroups, parsedSubs.length - 1);
107
+ const groups = toSubGroups(boundaries, parsedSubs.length - 1);
108
+ const currentGroup = toCurrentGroup(groups, lastElapsedPos, isEnded);
109
+ return _jsx("div", { className: rootClss, children: timecodeMs !== undefined && groups.map(group => {
110
+ const groupClss = c('group', { curr: currentGroup?.startPos === group.startPos });
111
+ return _jsx("div", { className: groupClss, "data-start-sub-pos": group.startPos, "data-end-sub-pos": group.endPos, children: parsedSubs.slice(group.startPos, group.endPos + 1).map((sub, indexInGroup) => {
112
+ const subPos = group.startPos + indexInGroup;
113
+ const subClss = c('sub', {
114
+ prev: subPos <= lastElapsedPos,
115
+ curr: timecodeMs >= sub.start && timecodeMs <= sub.end
183
116
  });
184
- return _jsx("span", { className: subClass, "data-sub-pos": sub.id, children: subText }, sub.id);
185
- });
186
- return _jsx("div", { className: groupClass, "data-start-sub-pos": group.startId, "data-end-sub-pos": group.endId, children: subsNodes }, group.startId);
187
- }) });
117
+ return _jsx("span", { className: subClss, "data-sub-pos": subPos, "data-sub-id": sub.id, children: sub.content }, subPos);
118
+ }) }, group.startPos);
119
+ }) });
188
120
  };
@@ -0,0 +1,60 @@
1
+ import type { SrtCue } from '../../agnostic/subtitles/parse-srt/types.js';
2
+ /**
3
+ * The inclusive bounds of a subtitle group, as **positions** in the cue array.
4
+ *
5
+ * Positions rather than file ids: an SRT concatenated from several files, or edited
6
+ * by hand, numbers its cues however it likes, and nothing guarantees a run starting
7
+ * at 1. A consumer shouldn't have to know a file's numbering to split it.
8
+ *
9
+ * @property startPos - Position of the group's first cue.
10
+ * @property endPos - Position of its last cue.
11
+ */
12
+ export type SubGroupBounds = {
13
+ startPos: number;
14
+ endPos: number;
15
+ };
16
+ /**
17
+ * Puts a consumer's group boundaries in a state the group builder can use.
18
+ *
19
+ * Out-of-range, duplicate and unordered boundaries all produce overlapping or
20
+ * inverted groups downstream — the same cue rendered twice, colliding keys. Rather
21
+ * than demand a sorted array and break quietly when it isn't, normalise here.
22
+ *
23
+ * @param boundaries - The positions a consumer asked to split on, in any order.
24
+ * @param lastPos - Position of the last cue.
25
+ * @returns The usable boundaries, ascending, without duplicates, and without the
26
+ * last position — which ends the final group anyway.
27
+ */
28
+ export declare function normalizeGroupBoundaries(boundaries: number[] | undefined, lastPos: number): number[];
29
+ /**
30
+ * Cuts the cue array into groups.
31
+ *
32
+ * @param boundaries - Normalised boundaries, each the position of a group's last cue.
33
+ * See {@link normalizeGroupBoundaries}.
34
+ * @param lastPos - Position of the last cue.
35
+ * @returns One group per section, in order. A single group spanning everything when
36
+ * no boundary is given, and nothing at all when there is no cue to group.
37
+ */
38
+ export declare function toSubGroups(boundaries: number[], lastPos: number): SubGroupBounds[];
39
+ /**
40
+ * Finds the last cue the playhead has already reached.
41
+ *
42
+ * @param cues - The parsed cues, in order.
43
+ * @param timecodeMs - Current media position, absent while nothing plays.
44
+ * @returns Its position, or `-1` when the playhead sits before the first cue. One
45
+ * number, computed once: `--prev` on a cue and the current group both read from it,
46
+ * and deriving them separately lets them disagree on a file with duplicate or
47
+ * out-of-order start times.
48
+ */
49
+ export declare function lastElapsedPos(cues: SrtCue[], timecodeMs?: number): number;
50
+ /**
51
+ * Picks the group the reader is in.
52
+ *
53
+ * @param groups - The groups, in order.
54
+ * @param lastElapsedPos - Position of the last elapsed cue, `-1` before the first.
55
+ * @param isEnded - Whether playback is over.
56
+ * @returns The group holding that cue. Once playback has ended, the last group
57
+ * whatever the timecode says — which is what `isEnded` is for. The first group
58
+ * before anything has elapsed.
59
+ */
60
+ export declare function toCurrentGroup(groups: SubGroupBounds[], lastElapsedPos: number, isEnded?: boolean): SubGroupBounds | undefined;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Puts a consumer's group boundaries in a state the group builder can use.
3
+ *
4
+ * Out-of-range, duplicate and unordered boundaries all produce overlapping or
5
+ * inverted groups downstream — the same cue rendered twice, colliding keys. Rather
6
+ * than demand a sorted array and break quietly when it isn't, normalise here.
7
+ *
8
+ * @param boundaries - The positions a consumer asked to split on, in any order.
9
+ * @param lastPos - Position of the last cue.
10
+ * @returns The usable boundaries, ascending, without duplicates, and without the
11
+ * last position — which ends the final group anyway.
12
+ */
13
+ export function normalizeGroupBoundaries(boundaries, lastPos) {
14
+ if (boundaries === undefined)
15
+ return [];
16
+ const usable = boundaries.filter(pos => Number.isInteger(pos) && pos >= 0 && pos < lastPos);
17
+ return [...new Set(usable)].sort((a, b) => a - b);
18
+ }
19
+ /**
20
+ * Cuts the cue array into groups.
21
+ *
22
+ * @param boundaries - Normalised boundaries, each the position of a group's last cue.
23
+ * See {@link normalizeGroupBoundaries}.
24
+ * @param lastPos - Position of the last cue.
25
+ * @returns One group per section, in order. A single group spanning everything when
26
+ * no boundary is given, and nothing at all when there is no cue to group.
27
+ */
28
+ export function toSubGroups(boundaries, lastPos) {
29
+ if (lastPos < 0)
30
+ return [];
31
+ const groups = [];
32
+ let startPos = 0;
33
+ for (const endPos of boundaries) {
34
+ groups.push({ startPos, endPos });
35
+ startPos = endPos + 1;
36
+ }
37
+ groups.push({ startPos, endPos: lastPos });
38
+ return groups;
39
+ }
40
+ /**
41
+ * Finds the last cue the playhead has already reached.
42
+ *
43
+ * @param cues - The parsed cues, in order.
44
+ * @param timecodeMs - Current media position, absent while nothing plays.
45
+ * @returns Its position, or `-1` when the playhead sits before the first cue. One
46
+ * number, computed once: `--prev` on a cue and the current group both read from it,
47
+ * and deriving them separately lets them disagree on a file with duplicate or
48
+ * out-of-order start times.
49
+ */
50
+ export function lastElapsedPos(cues, timecodeMs) {
51
+ if (timecodeMs === undefined)
52
+ return -1;
53
+ let found = -1;
54
+ cues.forEach((cue, pos) => {
55
+ if (cue.start < timecodeMs)
56
+ found = pos;
57
+ });
58
+ return found;
59
+ }
60
+ /**
61
+ * Picks the group the reader is in.
62
+ *
63
+ * @param groups - The groups, in order.
64
+ * @param lastElapsedPos - Position of the last elapsed cue, `-1` before the first.
65
+ * @param isEnded - Whether playback is over.
66
+ * @returns The group holding that cue. Once playback has ended, the last group
67
+ * whatever the timecode says — which is what `isEnded` is for. The first group
68
+ * before anything has elapsed.
69
+ */
70
+ export function toCurrentGroup(groups, lastElapsedPos, isEnded) {
71
+ if (isEnded === true)
72
+ return groups[groups.length - 1];
73
+ if (lastElapsedPos < 0)
74
+ return groups[0];
75
+ return groups.find(group => group.startPos <= lastElapsedPos && lastElapsedPos <= group.endPos);
76
+ }
@@ -69,6 +69,8 @@ type TrackData = {
69
69
  * seeks to the target right after, unless the time is controlled.
70
70
  * @property onIsPlayingChanged - Called once the playback state has changed.
71
71
  * @property onIsFullscreenChanged - Called once the fullscreen state has changed.
72
+ * @property onIsEndedChanged - Called after playback reached the end, and again
73
+ * once it left it — a seek back or a new play. Never on mount.
72
74
  * @property onIsLoudChanged - Called once the mute state has changed (`true` = unmuted).
73
75
  * @property onVolumeChanged - Called once the volume has changed (`0` to `1`).
74
76
  * @property onPlaybackRateChanged - Called once the playback rate has changed.
@@ -109,6 +111,7 @@ export type Props = PropsWithChildren<WithClassName<{
109
111
  onIsPlayingChanged?: (isPlaying: boolean) => void;
110
112
  onIsFullscreenChanged?: (isFullscreen: boolean) => void;
111
113
  onIsLoudChanged?: (isLoud: boolean) => void;
114
+ onIsEndedChanged?: (isEnded: boolean) => void;
112
115
  onVolumeChanged?: (volume: number) => void;
113
116
  onPlaybackRateChanged?: (playbackRate: number) => void;
114
117
  onCurrentTimeMsChanged?: (currentTimeMs: number) => void;
@@ -125,10 +128,12 @@ export type Props = PropsWithChildren<WithClassName<{
125
128
  * - `--play-on` / `--play-off` — reflects current playback state.
126
129
  * - `--fullscreen-on` / `--fullscreen-off` — reflects fullscreen state.
127
130
  * - `--loud` / `--muted` — reflects mute state.
131
+ * - `--ended` — playback reached the end and has not left it.
128
132
  *
129
133
  * ### Data attributes on the root element
130
134
  * - `data-play-on` — present (empty string) when playing.
131
135
  * - `data-play-off` — present (empty string) when paused.
136
+ * - `data-ended` — present (empty string) once playback reached the end.
132
137
  * - `data-fullscreen-on` — present (empty string) when in fullscreen.
133
138
  * - `data-fullscreen-off` — present (empty string) when not in fullscreen.
134
139
  * - `data-loud` — present (empty string) when unmuted.
@@ -17,10 +17,12 @@ import { forceExitFullscreen, forceFullscreen, forceLoud, forceMute, forcePause,
17
17
  * - `--play-on` / `--play-off` — reflects current playback state.
18
18
  * - `--fullscreen-on` / `--fullscreen-off` — reflects fullscreen state.
19
19
  * - `--loud` / `--muted` — reflects mute state.
20
+ * - `--ended` — playback reached the end and has not left it.
20
21
  *
21
22
  * ### Data attributes on the root element
22
23
  * - `data-play-on` — present (empty string) when playing.
23
24
  * - `data-play-off` — present (empty string) when paused.
25
+ * - `data-ended` — present (empty string) once playback reached the end.
24
26
  * - `data-fullscreen-on` — present (empty string) when in fullscreen.
25
27
  * - `data-fullscreen-off` — present (empty string) when not in fullscreen.
26
28
  * - `data-loud` — present (empty string) when unmuted.
@@ -41,11 +43,16 @@ import { forceExitFullscreen, forceFullscreen, forceLoud, forceMute, forcePause,
41
43
  * @returns A `<figure>` element containing the video, its controls, optional
42
44
  * subtitles.
43
45
  */
44
- export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pauseBtnContent, loudBtnContent, muteBtnContent, fullscreenBtnContent, play, fullscreen, mute, muted, volume = 1, playbackRate = 1, currentTimeMs: givenCurrentTimeMs, onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, onTimelineClicked, onIsPlayingChanged, onIsFullscreenChanged, onIsLoudChanged, onVolumeChanged, onPlaybackRateChanged, onCurrentTimeMsChanged, onFullscreenChange, children, className, ...intrinsicVideoAttributes }) => {
46
+ export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pauseBtnContent, loudBtnContent, muteBtnContent, fullscreenBtnContent, play, fullscreen, mute, muted, volume = 1, playbackRate = 1, currentTimeMs: givenCurrentTimeMs, onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, onTimelineClicked, onIsPlayingChanged, onIsFullscreenChanged, onIsLoudChanged, onIsEndedChanged, onVolumeChanged, onPlaybackRateChanged, onCurrentTimeMsChanged, onFullscreenChange, children, className, ...intrinsicVideoAttributes }) => {
45
47
  const videoRef = useRef(null);
46
48
  const [totalTime, setTotalTime] = useState(0);
47
49
  const totalTimeMs = useMemo(() => secondsToMs(totalTime), [totalTime]);
48
50
  const [internalCurrentTimeMs, setInternalCurrentTimeMs] = useState(0);
51
+ // Unlike play, mute and fullscreen, this one isn't a prop: nothing can *set* a
52
+ // media to ended, it is something the element reports. So it is read from the
53
+ // element, and the two events that turn it on and off are joined by a sync on
54
+ // every time update, which covers a seek away from the end.
55
+ const [isEnded, setIsEnded] = useState(false);
49
56
  const isTimeControlled = givenCurrentTimeMs !== undefined;
50
57
  // The parent owns the time as soon as it provides one, so that is what gets
51
58
  // displayed — not what the element reported one render later.
@@ -73,9 +80,18 @@ export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pa
73
80
  const video = e.currentTarget;
74
81
  const newTimeMs = secondsToMs(video.currentTime);
75
82
  setInternalCurrentTimeMs(newTimeMs);
83
+ setIsEnded(video.ended);
76
84
  if (intrinsicVideoAttributes.onTimeUpdate !== undefined)
77
85
  intrinsicVideoAttributes.onTimeUpdate(e);
78
86
  }, [intrinsicVideoAttributes.onTimeUpdate]);
87
+ const handleEndedEvent = useCallback((e) => {
88
+ setIsEnded(true);
89
+ intrinsicVideoAttributes.onEnded?.(e);
90
+ }, [intrinsicVideoAttributes.onEnded]);
91
+ const handlePlayEvent = useCallback((e) => {
92
+ setIsEnded(false);
93
+ intrinsicVideoAttributes.onPlay?.(e);
94
+ }, [intrinsicVideoAttributes.onPlay]);
79
95
  // Custom action handlers
80
96
  const handlePlayButtonClick = useCallback((e) => {
81
97
  const wasPlaying = videoRef.current?.paused === false;
@@ -118,6 +134,7 @@ export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pa
118
134
  const rootClss = mergeClassNames(c(null, {
119
135
  'play-on': isPlaying,
120
136
  'play-off': !isPlaying,
137
+ ended: isEnded,
121
138
  'fullscreen-on': isFullscreen,
122
139
  'fullscreen-off': !isFullscreen,
123
140
  'loud': isLoud,
@@ -129,6 +146,7 @@ export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pa
129
146
  const rootAttributes = {
130
147
  'data-play-on': isPlaying ? '' : undefined,
131
148
  'data-play-off': !isPlaying ? '' : undefined,
149
+ 'data-ended': isEnded ? '' : undefined,
132
150
  'data-fullscreen-on': isFullscreen ? '' : undefined,
133
151
  'data-fullscreen-off': !isFullscreen ? '' : undefined,
134
152
  'data-loud': isLoud ? '' : undefined,
@@ -249,11 +267,12 @@ export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pa
249
267
  useChangeDispatch(isPlaying, onIsPlayingChanged);
250
268
  useChangeDispatch(isFullscreen, onIsFullscreenChanged);
251
269
  useChangeDispatch(isLoud, onIsLoudChanged);
270
+ useChangeDispatch(isEnded, onIsEndedChanged);
252
271
  useChangeDispatch(volume, onVolumeChanged);
253
272
  useChangeDispatch(playbackRate, onPlaybackRateChanged);
254
- return _jsxs("figure", { className: rootClss, style: rootStyles, ...rootAttributes, children: [_jsxs("video", { ref: videoRef, className: videoClss, ...intrinsicVideoAttributes, autoPlay: isTimeControlled ? false : intrinsicVideoAttributes.autoPlay, onLoadedMetadata: handleMetadataLoadEvent, onTimeUpdate: handleOnTimeUpdateEvent, children: [parsedSources.map((source, index) => typeof source === 'string'
273
+ return _jsxs("figure", { 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, children: [parsedSources.map((source, index) => typeof source === 'string'
255
274
  ? _jsx("source", { src: source }, index)
256
275
  : _jsx("source", { src: source.src, type: source.type }, index)), parsedTracks.map((track, index) => typeof track === 'string'
257
276
  ? _jsx("track", { src: track }, index)
258
- : _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 }), _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: formatTime(currentTimeMs, 'mm:ss:ms') }), _jsx("span", { className: totalTimeClss, children: formatTime(totalTimeMs, 'mm:ss:ms') }), _jsx("div", { className: timelineClss, onClick: handleTimelineClick })] }), subtitles !== undefined && _jsx(Subtitles, { ...subtitles, timecodeMs: currentTimeMs })] });
277
+ : _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 }), _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: formatTime(currentTimeMs, 'mm:ss:ms') }), _jsx("span", { className: totalTimeClss, children: formatTime(totalTimeMs, 'mm:ss:ms') }), _jsx("div", { className: timelineClss, onClick: handleTimelineClick })] }), subtitles !== undefined && _jsx(Subtitles, { ...subtitles, timecodeMs: currentTimeMs, isEnded: isEnded })] });
259
278
  };
@@ -1,17 +1,29 @@
1
1
  import { type FunctionComponent } from 'react';
2
+ import type { WithViewportObservation } from '../utils/types.js';
2
3
  import { type Props as ControlledProps } from './index.controlled.js';
3
4
  /**
4
5
  * Props for the {@link Video} component.
5
6
  *
6
7
  * Extends all ControlledVideo props except play, mute, fullscreen, volume, playbackRate, and their associated event handlers
7
- * @property autoPlayWhenVisible - When `true`, triggers playback the first time the
8
- * component intersects the viewport.
9
- * @property autoPauseWhenHidden - When `true`, pauses playback whenever the component
10
- * leaves the viewport.
11
- * @property autoLoudWhenVisible - When `true`, unmutes the video the first time the
12
- * component intersects the viewport.
13
- * @property autoMuteWhenHidden - When `true`, mutes the video whenever the component
14
- * leaves the viewport.
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.
15
27
  * @property currentTimeMs - When provided, hands ownership of the current time
16
28
  * (in milliseconds) to the parent, which is then responsible for updating it —
17
29
  * typically to scrub the video from scroll position. A controlled time implies a
@@ -23,11 +35,15 @@ import { type Props as ControlledProps } from './index.controlled.js';
23
35
  * @property children - React children rendered inside the `<video>` element itself
24
36
  * (e.g. fallback content).
25
37
  */
26
- export type Props = Omit<ControlledProps, 'play' | 'fullscreen' | 'volume' | 'mute' | 'playbackRate'> & {
38
+ export type Props = WithViewportObservation<Omit<ControlledProps, 'play' | 'fullscreen' | 'volume' | 'mute' | 'playbackRate'>> & {
27
39
  autoPlayWhenVisible?: boolean;
40
+ autoPlayOnceVisible?: boolean;
28
41
  autoPauseWhenHidden?: boolean;
42
+ autoPauseOnceHidden?: boolean;
29
43
  autoLoudWhenVisible?: boolean;
44
+ autoLoudOnceVisible?: boolean;
30
45
  autoMuteWhenHidden?: boolean;
46
+ autoMuteOnceHidden?: boolean;
31
47
  wrapperClassName?: string;
32
48
  };
33
49
  /**
@@ -39,5 +55,27 @@ export type Props = Omit<ControlledProps, 'play' | 'fullscreen' | 'volume' | 'mu
39
55
  * @see {@link Props}
40
56
  * @returns A `<figure>` element containing the video, its controls and optional
41
57
  * subtitles.
58
+ *
59
+ * @remarks
60
+ * **Audio-only media belongs here too**, carried by the same `<video>` element.
61
+ * Everything this component drives — play, mute, volume, rate, time, `ended` — is
62
+ * `HTMLMediaElement`, shared with `<audio>`; only fullscreen is video's own. And an
63
+ * `<audio>` tag can't be autoplayed: the muted-autoplay exemption browsers grant is
64
+ * for video, so an `<audio>` waits for a user gesture whatever its muted state.
65
+ * `autoPlayWhenVisible` on an audio file therefore needs `muted` and `playsInline`
66
+ * on a `<video>`, which is what this is. The one thing lost is the assistive-tech
67
+ * label: a screen reader announces a video player. Worth a `tag` prop the day that
68
+ * matters more than autoplay — not before.
69
+ *
70
+ * Each viewport-driven behaviour comes in two flavours: `…When…` fires on every
71
+ * crossing, `…Once…` only on the first one. A `…Once…` flag is armed by its own
72
+ * automatic trigger and by nothing else — pressing play does not spend the one
73
+ * automatic play the component still owed. Setting both flavours of the same
74
+ * behaviour is the same as setting the `…When…` one alone.
75
+ *
76
+ * Browsers refuse an unmuted `play()` outside a user gesture, so pairing an
77
+ * `autoLoud…` with an `autoPlay…` will usually have the playback rejected: the
78
+ * element stays paused while the controls believe otherwise. Autoplay muted, and
79
+ * leave unmuting to the reader.
42
80
  */
43
81
  export declare const Video: FunctionComponent<Props>;