react-native-tv-audio-description 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/PLATFORM.md +262 -0
  4. package/README.md +324 -0
  5. package/lib/ADControls.d.ts +24 -0
  6. package/lib/ADControls.js +41 -0
  7. package/lib/ADControls.js.map +1 -0
  8. package/lib/CueScheduler.d.ts +49 -0
  9. package/lib/CueScheduler.js +114 -0
  10. package/lib/CueScheduler.js.map +1 -0
  11. package/lib/DescriptionAudio.d.ts +36 -0
  12. package/lib/DescriptionAudio.js +88 -0
  13. package/lib/DescriptionAudio.js.map +1 -0
  14. package/lib/MediaAdapter.d.ts +123 -0
  15. package/lib/MediaAdapter.js +3 -0
  16. package/lib/MediaAdapter.js.map +1 -0
  17. package/lib/TrackLoader.d.ts +48 -0
  18. package/lib/TrackLoader.js +129 -0
  19. package/lib/TrackLoader.js.map +1 -0
  20. package/lib/budget.d.ts +41 -0
  21. package/lib/budget.js +60 -0
  22. package/lib/budget.js.map +1 -0
  23. package/lib/duck.d.ts +21 -0
  24. package/lib/duck.js +40 -0
  25. package/lib/duck.js.map +1 -0
  26. package/lib/index.d.ts +10 -0
  27. package/lib/index.js +33 -0
  28. package/lib/index.js.map +1 -0
  29. package/lib/log.d.ts +17 -0
  30. package/lib/log.js +18 -0
  31. package/lib/log.js.map +1 -0
  32. package/lib/messages.d.ts +21 -0
  33. package/lib/messages.js +17 -0
  34. package/lib/messages.js.map +1 -0
  35. package/lib/track.d.ts +44 -0
  36. package/lib/track.js +22 -0
  37. package/lib/track.js.map +1 -0
  38. package/lib/vega/SegmentBuffer.d.ts +87 -0
  39. package/lib/vega/SegmentBuffer.js +133 -0
  40. package/lib/vega/SegmentBuffer.js.map +1 -0
  41. package/lib/vega/index.d.ts +33 -0
  42. package/lib/vega/index.js +280 -0
  43. package/lib/vega/index.js.map +1 -0
  44. package/package.json +89 -0
  45. package/src/ADControls.tsx +92 -0
  46. package/src/CueScheduler.ts +145 -0
  47. package/src/DescriptionAudio.ts +97 -0
  48. package/src/MediaAdapter.ts +136 -0
  49. package/src/TrackLoader.ts +156 -0
  50. package/src/budget.ts +61 -0
  51. package/src/duck.ts +42 -0
  52. package/src/index.ts +42 -0
  53. package/src/log.ts +30 -0
  54. package/src/messages.ts +27 -0
  55. package/src/track.ts +52 -0
  56. package/src/vega/SegmentBuffer.ts +195 -0
  57. package/src/vega/index.tsx +382 -0
@@ -0,0 +1,33 @@
1
+ import type { MediaAdapter } from '../MediaAdapter';
2
+ import { type BufferWindow } from './SegmentBuffer';
3
+ export { SegmentBuffer, DEFAULT_WINDOW, type AppendTarget, type BufferWindow, type SourceLike, } from './SegmentBuffer';
4
+ /**
5
+ * The Vega OS implementation of `MediaAdapter`.
6
+ *
7
+ * EXTRACTED from code that already ran on the Vega Virtual Device, not written
8
+ * from the interface down. Each of the following is a bug somebody already
9
+ * paid for (details in PLATFORM.md):
10
+ * - `srcObject`, never `src` — `url_mode_broken`: assigning a URL to `src`
11
+ * fails before a single byte is requested;
12
+ * - dual readiness tracking — `surface_races_init`: the surface can arrive
13
+ * before `initialize()` resolves, and playing before both have landed
14
+ * yields MEDIA_ERR_SRC_NOT_SUPPORTED, which reads exactly like an
15
+ * unsupported file and is not one;
16
+ * - fragmented MP4 through MediaSource only — `mse_path`;
17
+ * - clips on an AudioPlayer built for accessibility speech, as a second
18
+ * stream over the film — `concurrent_streams`.
19
+ */
20
+ export interface VegaAdapterOptions {
21
+ /**
22
+ * The MSE type of YOUR asset, with full codec parameters. A bare
23
+ * 'video/mp4' is rejected at addSourceBuffer. Default: H.264 Constrained
24
+ * Baseline L3.0 + AAC-LC. Read yours with
25
+ * `ffprobe -show_streams` and build the RFC 6381 string.
26
+ */
27
+ videoMime?: string;
28
+ /** the MSE type of the description clips (default fragmented AAC-LC) */
29
+ clipMime?: string;
30
+ /** how much media to hold around the playhead */
31
+ window?: BufferWindow;
32
+ }
33
+ export declare function createVegaAdapter(options?: VegaAdapterOptions): MediaAdapter;
@@ -0,0 +1,280 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_WINDOW = exports.SegmentBuffer = void 0;
4
+ exports.createVegaAdapter = createVegaAdapter;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const react_1 = require("react");
7
+ const react_native_1 = require("react-native");
8
+ const react_native_w3cmedia_1 = require("@amazon-devices/react-native-w3cmedia");
9
+ const SegmentBuffer_1 = require("./SegmentBuffer");
10
+ const log_1 = require("../log");
11
+ var SegmentBuffer_2 = require("./SegmentBuffer");
12
+ Object.defineProperty(exports, "SegmentBuffer", { enumerable: true, get: function () { return SegmentBuffer_2.SegmentBuffer; } });
13
+ Object.defineProperty(exports, "DEFAULT_WINDOW", { enumerable: true, get: function () { return SegmentBuffer_2.DEFAULT_WINDOW; } });
14
+ const DEFAULT_VIDEO_MIME = 'video/mp4; codecs="avc1.42C01E,mp4a.40.2"';
15
+ const DEFAULT_CLIP_MIME = 'audio/mp4; codecs="mp4a.40.2"';
16
+ /** whole-file append, for description CLIPS — they are seconds long, not films */
17
+ function fetchAndAppend(source, mime, uri) {
18
+ return new Promise((resolve, reject) => {
19
+ source.addEventListener('sourceopen', () => {
20
+ (async () => {
21
+ try {
22
+ const buffer = source.addSourceBuffer(mime);
23
+ const response = await fetch(uri);
24
+ const bytes = new Uint8Array(await response.arrayBuffer());
25
+ buffer.addEventListener('updateend', () => {
26
+ if (source.readyState !== 'open')
27
+ return;
28
+ source.endOfStream();
29
+ resolve(bytes.byteLength);
30
+ });
31
+ buffer.appendBuffer(bytes);
32
+ }
33
+ catch (err) {
34
+ reject(err);
35
+ }
36
+ })().catch(reject);
37
+ });
38
+ });
39
+ }
40
+ class VegaVideo {
41
+ constructor(mime, window) {
42
+ this.mime = mime;
43
+ this.window = window;
44
+ this.player = new react_native_w3cmedia_1.VideoPlayer();
45
+ this.ready = { player: false, surface: false };
46
+ this.pendingPlay = null;
47
+ this.listeners = new Map();
48
+ this.initialised = null;
49
+ this.lastError = null;
50
+ this.segments = null;
51
+ }
52
+ async initialize() {
53
+ if (!this.initialised) {
54
+ this.initialised = this.player.initialize().then(() => {
55
+ this.player.addEventListener('error', () => {
56
+ const code = this.player.error?.code ?? -1;
57
+ const msg = this.player.error?.message ?? 'none';
58
+ (0, log_1.log)(`player.error code=${code} msg=${msg}`);
59
+ this.lastError = new Error(`media error ${code}: ${msg}`);
60
+ this.emit('error');
61
+ });
62
+ for (const type of ['timeupdate', 'seeked', 'waiting', 'stalled', 'playing', 'ended']) {
63
+ this.player.addEventListener(type, () => this.emit(type));
64
+ }
65
+ });
66
+ }
67
+ return this.initialised;
68
+ }
69
+ emit(type) {
70
+ for (const cb of this.listeners.get(type) ?? [])
71
+ cb();
72
+ }
73
+ on(type, cb) {
74
+ const set = this.listeners.get(type) ?? new Set();
75
+ set.add(cb);
76
+ this.listeners.set(type, set);
77
+ return () => set.delete(cb);
78
+ }
79
+ /** called by the surface component when the platform hands over a handle */
80
+ attachSurface(handle) {
81
+ (0, log_1.log)(`player.surface created handle=${handle}`);
82
+ this.player.setSurfaceHandle(handle);
83
+ this.ready.surface = true;
84
+ this.release();
85
+ }
86
+ detachSurface(handle) {
87
+ (0, log_1.log)(`player.surface destroyed handle=${handle}`);
88
+ this.player.clearSurfaceHandle(handle);
89
+ this.ready.surface = false;
90
+ }
91
+ /** whichever of the two readiness signals lands second lets playback start */
92
+ release() {
93
+ if (!this.ready.player || !this.ready.surface)
94
+ return;
95
+ const go = this.pendingPlay;
96
+ this.pendingPlay = null;
97
+ go?.();
98
+ }
99
+ async open(asset) {
100
+ await this.initialize();
101
+ const source = new react_native_w3cmedia_1.MediaSource();
102
+ const opened = new Promise((resolve, reject) => {
103
+ let handled = false;
104
+ source.addEventListener('sourceopen', () => {
105
+ // ONCE. Per MSE, appendBuffer() or remove() on an 'ended' source
106
+ // reopens it and fires 'sourceopen' again — and evicting behind the
107
+ // playhead after the last segment is in does exactly that. Handling it
108
+ // twice builds a second SourceBuffer and re-appends the whole asset,
109
+ // which evicts, which reopens: a loop, measured on the Virtual Device
110
+ // at the end of a 20 s clip (PLATFORM.md `sourceopen_refires`).
111
+ if (handled)
112
+ return;
113
+ handled = true;
114
+ (async () => {
115
+ const buffer = source.addSourceBuffer(this.mime);
116
+ this.segments = new SegmentBuffer_1.SegmentBuffer(source, buffer, asset, this.window);
117
+ await this.segments.start();
118
+ resolve();
119
+ })().catch(reject);
120
+ });
121
+ });
122
+ // srcObject, NOT src — see PLATFORM.md `url_mode_broken`.
123
+ this.player.srcObject = source;
124
+ await opened;
125
+ (0, log_1.log)(`player.opened segments=${asset.segments.length} init=${asset.initUri ? 'yes' : 'none'}`);
126
+ // Keep the window fed as the playhead moves. The bounded window is what
127
+ // stops a feature-length asset becoming a feature-length ArrayBuffer.
128
+ this.on('timeupdate', () => {
129
+ void this.segments?.ensure(this.positionMs());
130
+ });
131
+ this.ready.player = true;
132
+ this.release();
133
+ }
134
+ play() {
135
+ return new Promise((resolve, reject) => {
136
+ const start = () => {
137
+ this.player
138
+ .play()
139
+ .then(() => {
140
+ (0, log_1.log)('player.play resolved');
141
+ resolve();
142
+ })
143
+ .catch(reject);
144
+ };
145
+ if (this.ready.player && this.ready.surface)
146
+ start();
147
+ else
148
+ this.pendingPlay = start; // the surface race, handled once, here
149
+ });
150
+ }
151
+ pause() {
152
+ this.player.pause();
153
+ }
154
+ positionMs() {
155
+ // currentTime is NaN until the player has media; a NaN position would
156
+ // reach the scheduler as a seek to nowhere
157
+ const t = this.player.currentTime;
158
+ return Number.isFinite(t) ? Math.round(t * 1000) : 0;
159
+ }
160
+ durationMs() {
161
+ const d = this.player.duration;
162
+ return Number.isFinite(d) ? Math.round(d * 1000) : 0;
163
+ }
164
+ isPlaying() {
165
+ return !this.player.paused;
166
+ }
167
+ setVolumePct(pct) {
168
+ this.player.volume = Math.max(0, Math.min(100, pct)) / 100;
169
+ }
170
+ onPosition(cb) {
171
+ return this.on('timeupdate', () => cb(this.positionMs()));
172
+ }
173
+ onSeek(cb) {
174
+ return this.on('seeked', () => cb(this.positionMs()));
175
+ }
176
+ onStalled(cb) {
177
+ const offWaiting = this.on('waiting', cb);
178
+ const offStalled = this.on('stalled', cb);
179
+ return () => {
180
+ offWaiting();
181
+ offStalled();
182
+ };
183
+ }
184
+ onPlaying(cb) {
185
+ return this.on('playing', cb);
186
+ }
187
+ onEnded(cb) {
188
+ return this.on('ended', cb);
189
+ }
190
+ onError(cb) {
191
+ return this.on('error', () => cb(this.lastError ?? new Error('media error')));
192
+ }
193
+ async destroy() {
194
+ this.segments?.dispose();
195
+ this.segments = null;
196
+ this.listeners.clear();
197
+ this.ready = { player: false, surface: false };
198
+ this.pendingPlay = null;
199
+ await this.player.deinitialize().catch(() => {
200
+ // best effort during teardown; must not throw into React's cleanup path
201
+ });
202
+ }
203
+ }
204
+ class VegaClips {
205
+ constructor(mime) {
206
+ this.mime = mime;
207
+ this.current = null;
208
+ /** settles the clip that is playing; set for exactly as long as one is */
209
+ this.interrupt = null;
210
+ }
211
+ async play(uri) {
212
+ const player = new react_native_w3cmedia_1.AudioPlayer(react_native_w3cmedia_1.AudioContentType.CONTENT_TYPE_SPEECH, react_native_w3cmedia_1.AudioUsageType.USAGE_ACCESSIBILITY);
213
+ this.current = player;
214
+ try {
215
+ await player.initialize();
216
+ if (!react_native_w3cmedia_1.MediaSource.isTypeSupported(this.mime)) {
217
+ throw new Error(`cue container unsupported: ${this.mime}`);
218
+ }
219
+ const source = new react_native_w3cmedia_1.MediaSource();
220
+ const appended = fetchAndAppend(source, this.mime, uri);
221
+ player.srcObject = source;
222
+ const bytes = await appended;
223
+ (0, log_1.log)(`cue.audio bytes=${bytes}`);
224
+ await new Promise((resolve, reject) => {
225
+ // A paused AudioPlayer never emits 'ended', so without this a stopped
226
+ // clip's promise never settles and its player is never torn down —
227
+ // one leaked player per interrupted cue, measured on the Virtual Device.
228
+ this.interrupt = () => {
229
+ (0, log_1.log)('cue.audio state=stopped');
230
+ resolve();
231
+ };
232
+ player.addEventListener('error', () => {
233
+ const code = player.error?.code ?? -1;
234
+ reject(new Error(`cue media error ${code}`));
235
+ });
236
+ player.addEventListener('ended', () => {
237
+ (0, log_1.log)(`cue.audio state=ended t=${player.currentTime.toFixed(2)}`);
238
+ resolve();
239
+ });
240
+ player.play().catch(reject);
241
+ });
242
+ }
243
+ finally {
244
+ this.current = null;
245
+ this.interrupt = null;
246
+ // Not awaited: tearing the clip player down took ~130 ms on the Virtual
247
+ // Device, and every one of them kept the film ducked after the speech
248
+ // had ended. A cue that cannot be torn down must not stop the film.
249
+ void player.deinitialize().catch(() => undefined);
250
+ }
251
+ }
252
+ stop() {
253
+ this.current?.pause();
254
+ this.interrupt?.();
255
+ }
256
+ }
257
+ class VegaLifecycle {
258
+ onBackground(cb) {
259
+ const sub = react_native_1.AppState.addEventListener('change', (state) => {
260
+ if (state !== 'active')
261
+ cb();
262
+ });
263
+ return () => sub.remove();
264
+ }
265
+ }
266
+ function createVegaAdapter(options = {}) {
267
+ const video = new VegaVideo(options.videoMime ?? DEFAULT_VIDEO_MIME, options.window ?? SegmentBuffer_1.DEFAULT_WINDOW);
268
+ function VideoSurface({ style }) {
269
+ const onCreated = (0, react_1.useCallback)((handle) => video.attachSurface(handle), []);
270
+ const onDestroyed = (0, react_1.useCallback)((handle) => video.detachSurface(handle), []);
271
+ return ((0, jsx_runtime_1.jsx)(react_native_w3cmedia_1.KeplerVideoSurfaceView, { style: style, scalingmode: "fit", onSurfaceViewCreated: onCreated, onSurfaceViewDestroyed: onDestroyed }));
272
+ }
273
+ return {
274
+ video,
275
+ clips: new VegaClips(options.clipMime ?? DEFAULT_CLIP_MIME),
276
+ lifecycle: new VegaLifecycle(),
277
+ VideoSurface,
278
+ };
279
+ }
280
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/vega/index.tsx"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "react-native-tv-audio-description",
3
+ "version": "0.1.0",
4
+ "description": "Speak AI-generated audio description in the gaps between dialogue, on the TV itself: duck the film, play the cue, restore — driven from the remote, with three verbosity levels.",
5
+ "license": "MIT",
6
+ "author": "César Rivas",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/CesarRivasP/react-native-tv-audio-description.git"
10
+ },
11
+ "homepage": "https://github.com/CesarRivasP/react-native-tv-audio-description#readme",
12
+ "bugs": "https://github.com/CesarRivasP/react-native-tv-audio-description/issues",
13
+ "keywords": [
14
+ "react-native",
15
+ "tv",
16
+ "fire-tv",
17
+ "vega",
18
+ "accessibility",
19
+ "audio-description",
20
+ "a11y",
21
+ "mse"
22
+ ],
23
+ "main": "lib/index.js",
24
+ "types": "lib/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./lib/index.d.ts",
28
+ "default": "./lib/index.js"
29
+ },
30
+ "./vega": {
31
+ "types": "./lib/vega/index.d.ts",
32
+ "default": "./lib/vega/index.js"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "lib",
38
+ "src",
39
+ "PLATFORM.md",
40
+ "CHANGELOG.md",
41
+ "LICENSE",
42
+ "README.md"
43
+ ],
44
+ "sideEffects": false,
45
+ "engines": {
46
+ "node": ">=20"
47
+ },
48
+ "scripts": {
49
+ "build": "rm -rf lib && tsc -p tsconfig.build.json",
50
+ "typecheck": "tsc --noEmit",
51
+ "test": "jest",
52
+ "example": "tsx example/node/index.ts",
53
+ "release:check": "node scripts/check-release.mjs",
54
+ "version": "node scripts/check-release.mjs --changelog-only",
55
+ "prepublishOnly": "npm run release:check && npm run typecheck && npm test && npm run build",
56
+ "prepare": "npm run build"
57
+ },
58
+ "peerDependencies": {
59
+ "@amazon-devices/react-native-kepler": "~4.0.0",
60
+ "@amazon-devices/react-native-w3cmedia": "~2.3.2",
61
+ "react": "^19.2.0",
62
+ "react-native": "^0.83.0"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "@amazon-devices/react-native-kepler": {
66
+ "optional": true
67
+ },
68
+ "@amazon-devices/react-native-w3cmedia": {
69
+ "optional": true
70
+ }
71
+ },
72
+ "devDependencies": {
73
+ "@amazon-devices/react-native-kepler": "~4.0.0",
74
+ "@amazon-devices/react-native-w3cmedia": "~2.3.2",
75
+ "@babel/core": "^7.29.7",
76
+ "@react-native/babel-preset": "0.83.0",
77
+ "@testing-library/react-native": "^13.3.3",
78
+ "@types/jest": "^29.5.14",
79
+ "@types/node": "^24.13.6",
80
+ "@types/react": "^19.3.0",
81
+ "babel-jest": "^29.7.0",
82
+ "jest": "^29.7.0",
83
+ "react": "19.2.0",
84
+ "react-native": "0.83.0",
85
+ "react-test-renderer": "19.2.0",
86
+ "tsx": "^4.23.15",
87
+ "typescript": "~5.6"
88
+ }
89
+ }
@@ -0,0 +1,92 @@
1
+ import React, { useCallback, useEffect, useRef } from 'react';
2
+ import { AccessibilityInfo, Pressable, Text, View } from 'react-native';
3
+ import type { Verbosity } from './track';
4
+ import { VERBOSITY_LEVELS } from './track';
5
+ import { log } from './log';
6
+ import { stateMessage, type ADState } from './messages';
7
+
8
+ export { stateMessage, type ADState } from './messages';
9
+
10
+ /**
11
+ * The remote surface, and every failure said out loud.
12
+ *
13
+ * This is for blind and low-vision viewers. A control surface that shows an
14
+ * error and says nothing is the same as silence, so every assertion in its
15
+ * test suite is about what is ANNOUNCED, not about what is rendered.
16
+ *
17
+ * It is deliberately unstyled: bring your own look. What it guarantees is the
18
+ * accessibility contract — roles, labels, state, and one announcement per
19
+ * state change.
20
+ */
21
+
22
+ export interface ADControlsProps {
23
+ state: ADState;
24
+ onToggle: (enabled: boolean) => void;
25
+ onVerbosity: (v: Verbosity) => void;
26
+ /** hand focus here when the player screen mounts */
27
+ focusRef?: React.Ref<View>;
28
+ }
29
+
30
+ export function ADControls({ state, onToggle, onVerbosity, focusRef }: ADControlsProps) {
31
+ const lastSpoken = useRef<string>('');
32
+
33
+ // Announce on entry and on every state change, once each. The guard is not
34
+ // an optimisation — re-announcing an unchanged state talks over the film for
35
+ // no reason, and this screen is already competing for the one channel its
36
+ // users have.
37
+ useEffect(() => {
38
+ const message = stateMessage(state);
39
+ if (message === lastSpoken.current) return;
40
+ lastSpoken.current = message;
41
+ AccessibilityInfo.announceForAccessibility(message);
42
+ log(`controls.announce kind=${state.kind}`);
43
+ }, [state]);
44
+
45
+ const toggle = useCallback(() => {
46
+ if (state.kind !== 'ready') return;
47
+ log(`controls.toggle to=${!state.enabled}`);
48
+ onToggle(!state.enabled); // the scheduler flips; the video is untouched
49
+ }, [state, onToggle]);
50
+
51
+ if (state.kind !== 'ready') {
52
+ return (
53
+ <View accessible accessibilityRole="alert" accessibilityLabel={stateMessage(state)}>
54
+ <Text>{stateMessage(state)}</Text>
55
+ </View>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <View>
61
+ <Pressable
62
+ ref={focusRef}
63
+ accessible
64
+ accessibilityRole="switch"
65
+ accessibilityLabel="Audio description"
66
+ accessibilityState={{ checked: state.enabled }}
67
+ onPress={toggle}
68
+ hasTVPreferredFocus
69
+ >
70
+ <Text>{state.enabled ? 'Description: on' : 'Description: off'}</Text>
71
+ </Pressable>
72
+
73
+ {/* three levels, each its own focusable, each announcing its state */}
74
+ {VERBOSITY_LEVELS.map((level) => (
75
+ <Pressable
76
+ key={level}
77
+ accessible
78
+ accessibilityRole="radio"
79
+ accessibilityLabel={`${level} description`}
80
+ accessibilityState={{ selected: state.verbosity === level, disabled: !state.enabled }}
81
+ disabled={!state.enabled}
82
+ onPress={() => {
83
+ log(`controls.verbosity to=${level}`);
84
+ onVerbosity(level);
85
+ }}
86
+ >
87
+ <Text>{level}</Text>
88
+ </Pressable>
89
+ ))}
90
+ </View>
91
+ );
92
+ }
@@ -0,0 +1,145 @@
1
+ import type { DescriptionCue } from './track';
2
+ import { AD } from './budget';
3
+ import { log } from './log';
4
+
5
+ /**
6
+ * Playback position becomes "fire this cue now, once".
7
+ *
8
+ * The invariant this file exists to hold: a cue fires only while position is
9
+ * inside its own window. A cue whose window has already passed is DROPPED,
10
+ * never played late — a late cue is a cue playing over dialogue, which is the
11
+ * one thing worse than no description.
12
+ */
13
+
14
+ export interface SchedulerEvents {
15
+ onFire: (cue: DescriptionCue) => void;
16
+ }
17
+
18
+ export interface SchedulerOptions {
19
+ /**
20
+ * How long a cue needs, from firing to the film being back at full. A cue
21
+ * is only fired if that much of its window is left. Default: its words at
22
+ * the narration pace plus the fade down and up — pass a better figure if you
23
+ * have the clips' real durations.
24
+ */
25
+ estimateMs?: (cue: DescriptionCue) => number;
26
+ }
27
+
28
+ /** words at the narration pace, plus the fade down and the fade back up */
29
+ export function estimateCueMs(cue: DescriptionCue): number {
30
+ return (cue.words / AD.SPEAKING_RATE_WPM) * 60_000 + 2 * AD.DUCK_RAMP_MS;
31
+ }
32
+
33
+ export class CueScheduler {
34
+ private cues: DescriptionCue[] = [];
35
+ private cursor = 0;
36
+ private firing: DescriptionCue | null = null;
37
+ private enabled = true;
38
+
39
+ private readonly estimateMs: (cue: DescriptionCue) => number;
40
+
41
+ constructor(
42
+ private readonly events: SchedulerEvents,
43
+ options: SchedulerOptions = {},
44
+ ) {
45
+ this.estimateMs = options.estimateMs ?? estimateCueMs;
46
+ }
47
+
48
+ /** `failed` cues carry no audio; they never enter the schedule. */
49
+ load(cues: DescriptionCue[]): void {
50
+ this.cues = cues
51
+ .filter((c) => c.status === 'ok' && c.audio_uri !== '')
52
+ .sort((a, b) => a.start_ms - b.start_ms);
53
+ this.cursor = 0;
54
+ this.firing = null;
55
+ log(`scheduler.load cues=${this.cues.length}`);
56
+ }
57
+
58
+ setEnabled(on: boolean): void {
59
+ this.enabled = on;
60
+ if (!on) this.firing = null;
61
+ }
62
+
63
+ /** Called on every position update from the platform. */
64
+ tick(positionMs: number): void {
65
+ if (!this.enabled) return;
66
+
67
+ // Advance past every cue whose window closed while we were elsewhere.
68
+ while (this.cursor < this.cues.length && this.cues[this.cursor]!.end_ms <= positionMs) {
69
+ const skipped = this.cues[this.cursor]!;
70
+ if (skipped !== this.firing) {
71
+ log(`scheduler.skip id=${skipped.id} pos_ms=${positionMs}`);
72
+ }
73
+ this.cursor++;
74
+ }
75
+
76
+ const next = this.cues[this.cursor];
77
+ if (!next) return;
78
+
79
+ // Fire only INSIDE the window, and only if the cue can FINISH there. A cue
80
+ // reached late — description switched back on, a seek into the middle of a
81
+ // gap — would otherwise still be speaking when dialogue resumes. Measured
82
+ // on the Vega Virtual Device: switched on 2.5 s before its window closed, a
83
+ // 2.6 s cue brought the film back up half a second into the next line.
84
+ if (positionMs >= next.start_ms && positionMs < next.end_ms && this.firing !== next) {
85
+ const remaining = next.end_ms - positionMs;
86
+ const needs = Math.round(this.estimateMs(next));
87
+ if (remaining < needs) {
88
+ this.cursor++;
89
+ log(
90
+ `scheduler.skip id=${next.id} pos_ms=${positionMs} reason=late` +
91
+ ` remaining_ms=${remaining} needs_ms=${needs}`,
92
+ );
93
+ return;
94
+ }
95
+ this.firing = next;
96
+ this.cursor++;
97
+ log(
98
+ `scheduler.fire id=${next.id} pos_ms=${positionMs}` +
99
+ ` window=[${next.start_ms},${next.end_ms})`,
100
+ );
101
+ this.events.onFire(next);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Called with the SETTLED position after a seek — not with each
107
+ * intermediate position a held direction produces. Resets the cursor to the
108
+ * first cue that has not closed yet, so no backlog fires.
109
+ */
110
+ resync(positionMs: number): void {
111
+ let lo = 0;
112
+ let hi = this.cues.length;
113
+ while (lo < hi) {
114
+ const mid = (lo + hi) >> 1;
115
+ if (this.cues[mid]!.end_ms <= positionMs) lo = mid + 1;
116
+ else hi = mid;
117
+ }
118
+ this.cursor = lo;
119
+ this.firing = null;
120
+ log(`scheduler.resync pos_ms=${positionMs} cursor=${this.cursor}`);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The input half of seeking. A held D-pad direction emits a stream of key
126
+ * events; acting on each one produces a seek storm and a resync per event.
127
+ * This defers the action until the stream stops for `quietMs`.
128
+ */
129
+ export function coalesce(fn: (value: number) => void, quietMs = 250) {
130
+ let timer: ReturnType<typeof setTimeout> | null = null;
131
+ let pending = 0;
132
+ let events = 0;
133
+
134
+ return (value: number): void => {
135
+ pending = value;
136
+ events++;
137
+ if (timer) clearTimeout(timer);
138
+ timer = setTimeout(() => {
139
+ log(`scheduler.coalesced events=${events} settled_ms=${pending}`);
140
+ events = 0;
141
+ timer = null;
142
+ fn(pending);
143
+ }, quietMs);
144
+ };
145
+ }