@design-edito/tools 0.5.6 → 0.5.8
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/agnostic/arrays/index.d.ts +2 -2
- package/agnostic/arrays/index.js +2 -2
- package/agnostic/colors/index.d.ts +2 -2
- package/agnostic/colors/index.js +2 -2
- package/agnostic/css/index.d.ts +1 -1
- package/agnostic/css/index.js +1 -1
- package/agnostic/html/hyper-json/smart-tags/coalesced/index.d.ts +10 -10
- package/agnostic/html/hyper-json/smart-tags/coalesced/index.js +10 -10
- package/agnostic/html/hyper-json/smart-tags/isolated/index.d.ts +2 -2
- package/agnostic/html/hyper-json/smart-tags/isolated/index.js +2 -2
- package/agnostic/html/index.d.ts +1 -1
- package/agnostic/html/index.js +1 -1
- package/agnostic/index.d.ts +5 -4
- package/agnostic/index.js +5 -4
- package/agnostic/misc/index.d.ts +3 -3
- package/agnostic/misc/index.js +3 -3
- package/agnostic/numbers/index.d.ts +1 -1
- package/agnostic/numbers/index.js +1 -1
- package/agnostic/objects/index.d.ts +2 -2
- package/agnostic/objects/index.js +2 -2
- package/agnostic/strings/index.d.ts +1 -1
- package/agnostic/strings/index.js +1 -1
- package/agnostic/subtitles/index.d.ts +2 -0
- package/agnostic/subtitles/index.js +2 -0
- package/agnostic/subtitles/parse-srt/index.d.ts +17 -0
- package/agnostic/subtitles/parse-srt/index.js +67 -0
- package/agnostic/subtitles/parse-srt/index.test.d.ts +1 -0
- package/agnostic/subtitles/parse-srt/index.test.js +94 -0
- package/agnostic/subtitles/parse-srt/types.d.ts +22 -0
- package/agnostic/subtitles/parse-srt/types.js +1 -0
- package/agnostic/subtitles/srt-timecode-to-ms/index.d.ts +14 -0
- package/agnostic/subtitles/srt-timecode-to-ms/index.js +36 -0
- package/agnostic/subtitles/srt-timecode-to-ms/index.test.d.ts +1 -0
- package/agnostic/subtitles/srt-timecode-to-ms/index.test.js +44 -0
- package/components/BeforeAfter/index.controlled.d.ts +5 -4
- package/components/BeforeAfter/index.controlled.js +6 -6
- package/components/Drawer/index.d.ts +3 -1
- package/components/Drawer/index.js +5 -2
- package/components/Iframe/index.d.ts +4 -0
- package/components/Iframe/index.js +8 -1
- package/components/Input/index.d.ts +4 -0
- package/components/Input/index.js +9 -1
- package/components/ListLoader/index.controlled.d.ts +11 -3
- package/components/ListLoader/index.controlled.js +6 -3
- package/components/ListLoader/index.d.ts +1 -1
- package/components/ListLoader/index.js +2 -2
- package/components/ResizeObserver/index.d.ts +6 -1
- package/components/ResizeObserver/index.js +7 -2
- package/components/Scrllgngn/index.d.ts +37 -7
- package/components/Scrllgngn/index.js +81 -24
- package/components/Scrllgngn/styles.module.css +30 -5
- package/components/Scrllgngn/utils.d.ts +87 -4
- package/components/Scrllgngn/utils.js +142 -8
- package/components/ScrollListener/index.d.ts +12 -3
- package/components/ScrollListener/index.js +12 -3
- package/components/Select/index.d.ts +4 -0
- package/components/Select/index.js +9 -1
- package/components/Sequencer/index.d.ts +10 -1
- package/components/Sequencer/index.js +7 -3
- package/components/ShadowRoot/index.d.ts +4 -0
- package/components/ShadowRoot/index.js +5 -1
- package/components/Subtitles/index.d.ts +53 -62
- package/components/Subtitles/index.js +90 -158
- package/components/Subtitles/utils.d.ts +60 -0
- package/components/Subtitles/utils.js +76 -0
- package/components/Textarea/index.d.ts +4 -0
- package/components/Textarea/index.js +9 -1
- package/components/Video/index.controlled.d.ts +13 -2
- package/components/Video/index.controlled.js +37 -6
- package/components/Video/index.d.ts +47 -9
- package/components/Video/index.js +71 -20
- package/components/Video/utils.d.ts +14 -0
- package/components/Video/utils.js +18 -0
- package/components/index.d.ts +5 -5
- package/components/index.js +5 -5
- package/components/utils/types.d.ts +32 -0
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/node/@aws-s3/storage/directory/index.d.ts +1 -1
- package/node/@aws-s3/storage/directory/index.js +1 -1
- package/node/@aws-s3/storage/file/index.d.ts +1 -1
- package/node/@aws-s3/storage/file/index.js +1 -1
- package/node/@google-cloud/storage/directory/index.d.ts +1 -1
- package/node/@google-cloud/storage/directory/index.js +1 -1
- package/node/@google-cloud/storage/file/index.d.ts +3 -3
- package/node/@google-cloud/storage/file/index.js +3 -3
- package/node/cloud-storage/operations/index.d.ts +2 -2
- package/node/cloud-storage/operations/index.js +2 -2
- package/node/images/index.d.ts +1 -1
- package/node/images/index.js +1 -1
- package/node/images/transform/operations/index.d.ts +5 -5
- package/node/images/transform/operations/index.js +5 -5
- package/node/index.d.ts +1 -1
- package/node/index.js +1 -1
- package/node/sftp/file/index.d.ts +1 -1
- package/node/sftp/file/index.js +1 -1
- package/package.json +26 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type FunctionComponent } from 'react';
|
|
2
2
|
import { type Props as IOCompProps } from '../IntersectionObserver/index.js';
|
|
3
|
+
import type { ViewportObserverOptions } from '../utils/types.js';
|
|
3
4
|
import { type Props as ControlledProps } from './index.controlled.js';
|
|
4
5
|
/**
|
|
5
6
|
* Props for the {@link Sequencer} component.
|
|
@@ -29,6 +30,13 @@ import { type Props as ControlledProps } from './index.controlled.js';
|
|
|
29
30
|
* component enters the viewport. No-op when `play` is controlled.
|
|
30
31
|
* @property pauseOnHidden - When `true`, pauses internal playback when the
|
|
31
32
|
* component leaves the viewport. No-op when `play` is controlled.
|
|
33
|
+
* @property threshold - How much of the component has to be in view before it
|
|
34
|
+
* counts as visible, forwarded to the internal {@link IntersectionObserver}.
|
|
35
|
+
* @property root - The observer's root. Defaults to the viewport.
|
|
36
|
+
* @property rootMargin - Grows or shrinks that root before measuring.
|
|
37
|
+
* @property onVisibilityChanged - Called on every crossing with the new value.
|
|
38
|
+
* `onIntersected` is the raw form of the same event, carrying the entry and the
|
|
39
|
+
* observer; this one carries the answer.
|
|
32
40
|
* @property onIntersected - Forwarded verbatim to the internal
|
|
33
41
|
* {@link IntersectionObserverComponent}, and called on every intersection
|
|
34
42
|
* change whichever mode the sequencer runs in.
|
|
@@ -42,7 +50,7 @@ import { type Props as ControlledProps } from './index.controlled.js';
|
|
|
42
50
|
* @property onReachedLastStep - Called when the forwarded step becomes the last
|
|
43
51
|
* one.
|
|
44
52
|
*/
|
|
45
|
-
export type Props = Omit<ControlledProps, 'isPlaying' | 'tempo'> & {
|
|
53
|
+
export type Props = ViewportObserverOptions & Omit<ControlledProps, 'isPlaying' | 'tempo'> & {
|
|
46
54
|
defaultStep?: number;
|
|
47
55
|
tempo?: number;
|
|
48
56
|
play?: boolean;
|
|
@@ -54,6 +62,7 @@ export type Props = Omit<ControlledProps, 'isPlaying' | 'tempo'> & {
|
|
|
54
62
|
playOnVisible?: boolean;
|
|
55
63
|
pauseOnHidden?: boolean;
|
|
56
64
|
onIntersected?: IOCompProps['onIntersected'];
|
|
65
|
+
onVisibilityChanged?: (isVisible: boolean) => void;
|
|
57
66
|
onIsPlayingChanged?: (isPlaying: boolean) => void;
|
|
58
67
|
onStepChanged?: (step: number) => void;
|
|
59
68
|
onLooped?: () => void;
|
|
@@ -30,7 +30,7 @@ import { ControlledSequencer } from './index.controlled.js';
|
|
|
30
30
|
* @returns An {@link IntersectionObserverComponent} wrapping a
|
|
31
31
|
* {@link ControlledSequencer} with the computed step and modifiers applied.
|
|
32
32
|
*/
|
|
33
|
-
export const Sequencer = ({ defaultStep, tempo = 60, play, loop, clampFirst, clampLast, resetOnVisible, resetOnHidden, playOnVisible, pauseOnHidden, onIntersected, onIsPlayingChanged, onStepChanged, onLooped, onReachedFirstStep, onReachedLastStep, ...controlledProps }) => {
|
|
33
|
+
export const Sequencer = ({ defaultStep, tempo = 60, play, loop, clampFirst, clampLast, resetOnVisible, resetOnHidden, playOnVisible, pauseOnHidden, onIntersected, onVisibilityChanged, threshold, root, rootMargin, onIsPlayingChanged, onStepChanged, onLooped, onReachedFirstStep, onReachedLastStep, ...controlledProps }) => {
|
|
34
34
|
// State
|
|
35
35
|
const { step, activateOnStep, children } = controlledProps;
|
|
36
36
|
const [internalPlay, setInternalPlay] = useState(play ?? false);
|
|
@@ -83,9 +83,13 @@ export const Sequencer = ({ defaultStep, tempo = 60, play, loop, clampFirst, cla
|
|
|
83
83
|
// Action handlers
|
|
84
84
|
const handleIntersection = useCallback(({ ioEntry, observer }) => {
|
|
85
85
|
onIntersected?.({ ioEntry, observer });
|
|
86
|
+
const { isIntersecting } = ioEntry ?? {};
|
|
87
|
+
if (isIntersecting !== undefined)
|
|
88
|
+
onVisibilityChanged?.(isIntersecting);
|
|
89
|
+
// Both handlers fire whatever the mode: a controlled sequencer still wants to
|
|
90
|
+
// hear about the viewport, it just doesn't let it drive the step.
|
|
86
91
|
if (play === true || step !== undefined)
|
|
87
92
|
return;
|
|
88
|
-
const { isIntersecting } = ioEntry ?? {};
|
|
89
93
|
if (isIntersecting === true) {
|
|
90
94
|
if (resetOnVisible === true)
|
|
91
95
|
setInternalStep(0);
|
|
@@ -108,5 +112,5 @@ export const Sequencer = ({ defaultStep, tempo = 60, play, loop, clampFirst, cla
|
|
|
108
112
|
onIntersected
|
|
109
113
|
]);
|
|
110
114
|
// Rendering
|
|
111
|
-
return _jsx(IntersectionObserverComponent, { onIntersected: handleIntersection, children: _jsx(ControlledSequencer, { ...controlledProps, step: forwardedStep, isPlaying: actualPlay, tempo: tempo }) });
|
|
115
|
+
return _jsx(IntersectionObserverComponent, { threshold: threshold, root: root, rootMargin: rootMargin, onIntersected: handleIntersection, children: _jsx(ControlledSequencer, { ...controlledProps, step: forwardedStep, isPlaying: actualPlay, tempo: tempo }) });
|
|
112
116
|
};
|
|
@@ -28,6 +28,10 @@ export type Props = PropsWithChildren<WithClassName<{
|
|
|
28
28
|
* Component that creates a Shadow Root on its host element and renders
|
|
29
29
|
* its children inside that Shadow Root using a React portal.
|
|
30
30
|
*
|
|
31
|
+
* ### Root element modifiers
|
|
32
|
+
* - `--attached` — the shadow root exists and the children are rendered into it.
|
|
33
|
+
* Absent on the first render, before the host element is in the document.
|
|
34
|
+
*
|
|
31
35
|
* @param props - Component properties
|
|
32
36
|
* @see {@link Props}
|
|
33
37
|
* @returns A host `div` element that owns the created Shadow Root.
|
|
@@ -9,6 +9,10 @@ import cssModule from './styles.module.css';
|
|
|
9
9
|
* Component that creates a Shadow Root on its host element and renders
|
|
10
10
|
* its children inside that Shadow Root using a React portal.
|
|
11
11
|
*
|
|
12
|
+
* ### Root element modifiers
|
|
13
|
+
* - `--attached` — the shadow root exists and the children are rendered into it.
|
|
14
|
+
* Absent on the first render, before the host element is in the document.
|
|
15
|
+
*
|
|
12
16
|
* @param props - Component properties
|
|
13
17
|
* @see {@link Props}
|
|
14
18
|
* @returns A host `div` element that owns the created Shadow Root.
|
|
@@ -38,6 +42,6 @@ export const ShadowRootComponent = ({ mode = 'open', delegatesFocus, slotAssignm
|
|
|
38
42
|
]);
|
|
39
43
|
// Rendering
|
|
40
44
|
const c = clss(publicClassName, { cssModule });
|
|
41
|
-
const rootClss = mergeClassNames(c(), className);
|
|
45
|
+
const rootClss = mergeClassNames(c(null, { attached: shadowRoot !== null }), className);
|
|
42
46
|
return _jsx("div", { ref: hostRef, className: rootClss, children: shadowRoot !== null && createPortal(_jsxs(_Fragment, { children: [styles !== undefined && _jsx("style", { children: styles }), children] }), shadowRoot) });
|
|
43
47
|
};
|
|
@@ -1,82 +1,73 @@
|
|
|
1
|
-
import { type FunctionComponent
|
|
1
|
+
import { type FunctionComponent } from 'react';
|
|
2
|
+
import type { SrtCue } from '../../agnostic/subtitles/parse-srt/types.js';
|
|
2
3
|
import type { WithClassName } from '../utils/types.js';
|
|
3
|
-
/**
|
|
4
|
-
* A single parsed subtitle entry from an SRT file.
|
|
5
|
-
*
|
|
6
|
-
* @property id - Sequential subtitle number.
|
|
7
|
-
* @property start - Start time in milliseconds.
|
|
8
|
-
* @property end - End time in milliseconds.
|
|
9
|
-
* @property content - Subtitle text content.
|
|
10
|
-
*/
|
|
11
|
-
export type ParsedSub = {
|
|
12
|
-
id: number;
|
|
13
|
-
start?: number;
|
|
14
|
-
end?: number;
|
|
15
|
-
content?: string;
|
|
16
|
-
};
|
|
17
|
-
/**
|
|
18
|
-
* The inclusive ID boundaries of a subtitle group.
|
|
19
|
-
*
|
|
20
|
-
* @property startId - ID of the first subtitle in the group.
|
|
21
|
-
* @property endId - ID of the last subtitle in the group.
|
|
22
|
-
*/
|
|
23
|
-
export type SubGroupBoundaries = {
|
|
24
|
-
startId: number;
|
|
25
|
-
endId: number;
|
|
26
|
-
};
|
|
27
4
|
/**
|
|
28
5
|
* Props for the {@link Subtitles} component.
|
|
29
6
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* @property
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* @property
|
|
43
|
-
*
|
|
44
|
-
* @property
|
|
45
|
-
*
|
|
46
|
-
* @property
|
|
7
|
+
* The three sources are tried in order — `cues`, then `srtFileContent`, then `src` —
|
|
8
|
+
* and the first one present wins. With none of them, nothing is rendered.
|
|
9
|
+
*
|
|
10
|
+
* @property cues - Already parsed cues, used as they are. Spares a consumer that
|
|
11
|
+
* holds them — from a previous `onParsed`, or from its own call to `parseSrt` —
|
|
12
|
+
* having to serialise them back to SRT.
|
|
13
|
+
* @property srtFileContent - A raw SRT string, parsed directly with no network call.
|
|
14
|
+
* @property src - URL of an SRT file to fetch and parse.
|
|
15
|
+
* @property subsGroups - Positions to split the cues on, each the **position** of a
|
|
16
|
+
* group's last cue — not the id written in the file. Order doesn't matter, and
|
|
17
|
+
* duplicate or out-of-range positions are ignored. Omitted, every cue belongs to one
|
|
18
|
+
* group.
|
|
19
|
+
* @property timecodeMs - Current media position in milliseconds. Drives the `--prev`
|
|
20
|
+
* and `--curr` modifiers. While it is `undefined`, no group is rendered.
|
|
21
|
+
* @property isEnded - When `true`, the last group is the current one whatever
|
|
22
|
+
* `timecodeMs` says, so the closing subtitles stay up once playback is over.
|
|
23
|
+
* @property onLoaded - Called with the raw SRT string once fetched. Only fires for
|
|
24
|
+
* `src`, since the other two sources fetch nothing.
|
|
25
|
+
* @property onParsed - Called with the cues after they changed. Never on mount.
|
|
26
|
+
* @property onLoadFailed - Called with an `Error` when the fetch fails, the response
|
|
27
|
+
* is not ok, or the parse throws.
|
|
47
28
|
* @property className - Optional additional class name(s) applied to the root element.
|
|
48
|
-
* @property children - React children rendered inside the root element, after the subtitle groups.
|
|
49
29
|
*/
|
|
50
|
-
export type Props =
|
|
51
|
-
|
|
30
|
+
export type Props = WithClassName<{
|
|
31
|
+
cues?: SrtCue[];
|
|
52
32
|
srtFileContent?: string;
|
|
33
|
+
src?: string;
|
|
53
34
|
subsGroups?: number[];
|
|
54
35
|
timecodeMs?: number;
|
|
55
36
|
isEnded?: boolean;
|
|
56
37
|
onLoaded?: (subs: string) => void;
|
|
57
|
-
onParsed?: (subs:
|
|
38
|
+
onParsed?: (subs: SrtCue[]) => void;
|
|
58
39
|
onLoadFailed?: (error: Error) => void;
|
|
59
|
-
}
|
|
40
|
+
}>;
|
|
60
41
|
/**
|
|
61
|
-
* Subtitle synchronization component.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
42
|
+
* Subtitle synchronization component. Takes an SRT source — parsed cues, a raw
|
|
43
|
+
* string, or a URL to fetch — and renders its cues in groups, marking where the
|
|
44
|
+
* media currently is.
|
|
64
45
|
*
|
|
65
|
-
* ###
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* - `
|
|
69
|
-
* - `
|
|
46
|
+
* ### CSS elements
|
|
47
|
+
* - `lm-subtitles` — the root. Carries `--loading` while a fetch is in flight, and
|
|
48
|
+
* `--error` once one failed. Both are cleared when a new source starts loading.
|
|
49
|
+
* - `lm-subtitles__group` — one per group. Carries `--curr` when the reader is in it.
|
|
50
|
+
* - `lm-subtitles__sub` — one per cue. Carries `--prev` once the playhead has passed
|
|
51
|
+
* its start, and `--curr` while the playhead is inside its interval.
|
|
70
52
|
*
|
|
71
|
-
* ###
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* - `data-sub-pos` — the subtitle's numeric ID from the SRT source.
|
|
53
|
+
* ### Data attributes
|
|
54
|
+
* On a group: `data-start-sub-pos` and `data-end-sub-pos`, its bounds as positions
|
|
55
|
+
* in the cue array. On a cue: `data-sub-pos`, its position, and `data-sub-id`, the
|
|
56
|
+
* number written in the file.
|
|
76
57
|
*
|
|
77
58
|
* @param props - Component properties.
|
|
78
59
|
* @see {@link Props}
|
|
79
|
-
* @returns A root `<div>`
|
|
80
|
-
*
|
|
60
|
+
* @returns A root `<div>` holding the subtitle groups, empty while there is no
|
|
61
|
+
* timecode or no cue.
|
|
62
|
+
*
|
|
63
|
+
* @remarks
|
|
64
|
+
* Uncontrolled only. The cues are state, fed by whichever source is set: there is no
|
|
65
|
+
* controlled variant, because a consumer holding its own cues passes them as `cues`
|
|
66
|
+
* and owns them outright.
|
|
67
|
+
*
|
|
68
|
+
* Cues are rendered back to back with no separator — the space between two of them
|
|
69
|
+
* is presentation, and belongs in a stylesheet (`lm-subtitles__sub + …::before`, a
|
|
70
|
+
* `word-spacing`, whatever suits) rather than in the text content, where it would
|
|
71
|
+
* come back out of `textContent`.
|
|
81
72
|
*/
|
|
82
73
|
export declare const Subtitles: FunctionComponent<Props>;
|
|
@@ -1,188 +1,120 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
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 {
|
|
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.
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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
|
-
* ###
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* - `
|
|
102
|
-
* - `
|
|
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
|
-
* ###
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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>`
|
|
113
|
-
*
|
|
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 = ({
|
|
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
|
-
//
|
|
123
|
-
|
|
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(
|
|
65
|
+
return setParsedSubs(parseSrt(srtFileContent));
|
|
126
66
|
if (src === undefined)
|
|
127
|
-
return;
|
|
67
|
+
return setParsedSubs([]);
|
|
68
|
+
let isCurrent = true;
|
|
128
69
|
setIsLoading(true);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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:
|
|
185
|
-
});
|
|
186
|
-
|
|
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;
|