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.
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/PLATFORM.md +262 -0
- package/README.md +324 -0
- package/lib/ADControls.d.ts +24 -0
- package/lib/ADControls.js +41 -0
- package/lib/ADControls.js.map +1 -0
- package/lib/CueScheduler.d.ts +49 -0
- package/lib/CueScheduler.js +114 -0
- package/lib/CueScheduler.js.map +1 -0
- package/lib/DescriptionAudio.d.ts +36 -0
- package/lib/DescriptionAudio.js +88 -0
- package/lib/DescriptionAudio.js.map +1 -0
- package/lib/MediaAdapter.d.ts +123 -0
- package/lib/MediaAdapter.js +3 -0
- package/lib/MediaAdapter.js.map +1 -0
- package/lib/TrackLoader.d.ts +48 -0
- package/lib/TrackLoader.js +129 -0
- package/lib/TrackLoader.js.map +1 -0
- package/lib/budget.d.ts +41 -0
- package/lib/budget.js +60 -0
- package/lib/budget.js.map +1 -0
- package/lib/duck.d.ts +21 -0
- package/lib/duck.js +40 -0
- package/lib/duck.js.map +1 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +33 -0
- package/lib/index.js.map +1 -0
- package/lib/log.d.ts +17 -0
- package/lib/log.js +18 -0
- package/lib/log.js.map +1 -0
- package/lib/messages.d.ts +21 -0
- package/lib/messages.js +17 -0
- package/lib/messages.js.map +1 -0
- package/lib/track.d.ts +44 -0
- package/lib/track.js +22 -0
- package/lib/track.js.map +1 -0
- package/lib/vega/SegmentBuffer.d.ts +87 -0
- package/lib/vega/SegmentBuffer.js +133 -0
- package/lib/vega/SegmentBuffer.js.map +1 -0
- package/lib/vega/index.d.ts +33 -0
- package/lib/vega/index.js +280 -0
- package/lib/vega/index.js.map +1 -0
- package/package.json +89 -0
- package/src/ADControls.tsx +92 -0
- package/src/CueScheduler.ts +145 -0
- package/src/DescriptionAudio.ts +97 -0
- package/src/MediaAdapter.ts +136 -0
- package/src/TrackLoader.ts +156 -0
- package/src/budget.ts +61 -0
- package/src/duck.ts +42 -0
- package/src/index.ts +42 -0
- package/src/log.ts +30 -0
- package/src/messages.ts +27 -0
- package/src/track.ts +52 -0
- package/src/vega/SegmentBuffer.ts +195 -0
- 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
|
+
}
|