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,195 @@
|
|
|
1
|
+
import type { AssetSegment, AssetSource } from '../MediaAdapter';
|
|
2
|
+
import { log } from '../log';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Keep a bounded window of the asset in the SourceBuffer, and never the whole
|
|
6
|
+
* thing.
|
|
7
|
+
*
|
|
8
|
+
* Two platform facts shape this and neither was guessable (PLATFORM.md):
|
|
9
|
+
* - `url_mode_broken` — the player fetches nothing, so the app delivers
|
|
10
|
+
* every byte;
|
|
11
|
+
* - `no_range_requests` — a Range request returns the WHOLE file with status
|
|
12
|
+
* 200, so the app cannot read part of one. The window is therefore made of
|
|
13
|
+
* whole segments cut at build time. A window built on byte ranges would
|
|
14
|
+
* appear to work and hold the entire film in memory.
|
|
15
|
+
*
|
|
16
|
+
* The window is measured in TIME, never in segment count. ffmpeg cuts on
|
|
17
|
+
* keyframes, so a clip asked for 6 s segments came out 9.94 s, 4.17 s, 5.29 s
|
|
18
|
+
* and 0.65 s — "keep five segments" would mean anything between 3 and 50
|
|
19
|
+
* seconds of media, which is not a memory bound.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Only what this class actually uses, declared structurally.
|
|
24
|
+
*
|
|
25
|
+
* `MediaSource.addSourceBuffer` is typed as returning the W3C `SourceBuffer`
|
|
26
|
+
* interface, which does not declare `addEventListener` — but the
|
|
27
|
+
* implementation behind it does, and it was measured to work on the device.
|
|
28
|
+
* Naming the five members this class touches says what it needs and fails
|
|
29
|
+
* loudly if any of them disappears.
|
|
30
|
+
*/
|
|
31
|
+
export interface AppendTarget {
|
|
32
|
+
readonly updating: boolean;
|
|
33
|
+
appendBuffer(data: Uint8Array): void;
|
|
34
|
+
remove(start: number, end: number): void;
|
|
35
|
+
addEventListener(type: string, listener: () => void): void;
|
|
36
|
+
removeEventListener(type: string, listener: () => void): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BufferWindow {
|
|
40
|
+
/** media kept ahead of the playhead (default 30 s) */
|
|
41
|
+
aheadMs: number;
|
|
42
|
+
/** media kept behind it before eviction (default 10 s) */
|
|
43
|
+
behindMs: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_WINDOW: BufferWindow = { aheadMs: 30_000, behindMs: 10_000 };
|
|
47
|
+
|
|
48
|
+
/** the two members of MediaSource this class touches, for the same reason */
|
|
49
|
+
export interface SourceLike {
|
|
50
|
+
readonly readyState: string;
|
|
51
|
+
endOfStream(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class SegmentBuffer {
|
|
55
|
+
private appended = new Set<number>();
|
|
56
|
+
private queue: Promise<void> = Promise.resolve();
|
|
57
|
+
private disposed = false;
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
private readonly source: SourceLike,
|
|
61
|
+
private readonly buffer: AppendTarget,
|
|
62
|
+
private readonly asset: AssetSource,
|
|
63
|
+
private readonly window: BufferWindow = DEFAULT_WINDOW,
|
|
64
|
+
) {}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Append the init segment, then enough of the asset to start playing.
|
|
68
|
+
*
|
|
69
|
+
* Both steps are queued NOW, behind anything already queued, so a caller
|
|
70
|
+
* that feeds positions before `start()` settles can neither overlap two
|
|
71
|
+
* SourceBuffer operations nor have position 0 processed after a later one.
|
|
72
|
+
* Unlike a segment, a failed init REJECTS: without it nothing after it can
|
|
73
|
+
* decode, and the caller has to say so.
|
|
74
|
+
*/
|
|
75
|
+
async start(): Promise<void> {
|
|
76
|
+
const initUri = this.asset.initUri;
|
|
77
|
+
let init: Promise<void> = Promise.resolve();
|
|
78
|
+
if (initUri) {
|
|
79
|
+
init = this.queue.then(async () => {
|
|
80
|
+
await this.append(await this.fetchBytes(initUri), 'init');
|
|
81
|
+
});
|
|
82
|
+
this.queue = init.catch(() => undefined);
|
|
83
|
+
}
|
|
84
|
+
const first = this.ensure(0);
|
|
85
|
+
await init;
|
|
86
|
+
await first;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Bring the window up to date for a playhead position.
|
|
91
|
+
*
|
|
92
|
+
* Safe to call on every `timeupdate`: segments already appended are
|
|
93
|
+
* skipped, and the work is serialised behind one promise chain because a
|
|
94
|
+
* SourceBuffer accepts exactly one operation at a time. Calling
|
|
95
|
+
* `appendBuffer` while `updating` is true throws `InvalidStateError`, which
|
|
96
|
+
* on Vega surfaces as a media error indistinguishable from a bad file.
|
|
97
|
+
*/
|
|
98
|
+
ensure(positionMs: number): Promise<void> {
|
|
99
|
+
this.queue = this.queue
|
|
100
|
+
.then(() => this.sync(positionMs))
|
|
101
|
+
.catch((err) => {
|
|
102
|
+
log(`buffer.failed err=${(err as Error).message}`);
|
|
103
|
+
});
|
|
104
|
+
return this.queue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** segment indexes currently held, for diagnostics and tests */
|
|
108
|
+
held(): number[] {
|
|
109
|
+
return [...this.appended].sort((a, b) => a - b);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async sync(positionMs: number): Promise<void> {
|
|
113
|
+
if (this.disposed) return;
|
|
114
|
+
|
|
115
|
+
for (const segment of this.wanted(positionMs)) {
|
|
116
|
+
if (this.appended.has(segment.index)) continue;
|
|
117
|
+
const bytes = await this.fetchBytes(segment.uri);
|
|
118
|
+
if (this.disposed) return;
|
|
119
|
+
await this.append(bytes, `seg${segment.index}`);
|
|
120
|
+
this.appended.add(segment.index);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await this.evict(positionMs);
|
|
124
|
+
|
|
125
|
+
// `endOfStream` only once the last segment is in, or the player waits
|
|
126
|
+
// forever for media that is not coming.
|
|
127
|
+
const last = this.asset.segments[this.asset.segments.length - 1];
|
|
128
|
+
// and never while the SourceBuffer is mid-operation: that throws, and the
|
|
129
|
+
// platform reports it the same way as a bad file
|
|
130
|
+
if (
|
|
131
|
+
last &&
|
|
132
|
+
this.appended.has(last.index) &&
|
|
133
|
+
this.source.readyState === 'open' &&
|
|
134
|
+
!this.buffer.updating
|
|
135
|
+
) {
|
|
136
|
+
this.source.endOfStream();
|
|
137
|
+
log(`buffer.complete segments=${this.appended.size}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** every segment overlapping [position, position + ahead] */
|
|
142
|
+
private wanted(positionMs: number): AssetSegment[] {
|
|
143
|
+
return this.asset.segments.filter(
|
|
144
|
+
(s) => s.end_ms > positionMs && s.start_ms <= positionMs + this.window.aheadMs,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async evict(positionMs: number): Promise<void> {
|
|
149
|
+
const cutoff = positionMs - this.window.behindMs;
|
|
150
|
+
if (cutoff <= 0) return;
|
|
151
|
+
|
|
152
|
+
const stale = this.asset.segments.filter(
|
|
153
|
+
(s) => this.appended.has(s.index) && s.end_ms < cutoff,
|
|
154
|
+
);
|
|
155
|
+
if (stale.length === 0) return;
|
|
156
|
+
|
|
157
|
+
const end = Math.max(...stale.map((s) => s.end_ms));
|
|
158
|
+
await this.operation(() => this.buffer.remove(0, end / 1000));
|
|
159
|
+
for (const s of stale) this.appended.delete(s.index);
|
|
160
|
+
|
|
161
|
+
log(`buffer.evicted n=${stale.length} up_to_ms=${end} held=${this.appended.size}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async fetchBytes(uri: string): Promise<Uint8Array> {
|
|
165
|
+
const response = await fetch(uri);
|
|
166
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async append(bytes: Uint8Array, label: string): Promise<void> {
|
|
170
|
+
await this.operation(() => this.buffer.appendBuffer(bytes));
|
|
171
|
+
log(`buffer.appended ${label} bytes=${bytes.byteLength} held=${this.appended.size}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** run one SourceBuffer operation and wait for `updateend` */
|
|
175
|
+
private operation(run: () => void): Promise<void> {
|
|
176
|
+
return new Promise<void>((resolve, reject) => {
|
|
177
|
+
const done = () => {
|
|
178
|
+
this.buffer.removeEventListener('updateend', done);
|
|
179
|
+
resolve();
|
|
180
|
+
};
|
|
181
|
+
this.buffer.addEventListener('updateend', done);
|
|
182
|
+
try {
|
|
183
|
+
run();
|
|
184
|
+
} catch (err) {
|
|
185
|
+
this.buffer.removeEventListener('updateend', done);
|
|
186
|
+
reject(err as Error);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
dispose(): void {
|
|
192
|
+
this.disposed = true;
|
|
193
|
+
this.appended.clear();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import React, { useCallback } from 'react';
|
|
2
|
+
import { AppState } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
AudioContentType,
|
|
5
|
+
AudioPlayer,
|
|
6
|
+
AudioUsageType,
|
|
7
|
+
KeplerVideoSurfaceView,
|
|
8
|
+
MediaSource,
|
|
9
|
+
VideoPlayer as VegaVideoPlayer,
|
|
10
|
+
} from '@amazon-devices/react-native-w3cmedia';
|
|
11
|
+
import type {
|
|
12
|
+
AppLifecycle,
|
|
13
|
+
AssetSource,
|
|
14
|
+
ClipPlayer,
|
|
15
|
+
MediaAdapter,
|
|
16
|
+
Unsubscribe,
|
|
17
|
+
VideoPlayer,
|
|
18
|
+
VideoSurfaceProps,
|
|
19
|
+
} from '../MediaAdapter';
|
|
20
|
+
import { DEFAULT_WINDOW, SegmentBuffer, type AppendTarget, type BufferWindow } from './SegmentBuffer';
|
|
21
|
+
import { log } from '../log';
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
SegmentBuffer,
|
|
25
|
+
DEFAULT_WINDOW,
|
|
26
|
+
type AppendTarget,
|
|
27
|
+
type BufferWindow,
|
|
28
|
+
type SourceLike,
|
|
29
|
+
} from './SegmentBuffer';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The Vega OS implementation of `MediaAdapter`.
|
|
33
|
+
*
|
|
34
|
+
* EXTRACTED from code that already ran on the Vega Virtual Device, not written
|
|
35
|
+
* from the interface down. Each of the following is a bug somebody already
|
|
36
|
+
* paid for (details in PLATFORM.md):
|
|
37
|
+
* - `srcObject`, never `src` — `url_mode_broken`: assigning a URL to `src`
|
|
38
|
+
* fails before a single byte is requested;
|
|
39
|
+
* - dual readiness tracking — `surface_races_init`: the surface can arrive
|
|
40
|
+
* before `initialize()` resolves, and playing before both have landed
|
|
41
|
+
* yields MEDIA_ERR_SRC_NOT_SUPPORTED, which reads exactly like an
|
|
42
|
+
* unsupported file and is not one;
|
|
43
|
+
* - fragmented MP4 through MediaSource only — `mse_path`;
|
|
44
|
+
* - clips on an AudioPlayer built for accessibility speech, as a second
|
|
45
|
+
* stream over the film — `concurrent_streams`.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export interface VegaAdapterOptions {
|
|
49
|
+
/**
|
|
50
|
+
* The MSE type of YOUR asset, with full codec parameters. A bare
|
|
51
|
+
* 'video/mp4' is rejected at addSourceBuffer. Default: H.264 Constrained
|
|
52
|
+
* Baseline L3.0 + AAC-LC. Read yours with
|
|
53
|
+
* `ffprobe -show_streams` and build the RFC 6381 string.
|
|
54
|
+
*/
|
|
55
|
+
videoMime?: string;
|
|
56
|
+
/** the MSE type of the description clips (default fragmented AAC-LC) */
|
|
57
|
+
clipMime?: string;
|
|
58
|
+
/** how much media to hold around the playhead */
|
|
59
|
+
window?: BufferWindow;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const DEFAULT_VIDEO_MIME = 'video/mp4; codecs="avc1.42C01E,mp4a.40.2"';
|
|
63
|
+
const DEFAULT_CLIP_MIME = 'audio/mp4; codecs="mp4a.40.2"';
|
|
64
|
+
|
|
65
|
+
type Listener = () => void;
|
|
66
|
+
|
|
67
|
+
/** whole-file append, for description CLIPS — they are seconds long, not films */
|
|
68
|
+
function fetchAndAppend(source: MediaSource, mime: string, uri: string): Promise<number> {
|
|
69
|
+
return new Promise<number>((resolve, reject) => {
|
|
70
|
+
source.addEventListener('sourceopen', () => {
|
|
71
|
+
(async () => {
|
|
72
|
+
try {
|
|
73
|
+
const buffer = source.addSourceBuffer(mime);
|
|
74
|
+
|
|
75
|
+
const response = await fetch(uri);
|
|
76
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
77
|
+
|
|
78
|
+
(buffer as unknown as AppendTarget).addEventListener('updateend', () => {
|
|
79
|
+
if (source.readyState !== 'open') return;
|
|
80
|
+
source.endOfStream();
|
|
81
|
+
resolve(bytes.byteLength);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
buffer.appendBuffer(bytes);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
reject(err as Error);
|
|
87
|
+
}
|
|
88
|
+
})().catch(reject);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
class VegaVideo implements VideoPlayer {
|
|
94
|
+
readonly player = new VegaVideoPlayer();
|
|
95
|
+
|
|
96
|
+
private ready = { player: false, surface: false };
|
|
97
|
+
private pendingPlay: (() => void) | null = null;
|
|
98
|
+
private listeners = new Map<string, Set<Listener>>();
|
|
99
|
+
private initialised: Promise<void> | null = null;
|
|
100
|
+
private lastError: Error | null = null;
|
|
101
|
+
private segments: SegmentBuffer | null = null;
|
|
102
|
+
|
|
103
|
+
constructor(
|
|
104
|
+
private readonly mime: string,
|
|
105
|
+
private readonly window: BufferWindow,
|
|
106
|
+
) {}
|
|
107
|
+
|
|
108
|
+
private async initialize(): Promise<void> {
|
|
109
|
+
if (!this.initialised) {
|
|
110
|
+
this.initialised = this.player.initialize().then(() => {
|
|
111
|
+
this.player.addEventListener('error', () => {
|
|
112
|
+
const code = this.player.error?.code ?? -1;
|
|
113
|
+
const msg = (this.player.error as { message?: string } | null)?.message ?? 'none';
|
|
114
|
+
log(`player.error code=${code} msg=${msg}`);
|
|
115
|
+
this.lastError = new Error(`media error ${code}: ${msg}`);
|
|
116
|
+
this.emit('error');
|
|
117
|
+
});
|
|
118
|
+
for (const type of ['timeupdate', 'seeked', 'waiting', 'stalled', 'playing', 'ended'] as const) {
|
|
119
|
+
this.player.addEventListener(type, () => this.emit(type));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return this.initialised;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private emit(type: string): void {
|
|
127
|
+
for (const cb of this.listeners.get(type) ?? []) cb();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private on(type: string, cb: Listener): Unsubscribe {
|
|
131
|
+
const set = this.listeners.get(type) ?? new Set<Listener>();
|
|
132
|
+
set.add(cb);
|
|
133
|
+
this.listeners.set(type, set);
|
|
134
|
+
return () => set.delete(cb);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** called by the surface component when the platform hands over a handle */
|
|
138
|
+
attachSurface(handle: string): void {
|
|
139
|
+
log(`player.surface created handle=${handle}`);
|
|
140
|
+
this.player.setSurfaceHandle(handle);
|
|
141
|
+
this.ready.surface = true;
|
|
142
|
+
this.release();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
detachSurface(handle: string): void {
|
|
146
|
+
log(`player.surface destroyed handle=${handle}`);
|
|
147
|
+
this.player.clearSurfaceHandle(handle);
|
|
148
|
+
this.ready.surface = false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** whichever of the two readiness signals lands second lets playback start */
|
|
152
|
+
private release(): void {
|
|
153
|
+
if (!this.ready.player || !this.ready.surface) return;
|
|
154
|
+
const go = this.pendingPlay;
|
|
155
|
+
this.pendingPlay = null;
|
|
156
|
+
go?.();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async open(asset: AssetSource): Promise<void> {
|
|
160
|
+
await this.initialize();
|
|
161
|
+
|
|
162
|
+
const source = new MediaSource();
|
|
163
|
+
|
|
164
|
+
const opened = new Promise<void>((resolve, reject) => {
|
|
165
|
+
let handled = false;
|
|
166
|
+
source.addEventListener('sourceopen', () => {
|
|
167
|
+
// ONCE. Per MSE, appendBuffer() or remove() on an 'ended' source
|
|
168
|
+
// reopens it and fires 'sourceopen' again — and evicting behind the
|
|
169
|
+
// playhead after the last segment is in does exactly that. Handling it
|
|
170
|
+
// twice builds a second SourceBuffer and re-appends the whole asset,
|
|
171
|
+
// which evicts, which reopens: a loop, measured on the Virtual Device
|
|
172
|
+
// at the end of a 20 s clip (PLATFORM.md `sourceopen_refires`).
|
|
173
|
+
if (handled) return;
|
|
174
|
+
handled = true;
|
|
175
|
+
(async () => {
|
|
176
|
+
const buffer = source.addSourceBuffer(this.mime);
|
|
177
|
+
this.segments = new SegmentBuffer(
|
|
178
|
+
source,
|
|
179
|
+
buffer as unknown as AppendTarget,
|
|
180
|
+
asset,
|
|
181
|
+
this.window,
|
|
182
|
+
);
|
|
183
|
+
await this.segments.start();
|
|
184
|
+
resolve();
|
|
185
|
+
})().catch(reject);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// srcObject, NOT src — see PLATFORM.md `url_mode_broken`.
|
|
190
|
+
this.player.srcObject = source;
|
|
191
|
+
|
|
192
|
+
await opened;
|
|
193
|
+
log(`player.opened segments=${asset.segments.length} init=${asset.initUri ? 'yes' : 'none'}`);
|
|
194
|
+
|
|
195
|
+
// Keep the window fed as the playhead moves. The bounded window is what
|
|
196
|
+
// stops a feature-length asset becoming a feature-length ArrayBuffer.
|
|
197
|
+
this.on('timeupdate', () => {
|
|
198
|
+
void this.segments?.ensure(this.positionMs());
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
this.ready.player = true;
|
|
202
|
+
this.release();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
play(): Promise<void> {
|
|
206
|
+
return new Promise<void>((resolve, reject) => {
|
|
207
|
+
const start = () => {
|
|
208
|
+
this.player
|
|
209
|
+
.play()
|
|
210
|
+
.then(() => {
|
|
211
|
+
log('player.play resolved');
|
|
212
|
+
resolve();
|
|
213
|
+
})
|
|
214
|
+
.catch(reject);
|
|
215
|
+
};
|
|
216
|
+
if (this.ready.player && this.ready.surface) start();
|
|
217
|
+
else this.pendingPlay = start; // the surface race, handled once, here
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
pause(): void {
|
|
222
|
+
this.player.pause();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
positionMs(): number {
|
|
226
|
+
// currentTime is NaN until the player has media; a NaN position would
|
|
227
|
+
// reach the scheduler as a seek to nowhere
|
|
228
|
+
const t = this.player.currentTime;
|
|
229
|
+
return Number.isFinite(t) ? Math.round(t * 1000) : 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
durationMs(): number {
|
|
233
|
+
const d = this.player.duration;
|
|
234
|
+
return Number.isFinite(d) ? Math.round(d * 1000) : 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
isPlaying(): boolean {
|
|
238
|
+
return !this.player.paused;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
setVolumePct(pct: number): void {
|
|
242
|
+
this.player.volume = Math.max(0, Math.min(100, pct)) / 100;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
onPosition(cb: (ms: number) => void): Unsubscribe {
|
|
246
|
+
return this.on('timeupdate', () => cb(this.positionMs()));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
onSeek(cb: (ms: number) => void): Unsubscribe {
|
|
250
|
+
return this.on('seeked', () => cb(this.positionMs()));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
onStalled(cb: () => void): Unsubscribe {
|
|
254
|
+
const offWaiting = this.on('waiting', cb);
|
|
255
|
+
const offStalled = this.on('stalled', cb);
|
|
256
|
+
return () => {
|
|
257
|
+
offWaiting();
|
|
258
|
+
offStalled();
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
onPlaying(cb: () => void): Unsubscribe {
|
|
263
|
+
return this.on('playing', cb);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
onEnded(cb: () => void): Unsubscribe {
|
|
267
|
+
return this.on('ended', cb);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
onError(cb: (err: Error) => void): Unsubscribe {
|
|
271
|
+
return this.on('error', () => cb(this.lastError ?? new Error('media error')));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async destroy(): Promise<void> {
|
|
275
|
+
this.segments?.dispose();
|
|
276
|
+
this.segments = null;
|
|
277
|
+
this.listeners.clear();
|
|
278
|
+
this.ready = { player: false, surface: false };
|
|
279
|
+
this.pendingPlay = null;
|
|
280
|
+
await this.player.deinitialize().catch(() => {
|
|
281
|
+
// best effort during teardown; must not throw into React's cleanup path
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
class VegaClips implements ClipPlayer {
|
|
287
|
+
private current: AudioPlayer | null = null;
|
|
288
|
+
/** settles the clip that is playing; set for exactly as long as one is */
|
|
289
|
+
private interrupt: (() => void) | null = null;
|
|
290
|
+
|
|
291
|
+
constructor(private readonly mime: string) {}
|
|
292
|
+
|
|
293
|
+
async play(uri: string): Promise<void> {
|
|
294
|
+
const player = new AudioPlayer(
|
|
295
|
+
AudioContentType.CONTENT_TYPE_SPEECH,
|
|
296
|
+
AudioUsageType.USAGE_ACCESSIBILITY,
|
|
297
|
+
);
|
|
298
|
+
this.current = player;
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await player.initialize();
|
|
302
|
+
|
|
303
|
+
if (!MediaSource.isTypeSupported(this.mime)) {
|
|
304
|
+
throw new Error(`cue container unsupported: ${this.mime}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const source = new MediaSource();
|
|
308
|
+
const appended = fetchAndAppend(source, this.mime, uri);
|
|
309
|
+
player.srcObject = source;
|
|
310
|
+
const bytes = await appended;
|
|
311
|
+
log(`cue.audio bytes=${bytes}`);
|
|
312
|
+
|
|
313
|
+
await new Promise<void>((resolve, reject) => {
|
|
314
|
+
// A paused AudioPlayer never emits 'ended', so without this a stopped
|
|
315
|
+
// clip's promise never settles and its player is never torn down —
|
|
316
|
+
// one leaked player per interrupted cue, measured on the Virtual Device.
|
|
317
|
+
this.interrupt = () => {
|
|
318
|
+
log('cue.audio state=stopped');
|
|
319
|
+
resolve();
|
|
320
|
+
};
|
|
321
|
+
player.addEventListener('error', () => {
|
|
322
|
+
const code = player.error?.code ?? -1;
|
|
323
|
+
reject(new Error(`cue media error ${code}`));
|
|
324
|
+
});
|
|
325
|
+
player.addEventListener('ended', () => {
|
|
326
|
+
log(`cue.audio state=ended t=${player.currentTime.toFixed(2)}`);
|
|
327
|
+
resolve();
|
|
328
|
+
});
|
|
329
|
+
player.play().catch(reject);
|
|
330
|
+
});
|
|
331
|
+
} finally {
|
|
332
|
+
this.current = null;
|
|
333
|
+
this.interrupt = null;
|
|
334
|
+
// Not awaited: tearing the clip player down took ~130 ms on the Virtual
|
|
335
|
+
// Device, and every one of them kept the film ducked after the speech
|
|
336
|
+
// had ended. A cue that cannot be torn down must not stop the film.
|
|
337
|
+
void player.deinitialize().catch(() => undefined);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
stop(): void {
|
|
342
|
+
this.current?.pause();
|
|
343
|
+
this.interrupt?.();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
class VegaLifecycle implements AppLifecycle {
|
|
348
|
+
onBackground(cb: () => void): Unsubscribe {
|
|
349
|
+
const sub = AppState.addEventListener('change', (state) => {
|
|
350
|
+
if (state !== 'active') cb();
|
|
351
|
+
});
|
|
352
|
+
return () => sub.remove();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function createVegaAdapter(options: VegaAdapterOptions = {}): MediaAdapter {
|
|
357
|
+
const video = new VegaVideo(
|
|
358
|
+
options.videoMime ?? DEFAULT_VIDEO_MIME,
|
|
359
|
+
options.window ?? DEFAULT_WINDOW,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
function VideoSurface({ style }: VideoSurfaceProps) {
|
|
363
|
+
const onCreated = useCallback((handle: string) => video.attachSurface(handle), []);
|
|
364
|
+
const onDestroyed = useCallback((handle: string) => video.detachSurface(handle), []);
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<KeplerVideoSurfaceView
|
|
368
|
+
style={style}
|
|
369
|
+
scalingmode="fit"
|
|
370
|
+
onSurfaceViewCreated={onCreated}
|
|
371
|
+
onSurfaceViewDestroyed={onDestroyed}
|
|
372
|
+
/>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
video,
|
|
378
|
+
clips: new VegaClips(options.clipMime ?? DEFAULT_CLIP_MIME),
|
|
379
|
+
lifecycle: new VegaLifecycle(),
|
|
380
|
+
VideoSurface,
|
|
381
|
+
};
|
|
382
|
+
}
|