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
package/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ While the version is `0.x`, a **minor** release may change the public API — the
8
+ changelog says so under **Changed** whenever it does. A **patch** release never
9
+ does.
10
+
11
+ A version cannot be published without its section here: `prepublishOnly` checks.
12
+
13
+ ## [Unreleased]
14
+
15
+ ## [0.1.0] - 2026-09-27
16
+
17
+ First release: the TV-side audio description layer extracted from
18
+ [Interstice](https://github.com/CesarRivasP/Interstice), with a Vega OS adapter.
19
+
20
+ ### Added
21
+
22
+ - `MediaAdapter` — the one interface the layer uses to reach a platform: a
23
+ video player, a clip player, the app lifecycle and the video surface.
24
+ - `CueScheduler` — fires each cue once, only inside its dialogue gap, and only
25
+ if the gap has room left for it to finish; skips a late cue with a
26
+ `reason=late` log line rather than speaking over the next scene. `resync()`
27
+ after a settled seek, `coalesce()` for a held D-pad direction.
28
+ - `DescriptionAudio` — ducks the film to 25% over a 200 ms fade, plays the cue
29
+ as a second stream, restores to 100% — including when the clip fails, the
30
+ app is backgrounded, or description is switched off mid-cue.
31
+ - `rampVolumePct` — the fade the platform does not have, timed by elapsed time
32
+ so late timers cannot stretch it.
33
+ - `loadTrack` / `validateTrack` — one track file per verbosity level, validated
34
+ field by field, falling back to `standard` when a level is missing and
35
+ refusing to fall back when one is malformed. `ClipCache`, a bounded cache.
36
+ - `ADControls` — the remote surface: an on/off switch and three verbosity
37
+ levels, every state announced to the screen reader, once. `stateMessage()`
38
+ for the same sentences outside React Native.
39
+ - The word budget for track producers: `wordCeiling`, `wordTarget`,
40
+ `baseWordBudget`, `VERBOSITY_SCALES`.
41
+ - `setLogger()` — every diagnostic line handed to the app, silent by default.
42
+ - `react-native-tv-audio-description/vega` — `createVegaAdapter()` for Vega OS,
43
+ and `SegmentBuffer`, a buffer window of whole segments measured in time.
44
+ Handles, among the rest of [PLATFORM.md](PLATFORM.md): `srcObject` instead
45
+ of a URL, the surface arriving before the player is ready, `sourceopen`
46
+ firing again after the end of stream, a paused clip that never ends.
47
+ - `npm run example` — a minute of film at 20x in a terminal, self-checking.
48
+ - `example/vega` — a Vega app on a Tears of Steel excerpt, run on the Vega
49
+ Virtual Device.
50
+
51
+ [Unreleased]: https://github.com/CesarRivasP/react-native-tv-audio-description/compare/v0.1.0...HEAD
52
+ [0.1.0]: https://github.com/CesarRivasP/react-native-tv-audio-description/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 César Rivas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/PLATFORM.md ADDED
@@ -0,0 +1,262 @@
1
+ # Platform findings — Vega OS media
2
+
3
+ What this library works around, and how each thing was found. Every entry was
4
+ **measured**, most of them on the way to shipping
5
+ [Interstice](#where-these-come-from) on the Vega Virtual Device; none of it was
6
+ in the platform documentation at the time.
7
+
8
+ **Stack measured against:** Vega SDK 0.24 (0.24.12112), Vega CLI 1.3.4,
9
+ `@amazon-devices/react-native-w3cmedia` 2.3.2, React Native 0.83, the **Vega
10
+ Virtual Device**. Nothing here has been re-measured on a physical Fire TV
11
+ yet; the entries that can only be settled there say so.
12
+
13
+ Each entry: what happens, how it was measured, what it costs you if you do not
14
+ know, and where this library handles it.
15
+
16
+ ---
17
+
18
+ ## The six that shaped the design
19
+
20
+ ### url_mode_broken
21
+
22
+ **Assigning a URL to `player.src` fails before a single byte is requested.**
23
+
24
+ `MEDIA_ERR_SRC_NOT_SUPPORTED` (code 4), with an empty native message and no
25
+ media log line, for a remote URL, for a packaged `file://` path, and for
26
+ `AudioPlayer` as well as `VideoPlayer`. The HTTP server serving the file logs
27
+ no request at all. `canPlayType()` answers `"probably"` for the same type it
28
+ then refuses, so format negotiation is not where it stops. Same device, same
29
+ session, same footage: `.src` fails, `MediaSource` plays.
30
+
31
+ - **Measured:** 2026-09-25. Reproduced independently by another developer on
32
+ the same stack, in the
33
+ [Amazon developer forum](https://community.amazondeveloper.com/t/audioplayer-url-mode-fails-with-media-err-src-not-supported-on-vvd-app-never-connects-to-com-amazon-media-server-both-official-media-samples-also-fail/29117).
34
+ - **Cost of not knowing:** about four days and six rounds of work, most of it
35
+ spent on hypotheses about codecs, containers and paths that the error message
36
+ invites and that are all wrong.
37
+ - **Here:** every byte is fetched in JavaScript and appended to a
38
+ `MediaSource` attached through `srcObject`, for the film and for each
39
+ description clip (`src/vega/index.tsx`, `src/vega/SegmentBuffer.ts`). The
40
+ `MediaAdapter` interface says no implementation may assume the platform
41
+ fetches anything. A test fails if the adapter ever assigns `src`.
42
+
43
+ ### no_range_requests
44
+
45
+ **A `Range` request returns `200` and the whole file.**
46
+
47
+ A `fetch` of a packaged file with `Range: bytes=0-65535` came back with status
48
+ `200`, **2,628,566 bytes** — the entire file — and no `Content-Range` header.
49
+
50
+ - **Measured:** 2026-09-26, on the Virtual Device, against a packaged
51
+ `file:///pkg/...` path.
52
+ - **Cost of not knowing:** a buffer window built on byte ranges *appears to
53
+ work* — playback is fine — while every "chunk" is the whole film. On a
54
+ 32-bit Fire TV Stick that is the memory bound failing silently, in the one
55
+ place nobody is looking.
56
+ - **Here:** the asset is cut into whole segments at build time, and
57
+ `SegmentBuffer` keeps a window of whole segments measured in **time, not in
58
+ count**: ffmpeg cuts on keyframes, so segments asked for at 6 s came out
59
+ 9.94 s, 4.17 s, 5.29 s and 0.65 s, and "keep five segments" would mean
60
+ anything between 3 and 50 seconds of media.
61
+
62
+ ### surface_races_init
63
+
64
+ **`initialize()` and the surface handle arrive in no guaranteed order.**
65
+
66
+ The video surface was handed over **26 ms before** `VideoPlayer.initialize()`
67
+ resolved. Calling `play()` from the surface callback alone plays a player with
68
+ no source yet and yields `MEDIA_ERR_SRC_NOT_SUPPORTED` — which reads exactly
69
+ like an unsupported file, and is not one.
70
+
71
+ - **Measured:** 2026-09-22.
72
+ - **Here:** the adapter tracks both signals; whichever lands second starts
73
+ playback. The surface is part of the adapter (`MediaAdapter.VideoSurface`)
74
+ precisely so a screen cannot wire it up on its own. Tests cover both orders.
75
+
76
+ ### waiting_fires_at_start
77
+
78
+ **MSE emits `waiting` during normal startup.**
79
+
80
+ 2 ms after `play()` resolved, on a clip that then played to the end.
81
+
82
+ - **Measured:** 2026-09-25. No fake emits a spurious `waiting`, so no unit test
83
+ could have found this; it took running on the device.
84
+ - **Cost of not knowing:** a screen that treats a stall as terminal announces
85
+ "Buffering" over a film that is playing perfectly — to a viewer who cannot
86
+ see that it is.
87
+ - **Here:** `onStalled` is a state playback can *leave*: the interface exposes
88
+ `onPlaying` as its exit and says so.
89
+
90
+ ### no_volume_ramp
91
+
92
+ **The volume setter is instantaneous. There is no fade anywhere in the API.**
93
+
94
+ - **Measured:** 2026-09-22, from the package's own type declarations: no
95
+ ramp parameter exists anywhere in the media surface.
96
+ - **Cost of not knowing:** a duck that jumps from 100% to 25% in a single
97
+ step on every cue, because the platform will not fade it for you.
98
+ - **Here:** the fade is stepped in JavaScript, once, in `src/duck.ts`, and is
99
+ cancellable so it cannot outlive the screen that started it.
100
+ `VideoPlayer.setVolumePct` deliberately takes no `rampMs`: accepting one
101
+ would invite every platform implementation to rewrite the same loop, and
102
+ imply a capability no platform here has.
103
+
104
+ ### no_js_console
105
+
106
+ **There is no readable JavaScript console on the device.**
107
+
108
+ `console.log` reaches `vega device start-log-stream` in neither Release nor
109
+ Debug builds, and since React Native 0.73 Metro prints "JavaScript logs have
110
+ moved" and routes them to React Native DevTools, which needs a browser. There
111
+ is also no way to take a screenshot.
112
+
113
+ - **Measured:** 2026-09-22.
114
+ - **What works:** an HTTP beacon to the development host, over the reverse
115
+ port forwarding the device already uses for Metro
116
+ (`vega device start-port-forwarding --port 8099 --forward false`), with a
117
+ tiny server on the host printing each request.
118
+ - **Here:** the library writes nothing by default; `setLogger()` hands you
119
+ every diagnostic line, and where they go is your app's decision.
120
+
121
+ ---
122
+
123
+ ## Found by running this package on the Virtual Device
124
+
125
+ Measured 2026-09-27 with `example/vega`, on the stack above. None of these
126
+ shows up in a unit test against a mock — each needed the device.
127
+
128
+ ### sourceopen_refires
129
+
130
+ **`sourceopen` fires again after `endOfStream()`, whenever the buffer changes.**
131
+
132
+ This one is the MSE specification, not a Vega defect: `appendBuffer()` or
133
+ `remove()` on a source whose `readyState` is `ended` moves it back to `open`
134
+ and fires `sourceopen` again. Evicting old media behind the playhead once the
135
+ last segment is in does exactly that. A `sourceopen` handler that builds the
136
+ `SourceBuffer` therefore runs twice. On the device that built a second buffer,
137
+ re-appended the whole asset, evicted again, reopened again — a loop at the end
138
+ of every film, with `endOfStream()` throwing *"exception when updating
139
+ attribute is true"* as the two chains collided.
140
+
141
+ - **Here:** the adapter handles `sourceopen` once per `open()`, and
142
+ `endOfStream()` is only called when no buffer operation is in flight.
143
+
144
+ ### coarse_timers
145
+
146
+ **`setTimeout(fn, 16)` fires late.** A fade written as 13 steps of 16 ms took
147
+ about **510 ms** instead of 200. With the fade down, the clip start and the
148
+ fade back up all stretched, three of four cues brought the film back up after
149
+ their window had closed — by up to 0.57 s.
150
+
151
+ - **Here:** `rampVolumePct` sets each step's level from the time *elapsed*,
152
+ not from its step number, so the fade ends on time however late the timer
153
+ fires. Measured after the change: fades of 200–260 ms.
154
+
155
+ ### paused_never_ends
156
+
157
+ **A paused `AudioPlayer` never emits `ended`.** Stopping a cue by pausing its
158
+ clip player left the promise waiting for `ended` pending forever — so the
159
+ player was never torn down: one leaked player per interrupted cue.
160
+
161
+ - **Here:** `clips.stop()` settles the clip's promise itself, and teardown
162
+ (`deinitialize()`, ~120 ms on the device) runs without holding up the
163
+ restore of the film.
164
+
165
+ ### What a cue costs, end to end
166
+
167
+ From the scheduler firing a cue to the film being back at full, on the
168
+ Virtual Device with the changes above: **fade down + clip start 250–290 ms**,
169
+ then the speech, then **fade up 200–260 ms**. `CueScheduler` will not fire a
170
+ cue that its window cannot hold — reached late, say, because description was
171
+ switched back on mid-gap — and skips it with a `reason=late` log line instead.
172
+
173
+ ---
174
+
175
+ ## Also measured, and worth knowing
176
+
177
+ ### concurrent_streams — a second stream over the film works
178
+
179
+ A `VideoPlayer` fed by one `MediaSource` and an `AudioPlayer` built with
180
+ `(CONTENT_TYPE_SPEECH, USAGE_ACCESSIBILITY)` fed by another played
181
+ **simultaneously**: the cue ran its full 4.50 s while the video reported
182
+ `paused=false` and zero dropped frames. Ducking the film to `volume = 0.25`
183
+ disturbed neither. This is what makes the library possible.
184
+ **Not measured:** that a listener *hears* it — there is no audio capture path
185
+ off the Virtual Device. What is measured is that both pipelines ran and the
186
+ volume was applied. Audible ducking needs a physical Fire TV.
187
+
188
+ ### mse_path — fragmented MP4, full codec string
189
+
190
+ The media must be **fragmented MP4**
191
+ (`ffmpeg -movflags +frag_keyframe+empty_moov+default_base_moof`), and
192
+ `addSourceBuffer` rejects a bare `video/mp4`: it needs the full parameters,
193
+ e.g. `video/mp4; codecs="avc1.42C01E,mp4a.40.2"`. Pass yours as
194
+ `createVegaAdapter({ videoMime })`. An MP3 cannot be appended to a
195
+ `SourceBuffer` at all, so description clips need repackaging as fragmented
196
+ AAC.
197
+
198
+ ### media_process_is_separate — what JS can reach, the player may not
199
+
200
+ JavaScript fetched `http://localhost:8100/clip.mp4` over the reverse port
201
+ forward and got `200`; the player rejected the same URL and the server logged
202
+ no request from it. The media pipeline runs in its own process
203
+ (`com.amazon.media.server`). Host-localhost forwarding works for the app and
204
+ not for playback, and the two look identical from JavaScript. (With
205
+ `url_mode_broken` this is moot for this library — JS does all the fetching —
206
+ but it will bite anything that hands the player a URL.)
207
+
208
+ ### manifest services — missing ones fail silently
209
+
210
+ Media services must be declared under `[wants]` in `manifest.toml`. Missing,
211
+ the result is no video **and no error**.
212
+
213
+ ### video_width_unreported — `videoWidth` stays 0
214
+
215
+ `videoWidth` and `videoHeight` stay 0 through `loadedmetadata`, `playing` and
216
+ beyond, and the platform's `resize` event carries `w=0 h=0` — while 253 frames
217
+ decode with 0 dropped. Do not gate "is video playing" on the width; use
218
+ `getVideoPlaybackQuality().totalVideoFrames`.
219
+
220
+ ### voiceview_not_enablable — screen reader testing needs hardware
221
+
222
+ VoiceView cannot be turned on on the Virtual Device by any developer route:
223
+ the config key is readable but `vdcm set` returns "No permission for
224
+ operation" from both `vega device run-cmd` and `vega device shell`. Another
225
+ developer on the same platform reports the other two documented routes failing
226
+ too: the Virtual Device's Settings app has no Accessibility section, and the
227
+ Back+Menu chord does not fire. So `ADControls`' accessibility contract is unit-tested by
228
+ what it **announces**, and has not been exercised under VoiceView itself.
229
+
230
+ ### run_cmd_is_sandboxed — "does not exist" means nothing
231
+
232
+ `vega device run-cmd` runs as an app user inside an app context: `ps` lists two
233
+ processes and `/dev/input` "does not exist" while input injection through the
234
+ same shell works. **Any probe run from there that reports a resource absent has
235
+ reported nothing.** Key injection that does work:
236
+ `vega device run-cmd -c 'inputd-cli button_press KEY_ENTER'`.
237
+
238
+ ### build_packages_assets_dir — `assets/` ships, all of it
239
+
240
+ `react-native build-vega` copies the entire project-root `assets/` directory
241
+ into the package whether or not anything references it. A 372 MB source video
242
+ left there made a 497 MB package, with nothing in the build output to say so.
243
+ Keep host-side media somewhere else.
244
+
245
+ ### Kepler types and `noUncheckedIndexedAccess`
246
+
247
+ `@amazon-devices/react-native-kepler` ships `.ts` sources behind its type
248
+ entry point, so `skipLibCheck` does not cover them, and
249
+ `KeplerBackHandler.ts` fails with TS2722 under `noUncheckedIndexedAccess`. A
250
+ project that imports the w3cmedia types with that flag on cannot type-check.
251
+ This library leaves the flag off for that reason.
252
+
253
+ ---
254
+
255
+ ## Where these come from
256
+
257
+ This library was extracted from **Interstice**, an AI audio description app
258
+ for Fire TV built for the Amazon Developer Hackathon 2026. Its specification
259
+ keeps every one of these as a measured entry with date and evidence, and the
260
+ dates above are those measurements. If you re-measure any of them on another
261
+ SDK version or on hardware and get a different answer, an issue saying so is
262
+ the most useful contribution this file can get.
package/README.md ADDED
@@ -0,0 +1,324 @@
1
+ # react-native-tv-audio-description
2
+
3
+ Speak audio description in the gaps between dialogue, **on the TV itself**:
4
+ lower the film, say what is on screen, bring the film back — before anyone
5
+ speaks again. Driven from the remote, with three verbosity levels, and every
6
+ state announced to the screen reader.
7
+
8
+ For blind and low-vision viewers most films have no audio description track
9
+ at all. Tracks can now be generated — subtitles give the dialogue gaps, a
10
+ vision model describes the frames, text-to-speech voices it. What was missing
11
+ is the part that plays such a track **on a television**, where the media stack
12
+ is not a browser's and a remote is the only input. That is this library.
13
+
14
+ It was extracted from **Interstice**, an entry to the Amazon Developer
15
+ Hackathon 2026 (Fire TV, Vega OS), and ships a **Vega OS adapter** along with
16
+ the [platform findings](PLATFORM.md) it took to make one work.
17
+
18
+ ```sh
19
+ git clone https://github.com/CesarRivasP/react-native-tv-audio-description.git
20
+ cd react-native-tv-audio-description
21
+ npm install
22
+ npm run example
23
+ ```
24
+
25
+ `npm run example` plays a minute of film at 20x in your terminal — no device,
26
+ no SDK — through the library's real scheduler, audio layer and track loader,
27
+ and **checks what it shows**: it exits non-zero if a cue fires outside its
28
+ gap, is still speaking when dialogue resumes, or leaves the film ducked.
29
+
30
+ ---
31
+
32
+ ## What it does
33
+
34
+ ```
35
+ dialogue ██████████░░░░░░░░░░░░░░░░████████████░░░░░░░░░░░██████
36
+ gaps [ cue c1 ) [ cue c2 )
37
+ film volume 100% ─╮ ╭─ 100% ───╮ ╭─ 100%
38
+ ╰──── 25% ───────╯ ╰── 25% ────╯
39
+ description "A keeper climbs the stairs…" "Waves. An empty boat…"
40
+ ```
41
+
42
+ - **Fires a cue only inside its own gap, and only if it can finish there.** A
43
+ cue whose gap has passed, or whose gap has too little left — description
44
+ switched back on mid-gap — is skipped, never played late: a late cue talks
45
+ over dialogue, which is worse than no description.
46
+ - **Ducks, speaks, restores** — the film goes to 25% over a 200 ms fade, the
47
+ clip plays as a second stream over it, and the film always comes back to
48
+ 100%, including when the clip fails and when the app is backgrounded.
49
+ - **Seeks cleanly.** A held D-pad direction is collapsed into one settled
50
+ seek, and the scheduler resyncs once, so jumping through a film does not
51
+ fire a burst of descriptions for scenes the viewer skipped.
52
+ - **Three verbosity levels**, one track file each, falling back to `standard`
53
+ when a level was never generated.
54
+ - **Refuses to be silent.** A missing or malformed track produces a spoken
55
+ sentence — "Playback continues without description" — not a quiet screen
56
+ that a blind viewer cannot tell from a quiet scene.
57
+
58
+ ## Install
59
+
60
+ Not on npm yet — `0.1.0` is on its way. Until then, from GitHub:
61
+
62
+ ```sh
63
+ npm install github:CesarRivasP/react-native-tv-audio-description
64
+ ```
65
+
66
+ Peer dependencies: `react` ^19.2 and `react-native` ^0.83. For the Vega
67
+ adapter, also `@amazon-devices/react-native-w3cmedia ~2.3.2` and
68
+ `@amazon-devices/react-native-kepler ~4.0.0` — pin those with `~`, not `^`:
69
+ they are system-distributed libraries, and the Kepler linter fails the build
70
+ when they float.
71
+
72
+ ## Usage
73
+
74
+ On Vega OS, a player screen:
75
+
76
+ ```tsx
77
+ import React, { useEffect, useMemo, useState } from 'react';
78
+ import { View } from 'react-native';
79
+ import {
80
+ ADControls,
81
+ CueScheduler,
82
+ DescriptionAudio,
83
+ loadTrack,
84
+ type ADState,
85
+ type AssetSource,
86
+ type Verbosity,
87
+ } from 'react-native-tv-audio-description';
88
+ import { createVegaAdapter } from 'react-native-tv-audio-description/vega';
89
+
90
+ // Vega's player cannot be pointed at a path, but fetch can read a packaged file.
91
+ const fetchJson = async (path: string): Promise<unknown> => (await fetch(path)).json();
92
+
93
+ interface PlayerProps {
94
+ asset: AssetSource; // the film, cut into fragmented-MP4 segments
95
+ trackDir: string; // where <assetId>.<verbosity>.track.json live
96
+ assetId: string;
97
+ readJson?: (path: string) => Promise<unknown>; // how to read a track file
98
+ }
99
+
100
+ export function Player({ asset, trackDir, assetId, readJson = fetchJson }: PlayerProps) {
101
+ // One adapter, one audio layer, one scheduler per screen.
102
+ const media = useMemo(() => createVegaAdapter(), []);
103
+ const audio = useMemo(() => new DescriptionAudio(media), [media]);
104
+ const scheduler = useMemo(
105
+ () => new CueScheduler({ onFire: (cue) => void audio.speak(cue) }),
106
+ [audio],
107
+ );
108
+
109
+ const [verbosity, setVerbosity] = useState<Verbosity>('standard');
110
+ const [enabled, setEnabled] = useState(true);
111
+ const [state, setState] = useState<ADState>({ kind: 'missing', detail: 'loading' });
112
+
113
+ // Position drives the scheduler; a settled seek resyncs it.
114
+ useEffect(() => {
115
+ const offs = [
116
+ media.video.onPosition((ms) => scheduler.tick(ms)),
117
+ media.video.onSeek((ms) => scheduler.resync(ms)),
118
+ ];
119
+ void media.video.open(asset).then(() => media.video.play());
120
+ return () => {
121
+ offs.forEach((off) => off());
122
+ void audio.stop();
123
+ void media.video.destroy();
124
+ };
125
+ }, [media, audio, scheduler, asset]);
126
+
127
+ // A level switch loads that level's file, falling back to standard.
128
+ useEffect(() => {
129
+ void loadTrack(readJson, trackDir, assetId, verbosity).then((result) => {
130
+ if (!result.ok) {
131
+ scheduler.load([]);
132
+ setState({ kind: result.reason, detail: result.detail });
133
+ return;
134
+ }
135
+ scheduler.load(result.track.cues);
136
+ scheduler.resync(media.video.positionMs());
137
+ setState({
138
+ kind: 'ready',
139
+ enabled,
140
+ verbosity: result.loaded_verbosity,
141
+ cues: result.track.cues.length,
142
+ });
143
+ });
144
+ // `enabled` is applied separately below; reloading on a toggle is not needed
145
+ }, [media, scheduler, trackDir, assetId, verbosity, readJson]);
146
+
147
+ const onToggle = (on: boolean) => {
148
+ setEnabled(on);
149
+ scheduler.setEnabled(on); // the film is never touched by a toggle
150
+ if (!on) void audio.stop(); // cuts a cue mid-sentence and restores the film
151
+ setState((s) => (s.kind === 'ready' ? { ...s, enabled: on } : s));
152
+ };
153
+
154
+ return (
155
+ <View style={{ flex: 1 }}>
156
+ <media.VideoSurface style={{ flex: 1 }} />
157
+ <ADControls state={state} onToggle={onToggle} onVerbosity={setVerbosity} />
158
+ </View>
159
+ );
160
+ }
161
+ ```
162
+
163
+ This is [`example/vega/src/Player.tsx`](example/vega/src/Player.tsx), which
164
+ `npm run typecheck` compiles against the library — if the API changes, this
165
+ README's example stops compiling. A real screen will also want to say
166
+ something on `media.video.onStalled` (and stop saying it on `onPlaying` —
167
+ see [`waiting_fires_at_start`](PLATFORM.md#waiting_fires_at_start)).
168
+
169
+ ## The track
170
+
171
+ One JSON file per verbosity level, named `<asset_id>.<verbosity>.track.json`:
172
+
173
+ ```json
174
+ {
175
+ "version": "1",
176
+ "asset_id": "lighthouse",
177
+ "generated_at": "2026-09-27T00:00:00.000Z",
178
+ "source_subtitles": "lighthouse.en.srt",
179
+ "verbosity": "standard",
180
+ "model_id": "…",
181
+ "cues": [
182
+ {
183
+ "id": "c1",
184
+ "start_ms": 2000,
185
+ "end_ms": 6500,
186
+ "words": 10,
187
+ "text": "A lighthouse keeper climbs the spiral stairs, lantern in hand.",
188
+ "audio_uri": "audio/c1.standard.m4a",
189
+ "source_frames_ms": [4250],
190
+ "status": "ok"
191
+ }
192
+ ]
193
+ }
194
+ ```
195
+
196
+ - `start_ms`/`end_ms` is the dialogue gap the cue may speak in; `end_ms` is
197
+ exclusive.
198
+ - A cue that could not be produced is **written** with `"status": "failed"`
199
+ and empty `text` and `audio_uri`, not dropped. It is never scheduled, but the
200
+ track still says the gap was considered.
201
+ - The levels do **not** share a cue list: a gap that holds a useful sentence at
202
+ `detailed` may hold nothing useful at `concise`. In the film this was built
203
+ on, the three levels came out at 47, 55 and 60 cues.
204
+ - `validateTrack` checks every field; `loadTrack` treats a missing file and a
205
+ malformed one differently — missing falls back to `standard`, malformed does
206
+ not, because silently loading a different file would hide a broken producer.
207
+
208
+ Full examples in [`example/assets/`](example/assets/).
209
+
210
+ ### Producing tracks: the word budget
211
+
212
+ A cue has to finish before dialogue resumes. At 160 words per minute, with
213
+ 300 ms kept back for the fade:
214
+
215
+ ```ts
216
+ import { wordCeiling, wordTarget } from 'react-native-tv-audio-description';
217
+
218
+ wordCeiling(4500); // 11 — never exceed this, at any level
219
+ wordTarget(4500, 'concise'); // 6 — what to ask a describer for
220
+ wordTarget(4500, 'detailed'); // 11
221
+ ```
222
+
223
+ Levels are fractions of the ceiling (0.6 / 0.85 / 1.0), never multipliers, so
224
+ no level can ask for more words than the gap holds. `npm run example` checks
225
+ its own track against this before playing it.
226
+
227
+ ## The adapter
228
+
229
+ Everything above is written against one interface,
230
+ [`MediaAdapter`](src/MediaAdapter.ts), and never against a platform package —
231
+ a test fails if that stops being true. Two rules in it matter if you write
232
+ your own:
233
+
234
+ - **Do not assume the platform fetches anything.** On Vega it will not, so
235
+ the Vega adapter reads every byte itself. The interface takes an
236
+ `AssetSource` — a list of segments, even for a single file — so a short clip
237
+ and a feature film run the same code path.
238
+ - **`setVolumePct` is immediate and takes no ramp.** The fade lives in the
239
+ library, once. An interface that took `rampMs` would make every platform
240
+ rewrite it.
241
+
242
+ ### `createVegaAdapter(options?)`
243
+
244
+ | option | default | |
245
+ |---|---|---|
246
+ | `videoMime` | `video/mp4; codecs="avc1.42C01E,mp4a.40.2"` | **Set this to your asset's codecs.** A bare `video/mp4` is rejected. |
247
+ | `clipMime` | `audio/mp4; codecs="mp4a.40.2"` | Description clips, fragmented AAC. |
248
+ | `window` | `{ aheadMs: 30000, behindMs: 10000 }` | Media held around the playhead, in time. |
249
+
250
+ It handles, so you do not have to: `srcObject` instead of `src`, the surface
251
+ arriving before the player is ready, a buffer window of whole segments that
252
+ never holds the whole film, one `SourceBuffer` operation at a time, and clips
253
+ on an `AudioPlayer` built for accessibility speech. Each is explained in
254
+ [PLATFORM.md](PLATFORM.md).
255
+
256
+ Another platform: implement `MediaAdapter` (the simulated one in
257
+ [`example/node/simAdapter.ts`](example/node/simAdapter.ts) is a complete,
258
+ small example) and everything else works unchanged.
259
+
260
+ ## Seeing what it does on a device
261
+
262
+ Nothing is logged by default. On Vega, `console.log` goes nowhere you can read
263
+ ([`no_js_console`](PLATFORM.md#no_js_console)), so the library hands every
264
+ diagnostic line to you instead:
265
+
266
+ ```ts
267
+ import { setLogger } from 'react-native-tv-audio-description';
268
+
269
+ setLogger((line) => {
270
+ // e.g. "scheduler.fire id=c1 pos_ms=2004 window=[2000,6500)"
271
+ void fetch(`http://localhost:8099/?m=${encodeURIComponent(line)}`).catch(() => {});
272
+ });
273
+ ```
274
+
275
+ ## Platform findings
276
+
277
+ What the Vega adapter works around, each one measured — the short version:
278
+
279
+ | | what happens | cost if you do not know |
280
+ |---|---|---|
281
+ | [`url_mode_broken`](PLATFORM.md#url_mode_broken) | `player.src = url` fails before a byte is requested | days, chasing codecs that are fine |
282
+ | [`no_range_requests`](PLATFORM.md#no_range_requests) | a `Range` request returns `200` and the whole file | a "bounded" buffer that holds the entire film |
283
+ | [`surface_races_init`](PLATFORM.md#surface_races_init) | the surface can arrive before `initialize()` resolves | an error that reads as "unsupported file" |
284
+ | [`waiting_fires_at_start`](PLATFORM.md#waiting_fires_at_start) | MSE emits `waiting` 2 ms into normal playback | "Buffering" announced over a film that plays |
285
+ | [`no_volume_ramp`](PLATFORM.md#no_volume_ramp) | volume changes are instantaneous, no fade exists | a duck that jumps instead of fading |
286
+ | [`no_js_console`](PLATFORM.md#no_js_console) | no readable JS console, in Release or Debug | an app you cannot observe |
287
+
288
+ And a dozen smaller ones — fragmented MP4 only, a separate media process,
289
+ silent manifest failures, `videoWidth` stuck at 0, VoiceView unavailable on the
290
+ Virtual Device — in [PLATFORM.md](PLATFORM.md).
291
+
292
+ ## What is verified, and what is not
293
+
294
+ - **Unit tests** covering every component and the adapter against a
295
+ mock of the platform package. `npm test`.
296
+ - **The example** — end to end on a simulated adapter, self-checking.
297
+ - **On the Vega Virtual Device**, with [`example/vega`](example/vega): the
298
+ packaged library plays a segmented excerpt of Tears of Steel with four
299
+ cues, each firing inside its window, ducking to 25% and restoring before the
300
+ window closes; the remote switches description off mid-cue, on again, and
301
+ to `concise`. What those runs found — and fixed — is in
302
+ [PLATFORM.md § Found by running this package](PLATFORM.md#sourceopen_refires).
303
+ - **Not yet verified, and needing a physical Fire TV:** that the duck is
304
+ *audible* (the Virtual Device has no audio capture path), the controls under
305
+ VoiceView (it cannot be enabled on the Virtual Device), and the memory bound
306
+ over a feature-length film (eviction has run once, at the end of the 20 s
307
+ excerpt — not across a film long enough for the bound to matter).
308
+
309
+ ## Versions
310
+
311
+ [Semantic Versioning](https://semver.org), recorded in
312
+ [CHANGELOG.md](CHANGELOG.md). While the version is `0.x`, a minor release may
313
+ change the API and says so under **Changed**; a patch release never does.
314
+ Every published version is a tagged commit (`v0.1.0` …): `npm publish` refuses
315
+ to run unless HEAD carries the tag, the tree is clean, the changelog has the
316
+ version's section, and the typecheck, tests and build pass.
317
+
318
+ To release: move the **Unreleased** notes under a new dated heading, then
319
+ `npm version <major|minor|patch>` (which checks the changelog, commits and
320
+ tags), `git push --follow-tags`, `npm publish`.
321
+
322
+ ## License
323
+
324
+ MIT © César Rivas
@@ -0,0 +1,24 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import type { Verbosity } from './track';
4
+ import { type ADState } from './messages';
5
+ export { stateMessage, type ADState } from './messages';
6
+ /**
7
+ * The remote surface, and every failure said out loud.
8
+ *
9
+ * This is for blind and low-vision viewers. A control surface that shows an
10
+ * error and says nothing is the same as silence, so every assertion in its
11
+ * test suite is about what is ANNOUNCED, not about what is rendered.
12
+ *
13
+ * It is deliberately unstyled: bring your own look. What it guarantees is the
14
+ * accessibility contract — roles, labels, state, and one announcement per
15
+ * state change.
16
+ */
17
+ export interface ADControlsProps {
18
+ state: ADState;
19
+ onToggle: (enabled: boolean) => void;
20
+ onVerbosity: (v: Verbosity) => void;
21
+ /** hand focus here when the player screen mounts */
22
+ focusRef?: React.Ref<View>;
23
+ }
24
+ export declare function ADControls({ state, onToggle, onVerbosity, focusRef }: ADControlsProps): React.JSX.Element;