@vestra-player/sdk 1.6.16 → 1.6.18
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 +21 -0
- package/dist/commands.d.ts +5 -5
- package/dist/commands.js +117 -139
- package/dist/docs-tree.json.gz +0 -0
- package/dist/plugin/runtime.d.ts +26 -0
- package/dist/plugin/runtime.js +16 -0
- package/dist/plugin/testing/commands.d.ts +65 -0
- package/dist/plugin/testing/commands.js +412 -0
- package/dist/plugin/testing/core-actions.d.ts +39 -0
- package/dist/plugin/testing/core-actions.js +371 -0
- package/dist/plugin/testing/declarations.d.ts +37 -0
- package/dist/plugin/testing/declarations.js +80 -0
- package/dist/plugin/testing/i18n.d.ts +28 -0
- package/dist/plugin/testing/i18n.js +107 -0
- package/dist/plugin/testing/index.d.ts +3140 -0
- package/dist/plugin/testing/index.js +2796 -0
- package/dist/plugin/testing/internals.d.ts +76 -0
- package/dist/plugin/testing/internals.js +60 -0
- package/dist/plugin/testing/jobs.d.ts +9 -0
- package/dist/plugin/testing/jobs.js +304 -0
- package/dist/plugin/testing/layout.d.ts +85 -0
- package/dist/plugin/testing/layout.js +622 -0
- package/dist/plugin/testing/media.d.ts +55 -0
- package/dist/plugin/testing/media.js +615 -0
- package/dist/plugin/testing/osd.d.ts +4 -0
- package/dist/plugin/testing/osd.js +57 -0
- package/dist/plugin/testing/processing.d.ts +56 -0
- package/dist/plugin/testing/processing.js +491 -0
- package/dist/plugin/testing/services.d.ts +50 -0
- package/dist/plugin/testing/services.js +466 -0
- package/dist/plugin/testing/transport.d.ts +29 -0
- package/dist/plugin/testing/transport.js +140 -0
- package/dist/plugin/testing.d.ts +1 -3140
- package/dist/plugin/testing.js +1 -6259
- package/llms.txt +1 -1
- package/package.json +5 -3
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The source and what plays: `source.*` intents and resolvers, playback,
|
|
3
|
+
* subtitles and audio.
|
|
4
|
+
*
|
|
5
|
+
* Part of `TestHost` (`./index.ts`): functions over the host and its private
|
|
6
|
+
* state (`Internals`, `./internals.ts`) that the class delegates to.
|
|
7
|
+
* Internal to the SDK; not exported from `@vestra-player/sdk/testing`.
|
|
8
|
+
*/
|
|
9
|
+
import { extname, isAbsolute } from "node:path";
|
|
10
|
+
import { HostError } from "../../wire.js";
|
|
11
|
+
import { SUBTITLE_LIMITS } from "../subtitles.js";
|
|
12
|
+
import { isTextValue } from "../../i18n.js";
|
|
13
|
+
import { taken } from "./transport.js";
|
|
14
|
+
import { declaredText, hostText } from "./i18n.js";
|
|
15
|
+
import { timelineChanged } from "./layout.js";
|
|
16
|
+
export function initialSource() {
|
|
17
|
+
return {
|
|
18
|
+
kind: "media",
|
|
19
|
+
container: null,
|
|
20
|
+
audioTracks: null,
|
|
21
|
+
stamp: { generation: 0, intentVersion: 0 },
|
|
22
|
+
cycle: 0,
|
|
23
|
+
eventSequence: 0,
|
|
24
|
+
phase: "empty",
|
|
25
|
+
source: null,
|
|
26
|
+
loadingOwner: null,
|
|
27
|
+
terminalReason: null,
|
|
28
|
+
paused: false,
|
|
29
|
+
position: null,
|
|
30
|
+
duration: null,
|
|
31
|
+
capabilities: {
|
|
32
|
+
pause: false,
|
|
33
|
+
seek: false,
|
|
34
|
+
rate: false,
|
|
35
|
+
restart: false,
|
|
36
|
+
close: false,
|
|
37
|
+
snapshotSource: false,
|
|
38
|
+
},
|
|
39
|
+
output: { volume: 100, muted: false },
|
|
40
|
+
rate: 1,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export const readyCapabilities = {
|
|
44
|
+
pause: true,
|
|
45
|
+
seek: true,
|
|
46
|
+
rate: true,
|
|
47
|
+
restart: true,
|
|
48
|
+
close: true,
|
|
49
|
+
snapshotSource: true,
|
|
50
|
+
};
|
|
51
|
+
export function sourceIntent(host, inner, intent) {
|
|
52
|
+
const expected = intent.expected;
|
|
53
|
+
const stamp = host.source.stamp;
|
|
54
|
+
if (expected &&
|
|
55
|
+
(expected.generation !== stamp.generation ||
|
|
56
|
+
expected.intentVersion !== stamp.intentVersion))
|
|
57
|
+
throw new HostError("stale", "stale");
|
|
58
|
+
const next = {
|
|
59
|
+
...host.source,
|
|
60
|
+
stamp: { ...stamp, intentVersion: stamp.intentVersion + 1 },
|
|
61
|
+
eventSequence: host.source.eventSequence + 1,
|
|
62
|
+
};
|
|
63
|
+
if (intent.kind === "open" || intent.kind === "openProvider") {
|
|
64
|
+
next.stamp.generation++;
|
|
65
|
+
next.cycle++;
|
|
66
|
+
next.kind = intent.kind === "open" ? "media" : "producer";
|
|
67
|
+
next.source =
|
|
68
|
+
intent.kind === "open"
|
|
69
|
+
? String(intent.source)
|
|
70
|
+
: `producer://${intent.producerId}`;
|
|
71
|
+
next.phase = intent.kind === "open" ? "loading" : "ready";
|
|
72
|
+
next.paused = intent.startPaused === true;
|
|
73
|
+
next.position = null;
|
|
74
|
+
next.duration = null;
|
|
75
|
+
next.terminalReason = null;
|
|
76
|
+
next.capabilities = { ...readyCapabilities };
|
|
77
|
+
next.loop = null; // a new source clears the A-B loop
|
|
78
|
+
if (host.timeline.layers.size || host.timeline.rangeLayers.size) {
|
|
79
|
+
host.timeline.layers.clear(); // and every timeline layer
|
|
80
|
+
host.timeline.rangeLayers.clear();
|
|
81
|
+
queueMicrotask(() => timelineChanged(host, inner));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (intent.kind === "close") {
|
|
85
|
+
if (!host.source.source)
|
|
86
|
+
throw new HostError("invalid", "noSource");
|
|
87
|
+
Object.assign(next, {
|
|
88
|
+
phase: "empty",
|
|
89
|
+
source: null,
|
|
90
|
+
position: null,
|
|
91
|
+
duration: null,
|
|
92
|
+
terminalReason: "stopped",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
else if (intent.kind === "restart") {
|
|
96
|
+
next.cycle++;
|
|
97
|
+
Object.assign(next, {
|
|
98
|
+
phase: "ready",
|
|
99
|
+
position: 0,
|
|
100
|
+
paused: false,
|
|
101
|
+
terminalReason: null,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
else if (intent.kind === "control") {
|
|
105
|
+
if (!host.source.source)
|
|
106
|
+
throw new HostError("invalid", "noSource");
|
|
107
|
+
const control = intent.control;
|
|
108
|
+
if (control.kind === "pause")
|
|
109
|
+
next.paused = control.paused === true;
|
|
110
|
+
if (control.kind === "seek")
|
|
111
|
+
next.position = Number(control.seconds);
|
|
112
|
+
if (control.kind === "rate")
|
|
113
|
+
next.rate = Number(control.rate);
|
|
114
|
+
}
|
|
115
|
+
// Like the player: the selected producer is retired as soon as another
|
|
116
|
+
// source replaces it or the source closes.
|
|
117
|
+
if (["open", "openProvider", "close"].includes(String(intent.kind)))
|
|
118
|
+
retireSelected(host, inner, intent.kind === "openProvider" ? Number(intent.producerId) : null);
|
|
119
|
+
if (intent.kind === "openProvider")
|
|
120
|
+
inner.selected = Number(intent.producerId);
|
|
121
|
+
if (intent.kind === "open" || intent.kind === "close")
|
|
122
|
+
inner.selected = null;
|
|
123
|
+
host.source = next;
|
|
124
|
+
host.subtitles.source = {
|
|
125
|
+
generation: next.stamp.generation,
|
|
126
|
+
phase: next.phase,
|
|
127
|
+
};
|
|
128
|
+
host.emit("source.state", host.source);
|
|
129
|
+
cancelResolutions(host, inner);
|
|
130
|
+
return { ticket: { stamp: next.stamp, cycle: next.cycle, owner: 1 } };
|
|
131
|
+
}
|
|
132
|
+
/** The file resolver of this plugin that claims `source`, if any. */
|
|
133
|
+
export function fileResolver(host, _inner, source) {
|
|
134
|
+
if (source.includes("://") || !isAbsolute(source))
|
|
135
|
+
return null;
|
|
136
|
+
const extension = extname(source).slice(1).toLowerCase();
|
|
137
|
+
if (!extension)
|
|
138
|
+
return null;
|
|
139
|
+
for (const [id, declaration] of host.resolvers) {
|
|
140
|
+
const extensions = declaration.extensions;
|
|
141
|
+
if (extensions?.includes(extension))
|
|
142
|
+
return id;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
/** Retire the producer that is the player's source, unless it is `keep`. */
|
|
147
|
+
export function retireSelected(host, inner, keep) {
|
|
148
|
+
for (const producer of host.producers.values())
|
|
149
|
+
if (producer.id !== keep &&
|
|
150
|
+
!producer.retired &&
|
|
151
|
+
isCurrentProducer(host, inner, producer.id))
|
|
152
|
+
producer.retired = true;
|
|
153
|
+
}
|
|
154
|
+
/** Whether producer `id` is the player's source. */
|
|
155
|
+
export function isCurrentProducer(host, inner, id) {
|
|
156
|
+
return (host.source.kind === "producer" &&
|
|
157
|
+
(inner.selected === id || host.source.source === `producer://${id}`));
|
|
158
|
+
}
|
|
159
|
+
/** Start resolving `source` for the open just made; its request id, or null. */
|
|
160
|
+
export function startResolution(host, inner, source, paused, operation) {
|
|
161
|
+
const resolverId = fileResolver(host, inner, source);
|
|
162
|
+
if (resolverId === null)
|
|
163
|
+
return null;
|
|
164
|
+
const requestId = ++inner.next.resolution;
|
|
165
|
+
host.resolutions.set(requestId, { resolverId, source, paused });
|
|
166
|
+
inner.resolving.set(requestId, {
|
|
167
|
+
stamp: { ...host.source.stamp },
|
|
168
|
+
...(operation === undefined ? {} : { operation }),
|
|
169
|
+
});
|
|
170
|
+
host.emit("source.resolveRequest", { requestId, resolverId, url: source });
|
|
171
|
+
return requestId;
|
|
172
|
+
}
|
|
173
|
+
/** Settle a resolution's open; `error` fails it. */
|
|
174
|
+
export function settleResolution(host, inner, requestId, error) {
|
|
175
|
+
const operation = inner.resolving.get(requestId)?.operation;
|
|
176
|
+
host.resolutions.delete(requestId);
|
|
177
|
+
inner.resolving.delete(requestId);
|
|
178
|
+
if (operation === undefined)
|
|
179
|
+
return;
|
|
180
|
+
const entry = inner.sourceBook.get(operation);
|
|
181
|
+
inner.sourceBook.set(operation, error === undefined
|
|
182
|
+
? { status: "completed", ticket: entry?.ticket ?? null }
|
|
183
|
+
: { status: "failed", error: { backend: error } });
|
|
184
|
+
}
|
|
185
|
+
/** Resolutions whose open is no longer the source (or whose resolver left). */
|
|
186
|
+
export function cancelResolutions(host, inner) {
|
|
187
|
+
const stamp = host.source.stamp;
|
|
188
|
+
for (const [requestId, resolution] of [...host.resolutions]) {
|
|
189
|
+
const pending = inner.resolving.get(requestId);
|
|
190
|
+
if (pending.stamp.generation === stamp.generation &&
|
|
191
|
+
pending.stamp.intentVersion === stamp.intentVersion &&
|
|
192
|
+
host.resolvers.has(resolution.resolverId))
|
|
193
|
+
continue;
|
|
194
|
+
settleResolution(host, inner, requestId, "source resolution cancelled, expired or superseded");
|
|
195
|
+
host.emit("source.resolveCancel", {
|
|
196
|
+
requestId,
|
|
197
|
+
resolverId: resolution.resolverId,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** `sourceResolvers.register`: the player's rules for both forms. */
|
|
202
|
+
export function registerResolver(host, inner, p) {
|
|
203
|
+
const id = String(p.id);
|
|
204
|
+
const extensions = p.extensions;
|
|
205
|
+
if (host.resolvers.has(id))
|
|
206
|
+
throw new HostError("conflict", "resolver id already registered");
|
|
207
|
+
if (extensions !== undefined) {
|
|
208
|
+
if (p.schemes !== undefined ||
|
|
209
|
+
p.hosts !== undefined ||
|
|
210
|
+
!Array.isArray(extensions) ||
|
|
211
|
+
extensions.length < 1 ||
|
|
212
|
+
extensions.length > 16 ||
|
|
213
|
+
extensions.some((e) => typeof e !== "string" ||
|
|
214
|
+
!/^[a-z0-9]{1,16}$/.test(e) ||
|
|
215
|
+
e === "folder"))
|
|
216
|
+
throw new HostError("invalid", "file resolvers take 1–16 file extensions (lower case, no dot, not folder)");
|
|
217
|
+
}
|
|
218
|
+
const claims = (other) => extensions?.find((e) => other.extensions?.includes(e)) ??
|
|
219
|
+
(other.hosts?.some((h) => p.hosts?.includes(h)) &&
|
|
220
|
+
other.schemes?.some((x) => p.schemes?.includes(x))
|
|
221
|
+
? "host"
|
|
222
|
+
: undefined);
|
|
223
|
+
for (const own of host.resolvers.values())
|
|
224
|
+
if (claims(own))
|
|
225
|
+
throw new HostError("conflict", "already registered by this plugin");
|
|
226
|
+
for (const other of host.otherResolvers) {
|
|
227
|
+
const what = claims(other);
|
|
228
|
+
if (what) {
|
|
229
|
+
const target = what === "host"
|
|
230
|
+
? hostText(host, inner, "this site")
|
|
231
|
+
: hostText(host, inner, "File type .{}", [what]);
|
|
232
|
+
throw taken(hostText(host, inner, "{} is resolved by {}", [target, other.owner]));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
host.resolvers.set(id, p);
|
|
236
|
+
}
|
|
237
|
+
/** `sourceResolvers.respond` for a simulated file resolution. */
|
|
238
|
+
export function respondResolution(host, inner, p) {
|
|
239
|
+
const requestId = Number(p.requestId);
|
|
240
|
+
const resolution = host.resolutions.get(requestId);
|
|
241
|
+
if (!resolution) {
|
|
242
|
+
// Web resolutions are not simulated: their answers are only recorded.
|
|
243
|
+
if (requestId > inner.next.resolution)
|
|
244
|
+
return null;
|
|
245
|
+
throw new HostError("stale", "resolution no longer pending");
|
|
246
|
+
}
|
|
247
|
+
const fail = (message) => {
|
|
248
|
+
settleResolution(host, inner, requestId, message);
|
|
249
|
+
host.setSource({
|
|
250
|
+
phase: "error",
|
|
251
|
+
terminalReason: { failed: message },
|
|
252
|
+
});
|
|
253
|
+
throw new HostError("resolution", message);
|
|
254
|
+
};
|
|
255
|
+
// A text value (`plugin.i18n.error`) is shown translated, like the player.
|
|
256
|
+
const error = isTextValue(p.error)
|
|
257
|
+
? declaredText(host, inner, p.error, "error")
|
|
258
|
+
: p.error;
|
|
259
|
+
if (typeof error === "string")
|
|
260
|
+
fail(error.trim() ? [...error].slice(0, 2048).join("") : "resolver failed");
|
|
261
|
+
const result = (p.result ?? {});
|
|
262
|
+
const producer = host.producers.get(Number(result.producerId));
|
|
263
|
+
if (result.kind !== "stream")
|
|
264
|
+
fail("invalid resolved stream descriptor");
|
|
265
|
+
if (!producer || producer.released || producer.retired)
|
|
266
|
+
fail("unavailable: producer absent");
|
|
267
|
+
// Like openProvider, but the source keeps the path and `startPaused`.
|
|
268
|
+
retireSelected(host, inner, producer.id);
|
|
269
|
+
inner.selected = producer.id;
|
|
270
|
+
producer.active = true;
|
|
271
|
+
producer.acknowledged = false;
|
|
272
|
+
producer.pending = {
|
|
273
|
+
producerId: producer.id,
|
|
274
|
+
epoch: producer.epoch + 1,
|
|
275
|
+
baseSample: producer.receivedFrames,
|
|
276
|
+
paused: resolution.paused,
|
|
277
|
+
};
|
|
278
|
+
settleResolution(host, inner, requestId);
|
|
279
|
+
host.setSource({
|
|
280
|
+
kind: "producer",
|
|
281
|
+
phase: "ready",
|
|
282
|
+
paused: resolution.paused,
|
|
283
|
+
capabilities: {
|
|
284
|
+
pause: true,
|
|
285
|
+
seek: false,
|
|
286
|
+
rate: false,
|
|
287
|
+
restart: false,
|
|
288
|
+
close: true,
|
|
289
|
+
snapshotSource: false,
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
return { stamp: { ...host.source.stamp } };
|
|
293
|
+
}
|
|
294
|
+
/** Audio track calls need ready media and the source the caller saw. */
|
|
295
|
+
export function audioCondition(host, _inner, expected) {
|
|
296
|
+
const want = expected;
|
|
297
|
+
const stamp = host.source.stamp;
|
|
298
|
+
if (want &&
|
|
299
|
+
(want.generation !== stamp.generation ||
|
|
300
|
+
want.intentVersion !== stamp.intentVersion))
|
|
301
|
+
throw new HostError("stale", "source changed");
|
|
302
|
+
if (host.source.kind !== "media" ||
|
|
303
|
+
!["ready", "ended"].includes(host.source.phase))
|
|
304
|
+
throw new HostError("unavailable", "audio tracks need ready media");
|
|
305
|
+
}
|
|
306
|
+
/** `stale` unless `expected` is the current subtitle stamp. */
|
|
307
|
+
export function subtitleExpected(host, _inner, expected) {
|
|
308
|
+
const stamp = host.subtitles.selection.stamp;
|
|
309
|
+
const want = expected;
|
|
310
|
+
if (want.generation !== stamp.generation || want.revision !== stamp.revision)
|
|
311
|
+
throw new HostError("stale", "subtitle selection changed");
|
|
312
|
+
}
|
|
313
|
+
export function loadedSubtitles(host, inner) {
|
|
314
|
+
const generation = host.source.stamp.generation;
|
|
315
|
+
if (inner.subtitleResources.generation !== generation)
|
|
316
|
+
inner.subtitleResources = { generation, bytes: new Map() };
|
|
317
|
+
return inner.subtitleResources.bytes;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The player's `SUBTITLE_LIMITS` for a new resource (`busy`, as the
|
|
321
|
+
* player reports them). External tracks in `subtitles.tracks` that this
|
|
322
|
+
* plugin did not load count as other plugins'. `totalBytes` is not
|
|
323
|
+
* modelled: one plugin's 4 files of at most 2 MiB stay below it, and the
|
|
324
|
+
* bytes of other plugins' tracks are unknown.
|
|
325
|
+
*/
|
|
326
|
+
export function subtitleRoom(host, inner, resourceId) {
|
|
327
|
+
const loaded = loadedSubtitles(host, inner);
|
|
328
|
+
if (loaded.has(resourceId))
|
|
329
|
+
return;
|
|
330
|
+
const external = host.subtitles.tracks.filter((t) => t.target.kind === "external");
|
|
331
|
+
const own = external.filter((t) => t.target.kind === "external" && loaded.has(t.target.resourceId));
|
|
332
|
+
const others = external.length - own.length;
|
|
333
|
+
if (loaded.size >= SUBTITLE_LIMITS.loadsPerPlugin ||
|
|
334
|
+
loaded.size + others >= SUBTITLE_LIMITS.loads ||
|
|
335
|
+
own.length >= SUBTITLE_LIMITS.tracksPerPlugin ||
|
|
336
|
+
external.length >= SUBTITLE_LIMITS.tracks)
|
|
337
|
+
throw new HostError("busy", "subtitle resource or counter limit exceeded");
|
|
338
|
+
}
|
|
339
|
+
export function subtitleChange(host, inner, expected) {
|
|
340
|
+
subtitleExpected(host, inner, expected);
|
|
341
|
+
const stamp = host.subtitles.selection.stamp;
|
|
342
|
+
host.subtitles.selection.stamp = {
|
|
343
|
+
generation: host.source.stamp.generation,
|
|
344
|
+
revision: stamp.revision + 1,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/** Implements `TestHost.setSource`. */
|
|
348
|
+
export function setSource(host, _inner, patch) {
|
|
349
|
+
host.source = {
|
|
350
|
+
...host.source,
|
|
351
|
+
...patch,
|
|
352
|
+
eventSequence: host.source.eventSequence + 1,
|
|
353
|
+
};
|
|
354
|
+
if (patch.phase && patch.phase !== "empty" && !patch.capabilities)
|
|
355
|
+
host.source.capabilities = { ...readyCapabilities };
|
|
356
|
+
host.subtitles.source = {
|
|
357
|
+
generation: host.source.stamp.generation,
|
|
358
|
+
phase: host.source.phase,
|
|
359
|
+
};
|
|
360
|
+
host.emit("source.state", host.source);
|
|
361
|
+
// Like the player (reference §6.3): loading shows the backdrop.
|
|
362
|
+
if (patch.phase === "loading" && host.backdrop && !host.backdrop.effective)
|
|
363
|
+
host.setBackdropEffective(true);
|
|
364
|
+
}
|
|
365
|
+
/** The default handlers of this area's wire methods, by method name. */
|
|
366
|
+
export function mediaHandlers(host, inner) {
|
|
367
|
+
return {
|
|
368
|
+
"playback.stats": () => host.stats,
|
|
369
|
+
"audio.addTrack": (p) => {
|
|
370
|
+
audioCondition(host, inner, p.expected);
|
|
371
|
+
const path = String(p.path);
|
|
372
|
+
if (!path.startsWith("/") || path.includes("://"))
|
|
373
|
+
throw new HostError("invalid", "path must be an existing absolute file");
|
|
374
|
+
const tracks = host.source.audioTracks ?? [];
|
|
375
|
+
const track = {
|
|
376
|
+
trackId: Math.max(0, ...tracks.map((t) => t.trackId)) + 1,
|
|
377
|
+
streamIndex: null,
|
|
378
|
+
selected: p.select !== false,
|
|
379
|
+
external: true,
|
|
380
|
+
codec: null,
|
|
381
|
+
title: path.split("/").pop() ?? null,
|
|
382
|
+
language: null,
|
|
383
|
+
};
|
|
384
|
+
host.setSource({
|
|
385
|
+
audioTracks: [
|
|
386
|
+
...tracks.map((t) => ({
|
|
387
|
+
...t,
|
|
388
|
+
selected: track.selected ? false : t.selected,
|
|
389
|
+
})),
|
|
390
|
+
track,
|
|
391
|
+
],
|
|
392
|
+
});
|
|
393
|
+
return track;
|
|
394
|
+
},
|
|
395
|
+
"audio.selectTrack": (p) => {
|
|
396
|
+
audioCondition(host, inner, p.expected);
|
|
397
|
+
const tracks = host.source.audioTracks ?? [];
|
|
398
|
+
if (!tracks.some((t) => t.trackId === p.id))
|
|
399
|
+
throw new HostError("invalid", "no such audio track");
|
|
400
|
+
host.setSource({
|
|
401
|
+
audioTracks: tracks.map((t) => ({
|
|
402
|
+
...t,
|
|
403
|
+
selected: t.trackId === p.id,
|
|
404
|
+
})),
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
"source.state": () => host.source,
|
|
408
|
+
"source.submit": (p) => {
|
|
409
|
+
const intent = p.intent;
|
|
410
|
+
const control = intent.control;
|
|
411
|
+
if (intent.kind === "control" && control?.kind === "seek") {
|
|
412
|
+
if (control.keyframes !== undefined &&
|
|
413
|
+
typeof control.keyframes !== "boolean")
|
|
414
|
+
throw new HostError("invalid", "keyframes must be a boolean");
|
|
415
|
+
host.seeks.push({
|
|
416
|
+
seconds: Number(control.seconds),
|
|
417
|
+
keyframes: control.keyframes === true,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
const ticket = sourceIntent(host, inner, intent);
|
|
421
|
+
const operation = Number(p.operationId);
|
|
422
|
+
// A file this plugin's file resolver claims: pending until it answers.
|
|
423
|
+
if (intent.kind === "open" &&
|
|
424
|
+
startResolution(host, inner, String(intent.source), intent.startPaused === true, operation) !== null) {
|
|
425
|
+
const pending = { status: "pending", ...ticket };
|
|
426
|
+
inner.sourceBook.set(operation, pending);
|
|
427
|
+
return pending;
|
|
428
|
+
}
|
|
429
|
+
const result = { status: "completed", ...ticket };
|
|
430
|
+
inner.sourceBook.set(operation, result);
|
|
431
|
+
// Selecting a provider asks it to acknowledge a start control.
|
|
432
|
+
const producer = host.producers.get(Number(intent.producerId));
|
|
433
|
+
if (intent.kind === "openProvider" && producer) {
|
|
434
|
+
producer.acknowledged = false;
|
|
435
|
+
producer.pending = {
|
|
436
|
+
producerId: producer.id,
|
|
437
|
+
epoch: producer.epoch + 1,
|
|
438
|
+
baseSample: producer.receivedFrames,
|
|
439
|
+
paused: false,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
return result;
|
|
443
|
+
},
|
|
444
|
+
"source.operation": (p) => {
|
|
445
|
+
const result = inner.sourceBook.get(Number(p.operationId));
|
|
446
|
+
if (!result)
|
|
447
|
+
throw new HostError("unknown-expired", "UnknownExpired");
|
|
448
|
+
return result;
|
|
449
|
+
},
|
|
450
|
+
"playback.set": (p) => {
|
|
451
|
+
if (p.property === "volume")
|
|
452
|
+
host.source.output = { ...host.source.output, volume: Number(p.value) };
|
|
453
|
+
if (p.property === "muted")
|
|
454
|
+
host.source.output = { ...host.source.output, muted: p.value === true };
|
|
455
|
+
host.source = {
|
|
456
|
+
...host.source,
|
|
457
|
+
eventSequence: host.source.eventSequence + 1,
|
|
458
|
+
};
|
|
459
|
+
},
|
|
460
|
+
"subtitles.state": () => structuredClone(host.subtitles),
|
|
461
|
+
"subtitles.select": (p) => {
|
|
462
|
+
const target = p.target;
|
|
463
|
+
const same = (a, b) => a != null && JSON.stringify(a) === JSON.stringify(b);
|
|
464
|
+
if (p.role === "secondary") {
|
|
465
|
+
if (same(target, host.subtitles.selection.target))
|
|
466
|
+
throw new HostError("invalid", "secondary equals the primary");
|
|
467
|
+
const track = host.subtitles.tracks.find((t) => same(t.target, target));
|
|
468
|
+
if (track && track.text === false)
|
|
469
|
+
throw new HostError("invalid", "a bitmap subtitle cannot be secondary");
|
|
470
|
+
subtitleChange(host, inner, p.expected);
|
|
471
|
+
host.subtitles.selection.secondary = target;
|
|
472
|
+
return structuredClone(host.subtitles);
|
|
473
|
+
}
|
|
474
|
+
if (same(target, host.subtitles.selection.secondary))
|
|
475
|
+
throw new HostError("invalid", "primary equals the secondary");
|
|
476
|
+
subtitleChange(host, inner, p.expected);
|
|
477
|
+
host.subtitles.selection.target = target;
|
|
478
|
+
return structuredClone(host.subtitles);
|
|
479
|
+
},
|
|
480
|
+
"player.audioDevices": () => structuredClone(host.audioDevices),
|
|
481
|
+
"player.setAudioDevice": (p) => {
|
|
482
|
+
const requested = p.id === null ? null : String(p.id);
|
|
483
|
+
const present = host.audioDevices.some((d) => d.id === requested);
|
|
484
|
+
host.setSource({
|
|
485
|
+
audioOutput: { requested, active: present ? requested : null },
|
|
486
|
+
});
|
|
487
|
+
},
|
|
488
|
+
"subtitles.style": (p) => {
|
|
489
|
+
subtitleChange(host, inner, p.expected);
|
|
490
|
+
host.subtitles.presentation.revision++;
|
|
491
|
+
host.subtitles.presentation.effective = p.style
|
|
492
|
+
? {
|
|
493
|
+
owner: 1,
|
|
494
|
+
manifestId: host.manifestId,
|
|
495
|
+
revision: Number(p.revision),
|
|
496
|
+
style: p.style,
|
|
497
|
+
}
|
|
498
|
+
: null;
|
|
499
|
+
return structuredClone(host.subtitles);
|
|
500
|
+
},
|
|
501
|
+
"subtitles.upload.begin": (p) => {
|
|
502
|
+
if (p.generation !== host.source.stamp.generation)
|
|
503
|
+
throw new HostError("stale", "source generation changed");
|
|
504
|
+
const expectedBytes = Number(p.expectedBytes);
|
|
505
|
+
if (!Number.isSafeInteger(expectedBytes) ||
|
|
506
|
+
expectedBytes < 1 ||
|
|
507
|
+
expectedBytes > SUBTITLE_LIMITS.fileBytes)
|
|
508
|
+
throw new HostError("invalid", `a subtitle file has 1..${SUBTITLE_LIMITS.fileBytes} bytes`);
|
|
509
|
+
// Like the player: a new source drops the old one's uploads. A commit
|
|
510
|
+
// the player refused leaves its upload for `subtitles.upload.discard`,
|
|
511
|
+
// which the SDK sends after any failed load.
|
|
512
|
+
for (const [id, upload] of host.uploads)
|
|
513
|
+
if (upload.generation !== undefined &&
|
|
514
|
+
upload.generation !== host.source.stamp.generation)
|
|
515
|
+
host.uploads.delete(id);
|
|
516
|
+
if (host.uploads.size >= SUBTITLE_LIMITS.uploadsPerPlugin)
|
|
517
|
+
throw new HostError("busy", "upload memory, owner or active-token limit reached");
|
|
518
|
+
host.uploads.set(Number(p.uploadId), {
|
|
519
|
+
bytes: [],
|
|
520
|
+
expected: expectedBytes,
|
|
521
|
+
generation: host.source.stamp.generation,
|
|
522
|
+
});
|
|
523
|
+
return {
|
|
524
|
+
generation: p.generation,
|
|
525
|
+
expectedBytes: p.expectedBytes,
|
|
526
|
+
receivedBytes: 0,
|
|
527
|
+
complete: false,
|
|
528
|
+
};
|
|
529
|
+
},
|
|
530
|
+
"subtitles.upload.append": (p) => {
|
|
531
|
+
const upload = host.uploads.get(Number(p.uploadId));
|
|
532
|
+
if (!upload)
|
|
533
|
+
throw new HostError("missing", "upload");
|
|
534
|
+
upload.bytes.push(Buffer.from(String(p.base64), "base64"));
|
|
535
|
+
return {};
|
|
536
|
+
},
|
|
537
|
+
"subtitles.upload.discard": (p) => void host.uploads.delete(Number(p.uploadId)),
|
|
538
|
+
"subtitles.commit": (p) => {
|
|
539
|
+
// The player's order: the request's own fields, then `stale`, then
|
|
540
|
+
// the upload and its content, then the limits.
|
|
541
|
+
const format = p.format === "ass" ? "ass" : "srt";
|
|
542
|
+
const select = p.select === "none"
|
|
543
|
+
? "none"
|
|
544
|
+
: p.select === "secondary"
|
|
545
|
+
? "secondary"
|
|
546
|
+
: "primary";
|
|
547
|
+
const title = isTextValue(p.title)
|
|
548
|
+
? declaredText(host, inner, p.title, "title")
|
|
549
|
+
: typeof p.title === "string"
|
|
550
|
+
? p.title
|
|
551
|
+
: null;
|
|
552
|
+
if (title !== null && ([...title].length < 1 || [...title].length > 200))
|
|
553
|
+
throw new HostError("invalid", "subtitle title needs 1..200 characters");
|
|
554
|
+
subtitleExpected(host, inner, p.expected);
|
|
555
|
+
const upload = host.uploads.get(Number(p.uploadId));
|
|
556
|
+
if (!upload)
|
|
557
|
+
throw new HostError("missing", "upload");
|
|
558
|
+
const bytes = Buffer.concat(upload.bytes);
|
|
559
|
+
if (format === "ass" &&
|
|
560
|
+
!(/\[Script Info\]/i.test(bytes.toString()) &&
|
|
561
|
+
/\[Events\]/i.test(bytes.toString())))
|
|
562
|
+
throw new HostError("invalid", "ASS requires [Script Info] and [Events] sections");
|
|
563
|
+
subtitleRoom(host, inner, String(p.resourceId));
|
|
564
|
+
subtitleChange(host, inner, p.expected);
|
|
565
|
+
loadedSubtitles(host, inner).set(String(p.resourceId), bytes.length);
|
|
566
|
+
const target = {
|
|
567
|
+
kind: "external",
|
|
568
|
+
owner: 1,
|
|
569
|
+
resourceId: String(p.resourceId),
|
|
570
|
+
contentRevision: 1,
|
|
571
|
+
};
|
|
572
|
+
host.subtitles.tracks.push({
|
|
573
|
+
target,
|
|
574
|
+
title: title ??
|
|
575
|
+
hostText(host, inner, format === "ass" ? "External ASS" : "External SRT"),
|
|
576
|
+
language: null,
|
|
577
|
+
styleSupported: true,
|
|
578
|
+
styleReason: null,
|
|
579
|
+
codec: format === "ass" ? "ass" : "webvtt",
|
|
580
|
+
text: true,
|
|
581
|
+
streamIndex: null,
|
|
582
|
+
});
|
|
583
|
+
if (select === "primary")
|
|
584
|
+
host.subtitles.selection.target = target;
|
|
585
|
+
if (select === "secondary")
|
|
586
|
+
host.subtitles.selection.secondary = target;
|
|
587
|
+
host.subtitleLoads.push({ format, title, select, bytes });
|
|
588
|
+
host.uploads.delete(Number(p.uploadId));
|
|
589
|
+
return structuredClone(host.subtitles);
|
|
590
|
+
},
|
|
591
|
+
"subtitles.remove": (p) => {
|
|
592
|
+
subtitleChange(host, inner, p.expected);
|
|
593
|
+
host.subtitles.tracks = host.subtitles.tracks.filter((t) => t.target.kind !== "external" || t.target.resourceId !== p.resourceId);
|
|
594
|
+
const selected = host.subtitles.selection.target;
|
|
595
|
+
if (selected?.kind === "external" && selected.resourceId === p.resourceId)
|
|
596
|
+
host.subtitles.selection.target = null;
|
|
597
|
+
const secondary = host.subtitles.selection.secondary;
|
|
598
|
+
if (secondary?.kind === "external" &&
|
|
599
|
+
secondary.resourceId === p.resourceId)
|
|
600
|
+
host.subtitles.selection.secondary = null;
|
|
601
|
+
return structuredClone(host.subtitles);
|
|
602
|
+
},
|
|
603
|
+
"subtitles.operation": () => ({ status: "unknown" }),
|
|
604
|
+
"sourceResolvers.register": (p) => {
|
|
605
|
+
registerResolver(host, inner, p);
|
|
606
|
+
host.updateFileTypes();
|
|
607
|
+
},
|
|
608
|
+
"sourceResolvers.unregister": (p) => {
|
|
609
|
+
host.resolvers.delete(String(p.id));
|
|
610
|
+
cancelResolutions(host, inner);
|
|
611
|
+
host.updateFileTypes();
|
|
612
|
+
},
|
|
613
|
+
"sourceResolvers.respond": (p) => respondResolution(host, inner, p),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { TestHost } from "./index.ts";
|
|
2
|
+
import type { Handler, Internals } from "./internals.ts";
|
|
3
|
+
/** The default handlers of this area's wire methods, by method name. */
|
|
4
|
+
export declare function osdHandlers(host: TestHost, inner: Internals): Record<string, Handler>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-screen messages and the OSD panel (`osd.show`, `osd.panel`).
|
|
3
|
+
*
|
|
4
|
+
* Part of `TestHost` (`./index.ts`): functions over the host and its private
|
|
5
|
+
* state (`Internals`, `./internals.ts`) that the class delegates to.
|
|
6
|
+
* Internal to the SDK; not exported from `@vestra-player/sdk/testing`.
|
|
7
|
+
*/
|
|
8
|
+
import { HostError } from "../../wire.js";
|
|
9
|
+
import { OSD_LIMITS } from "../osd.js";
|
|
10
|
+
import { fitColumns } from "../../i18n.js";
|
|
11
|
+
import { shownText } from "./i18n.js";
|
|
12
|
+
/** The default handlers of this area's wire methods, by method name. */
|
|
13
|
+
export function osdHandlers(host, inner) {
|
|
14
|
+
return {
|
|
15
|
+
"osd.show": (p) => {
|
|
16
|
+
const text = shownText(host, inner, p.text, p.args, "text").replace(/[\n\r]/g, " ");
|
|
17
|
+
if (!text.trim() || [...text].length > OSD_LIMITS.messageChars)
|
|
18
|
+
throw new HostError("invalid", `osd text needs 1..${OSD_LIMITS.messageChars} characters`);
|
|
19
|
+
// Player 0.7.6: up to 2 buttons, each a command of this plugin.
|
|
20
|
+
const sent = p.actions;
|
|
21
|
+
if (sent !== undefined &&
|
|
22
|
+
(!Array.isArray(sent) || sent.length > OSD_LIMITS.actions))
|
|
23
|
+
throw new HostError("invalid", `actions is a list of at most ${OSD_LIMITS.actions}`);
|
|
24
|
+
const actions = (sent ?? []).map((action) => {
|
|
25
|
+
const title = shownText(host, inner, action.title, undefined, "title").replace(/[\n\r]/g, " ");
|
|
26
|
+
if (!title.trim() || [...title].length > OSD_LIMITS.actionChars)
|
|
27
|
+
throw new HostError("invalid", `an action title needs 1..=${OSD_LIMITS.actionChars} characters`);
|
|
28
|
+
const command = String(action.command);
|
|
29
|
+
if (!host.commands.has(command))
|
|
30
|
+
throw new HostError("invalid", `${command} is not a command of this plugin`);
|
|
31
|
+
return { title, command };
|
|
32
|
+
});
|
|
33
|
+
host.osd.push(text);
|
|
34
|
+
host.osdActions.push(actions);
|
|
35
|
+
},
|
|
36
|
+
"osd.panel": (p) => {
|
|
37
|
+
if (p.text === null)
|
|
38
|
+
return void (host.osdPanel = null);
|
|
39
|
+
const max = p.maxColumns;
|
|
40
|
+
if (max !== undefined &&
|
|
41
|
+
!(Number.isInteger(max) &&
|
|
42
|
+
Number(max) >= OSD_LIMITS.minColumns &&
|
|
43
|
+
Number(max) <= OSD_LIMITS.maxColumns))
|
|
44
|
+
throw new HostError("invalid", `maxColumns is an integer from ${OSD_LIMITS.minColumns} to ${OSD_LIMITS.maxColumns}`);
|
|
45
|
+
const shown = shownText(host, inner, p.text, p.args, "text");
|
|
46
|
+
const text = max === undefined ? shown : fitColumns(shown, Number(max));
|
|
47
|
+
// As the player: \n separates lines, a trailing one ends the last.
|
|
48
|
+
const lines = text.split("\n");
|
|
49
|
+
if (text.endsWith("\n"))
|
|
50
|
+
lines.pop();
|
|
51
|
+
if (Buffer.byteLength(text) > OSD_LIMITS.panelBytes ||
|
|
52
|
+
lines.length > OSD_LIMITS.panelLines)
|
|
53
|
+
throw new HostError("invalid", `osd panel allows ${OSD_LIMITS.panelLines} lines, ${OSD_LIMITS.panelBytes} bytes`);
|
|
54
|
+
host.osdPanel = text;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|