@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/commands.d.ts +5 -5
  3. package/dist/commands.js +117 -139
  4. package/dist/docs-tree.json.gz +0 -0
  5. package/dist/plugin/runtime.d.ts +26 -0
  6. package/dist/plugin/runtime.js +16 -0
  7. package/dist/plugin/testing/commands.d.ts +65 -0
  8. package/dist/plugin/testing/commands.js +412 -0
  9. package/dist/plugin/testing/core-actions.d.ts +39 -0
  10. package/dist/plugin/testing/core-actions.js +371 -0
  11. package/dist/plugin/testing/declarations.d.ts +37 -0
  12. package/dist/plugin/testing/declarations.js +80 -0
  13. package/dist/plugin/testing/i18n.d.ts +28 -0
  14. package/dist/plugin/testing/i18n.js +107 -0
  15. package/dist/plugin/testing/index.d.ts +3140 -0
  16. package/dist/plugin/testing/index.js +2796 -0
  17. package/dist/plugin/testing/internals.d.ts +76 -0
  18. package/dist/plugin/testing/internals.js +60 -0
  19. package/dist/plugin/testing/jobs.d.ts +9 -0
  20. package/dist/plugin/testing/jobs.js +304 -0
  21. package/dist/plugin/testing/layout.d.ts +85 -0
  22. package/dist/plugin/testing/layout.js +622 -0
  23. package/dist/plugin/testing/media.d.ts +55 -0
  24. package/dist/plugin/testing/media.js +615 -0
  25. package/dist/plugin/testing/osd.d.ts +4 -0
  26. package/dist/plugin/testing/osd.js +57 -0
  27. package/dist/plugin/testing/processing.d.ts +56 -0
  28. package/dist/plugin/testing/processing.js +491 -0
  29. package/dist/plugin/testing/services.d.ts +50 -0
  30. package/dist/plugin/testing/services.js +466 -0
  31. package/dist/plugin/testing/transport.d.ts +29 -0
  32. package/dist/plugin/testing/transport.js +140 -0
  33. package/dist/plugin/testing.d.ts +1 -3140
  34. package/dist/plugin/testing.js +1 -6259
  35. package/llms.txt +1 -1
  36. package/package.json +5 -3
@@ -0,0 +1,622 @@
1
+ /**
2
+ * Views, panels and tabs: `ui.*` windows and mounts, `layout.*`, property
3
+ * writes with row patches and parts, images, and the timeline layer.
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 { HostError } from "../../wire.js";
10
+ import { TIMELINE_LIMITS } from "../timeline.js";
11
+ import { UI_VALUE_LIMITS, valueLimitProblem } from "../values.js";
12
+ import { applyOps, PATCH_OPS } from "../patch.js";
13
+ import { isTextValue } from "../../i18n.js";
14
+ import { taken } from "./transport.js";
15
+ import { windowFacts } from "./core-actions.js";
16
+ import { declaredText, shownValues, hostText } from "./i18n.js";
17
+ export const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
18
+ export const SLOT_LABELS = {
19
+ bottom: "bottom bar",
20
+ sidebar: "sidebar",
21
+ backdrop: "video backdrop",
22
+ adjust: "adjust column",
23
+ };
24
+ /** The view a panel's writes are kept under (the player's, per slot). */
25
+ export const mountView = (slot) => slot === "bottom" ? "$mount" : `$mount:${slot}`;
26
+ /**
27
+ * A patch's ops checked against an array of `length` rows, as the player
28
+ * checks them (`ui_patch.rs`): in order, each on the result of the ones
29
+ * before; at most {@link PATCH_OPS}; the result at most
30
+ * `UI_VALUE_LIMITS.arrayRows` rows.
31
+ */
32
+ /** The player takes no Markdown documents in parts of a value (`invalid`). */
33
+ export function noMarkdownParts(rows) {
34
+ if (JSON.stringify(rows).includes('"$vestraMarkdown"'))
35
+ throw new HostError("invalid", "a value sent in parts holds no Markdown documents");
36
+ }
37
+ export function patchOps(ops, length) {
38
+ if (!Array.isArray(ops))
39
+ throw new HostError("invalid", "patch.ops requires an array");
40
+ if (ops.length > PATCH_OPS)
41
+ throw new HostError("limit", `a patch carries at most ${PATCH_OPS} operations`);
42
+ const index = (op, key) => {
43
+ const value = op[key];
44
+ if (!Number.isInteger(value) || value < 0)
45
+ throw new HostError("invalid", `patch op ${key} requires a non-negative integer`);
46
+ return value;
47
+ };
48
+ const count = (op) => {
49
+ const value = index(op, "count");
50
+ if (value === 0)
51
+ throw new HostError("invalid", "patch op count must be at least 1");
52
+ return value;
53
+ };
54
+ const past = (what) => new HostError("invalid", `${what} is past the end of the list`);
55
+ let len = length;
56
+ return ops.map((raw) => {
57
+ const op = (raw ?? {});
58
+ switch (op.op) {
59
+ case "set": {
60
+ const at = index(op, "at");
61
+ if (at >= len)
62
+ throw past(`set at ${at}`);
63
+ if (!("row" in op))
64
+ throw new HostError("invalid", "patch op set requires row");
65
+ return { op: "set", at, row: op.row };
66
+ }
67
+ case "insert": {
68
+ const at = index(op, "at");
69
+ const rows = op.rows;
70
+ if (!Array.isArray(rows) || rows.length === 0)
71
+ throw new HostError("invalid", "patch op insert requires rows");
72
+ if (at > len)
73
+ throw past(`insert at ${at}`);
74
+ len += rows.length;
75
+ if (len > UI_VALUE_LIMITS.arrayRows)
76
+ throw new HostError("limit", `UI array exceeds ${UI_VALUE_LIMITS.arrayRows} rows`);
77
+ return { op: "insert", at, rows: rows };
78
+ }
79
+ case "remove": {
80
+ const at = index(op, "at");
81
+ const n = count(op);
82
+ if (at + n > len)
83
+ throw past(`remove ${n} at ${at}`);
84
+ len -= n;
85
+ return { op: "remove", at, count: n };
86
+ }
87
+ case "move": {
88
+ const from = index(op, "from");
89
+ const n = count(op);
90
+ const to = index(op, "to");
91
+ if (from + n > len || to + n > len)
92
+ throw past(`move ${n} from ${from} to ${to}`);
93
+ return { op: "move", from, count: n, to };
94
+ }
95
+ default:
96
+ throw new HostError("invalid", "patch op must be set, insert, remove or move");
97
+ }
98
+ });
99
+ }
100
+ export function timelineChanged(host, _inner) {
101
+ host.emit("timeline.changed", {
102
+ marks: host.timeline.marks,
103
+ ranges: host.timeline.ranges,
104
+ });
105
+ }
106
+ export function resetWrites(_host, inner, view) {
107
+ for (const slot of [...inner.writes.keys()])
108
+ if (slot.startsWith(`${view}\u0000`))
109
+ inner.writes.delete(slot);
110
+ for (const slot of [...inner.parts.keys()])
111
+ if (slot.startsWith(`${view}\u0000`))
112
+ inner.parts.delete(slot);
113
+ }
114
+ /**
115
+ * A property write as the player takes it, returning the value the
116
+ * property shows after it: a whole `value`, or a `patch` of the array
117
+ * `current` applying on the write numbered `base` (code `stale`
118
+ * otherwise). The result is checked against the value limits whole: the
119
+ * player counts the rows a patch keeps too.
120
+ */
121
+ export function writeProperty(host, inner, view, property, p, current) {
122
+ const slot = `${view}\u0000${property}`;
123
+ const patch = p.patch;
124
+ // A whole array sent in parts (player 0.6.0, SDK 1.5.46): every part but
125
+ // the last carries `more`; the player holds them and writes the whole
126
+ // value at the last, as here (`undefined`: held, nothing shown yet).
127
+ const more = p.more === true;
128
+ const held = inner.parts.get(slot);
129
+ inner.parts.delete(slot);
130
+ if (more && patch === undefined) {
131
+ const rows = shownValues(host, inner, p.value);
132
+ if (!Array.isArray(rows))
133
+ throw new HostError("invalid", "a value sent in parts is an array");
134
+ noMarkdownParts(rows);
135
+ checkValues(host, inner, { [property]: rows });
136
+ inner.parts.set(slot, {
137
+ seq: typeof p.seq === "number" ? p.seq : 0,
138
+ rows,
139
+ });
140
+ return undefined;
141
+ }
142
+ if (patch !== undefined && held && patch.base === held.seq) {
143
+ const ops = patchOps(patch.ops, held.rows.length);
144
+ const resolved = ops.map((op) => op.op === "set"
145
+ ? { ...op, row: shownValues(host, inner, op.row) }
146
+ : op.op === "insert"
147
+ ? { ...op, rows: op.rows.map((row) => shownValues(host, inner, row)) }
148
+ : op);
149
+ noMarkdownParts(resolved.flatMap((op) => op.op === "set" ? [op.row] : op.op === "insert" ? op.rows : []));
150
+ const rows = applyOps(held.rows, resolved);
151
+ checkValues(host, inner, { [property]: rows });
152
+ if (more) {
153
+ inner.parts.set(slot, { seq: patch.seq, rows });
154
+ return undefined;
155
+ }
156
+ seeImages(host, inner, [rows]);
157
+ inner.writes.set(slot, patch.seq);
158
+ return rows;
159
+ }
160
+ if (patch !== undefined && more)
161
+ throw new HostError("stale", "the property changed since the write this patch applies on; send the whole value");
162
+ if (patch === undefined) {
163
+ const value = shownValues(host, inner, p.value);
164
+ checkValues(host, inner, { [property]: value });
165
+ seeImages(host, inner, [value]);
166
+ inner.writes.set(slot, typeof p.seq === "number" ? p.seq : 0);
167
+ return value;
168
+ }
169
+ const { base, seq } = patch;
170
+ if (!Number.isInteger(base) ||
171
+ !Number.isInteger(seq) ||
172
+ base < 0 ||
173
+ seq < 1)
174
+ throw new HostError("invalid", "patch.base and patch.seq require non-negative integers (seq ≥ 1)");
175
+ const stale = () => new HostError("stale", "the property changed since the write this patch applies on; send the whole value");
176
+ if ((inner.writes.get(slot) ?? 0) !== base || !Array.isArray(current))
177
+ throw stale();
178
+ const ops = patchOps(patch.ops, current.length);
179
+ const resolved = ops.map((op) => op.op === "set"
180
+ ? { ...op, row: shownValues(host, inner, op.row) }
181
+ : op.op === "insert"
182
+ ? { ...op, rows: op.rows.map((row) => shownValues(host, inner, row)) }
183
+ : op);
184
+ const value = applyOps(current, resolved);
185
+ checkValues(host, inner, { [property]: value });
186
+ seeImages(host, inner, resolved.flatMap((op) => op.op === "set" ? [op.row] : op.op === "insert" ? op.rows : []));
187
+ inner.writes.set(slot, seq);
188
+ return value;
189
+ }
190
+ /**
191
+ * Refuse property values over the player's limits ({@link UI_VALUE_LIMITS})
192
+ * with `limit`, naming the property, as the player does.
193
+ */
194
+ export function checkValues(_host, _inner, values) {
195
+ for (const [property, value] of Object.entries(values)) {
196
+ const problem = valueLimitProblem(value);
197
+ if (problem)
198
+ throw new HostError("limit", `${property}: ${problem}`);
199
+ }
200
+ }
201
+ /**
202
+ * A panel's or settings page's initial properties, as the player keeps
203
+ * them: one over the limits is left out (the property keeps its default)
204
+ * and reported with `ui.error`, the request itself succeeds.
205
+ */
206
+ export function initialValues(host, _inner, values) {
207
+ const kept = {};
208
+ for (const [property, value] of Object.entries(values)) {
209
+ const problem = valueLimitProblem(value);
210
+ if (problem)
211
+ host.emit("ui.error", {
212
+ code: "limit",
213
+ message: `${property}: ${problem}`,
214
+ });
215
+ else
216
+ kept[property] = value;
217
+ }
218
+ return kept;
219
+ }
220
+ export function seeImages(host, _inner, values) {
221
+ const see = (value) => {
222
+ const path = host.imagePath(value);
223
+ if (path !== null) {
224
+ const { ref, maxSide } = value;
225
+ host.images.push({
226
+ path,
227
+ ref: Number(ref),
228
+ ...(maxSide === undefined ? {} : { maxSide }),
229
+ });
230
+ }
231
+ else if (value !== null && typeof value === "object")
232
+ for (const item of Object.values(value))
233
+ see(item);
234
+ };
235
+ for (const value of Object.values(values))
236
+ see(value);
237
+ }
238
+ export function addPanel(host, _inner, slot, panel) {
239
+ const state = host.layout[slot];
240
+ if (state)
241
+ state.panels = [...(state.panels ?? []), panel];
242
+ else {
243
+ // The adjust column starts hidden, as the player's.
244
+ const shown = slot !== "adjust";
245
+ host.layout[slot] = {
246
+ owner: panel.owner,
247
+ preferred: shown,
248
+ effective: shown,
249
+ reason: shown ? null : "userHidden",
250
+ detached: false,
251
+ panels: [panel],
252
+ };
253
+ }
254
+ }
255
+ export function removePanel(host, _inner, slot, owner) {
256
+ const state = host.layout[slot];
257
+ if (!state)
258
+ return;
259
+ const panels = state.panels ?? [];
260
+ const index = panels.findIndex((panel) => panel.owner === owner);
261
+ const rest = panels.filter((panel) => panel.owner !== owner);
262
+ if (rest.length === 0) {
263
+ host.layout[slot] = null;
264
+ return;
265
+ }
266
+ state.panels = rest;
267
+ if (state.owner === owner)
268
+ state.owner = (rest[index] ?? rest[rest.length - 1]).owner;
269
+ }
270
+ /** The panel a request is about: its `slot`, else the only one. */
271
+ export function panelFor(host, _inner, p) {
272
+ const slot = typeof p.slot === "string" ? p.slot : undefined;
273
+ const panel = slot ? host.panels.get(slot) : host.panel;
274
+ if (!panel)
275
+ throw new HostError("missing", "owner has no ready mount");
276
+ return panel;
277
+ }
278
+ /** Implements `TestHost.mountOther`. */
279
+ export function mountOther(host, inner, slot, plugin, options = {}) {
280
+ if (inner.others.has(plugin))
281
+ throw new Error(`${plugin} already has a panel`);
282
+ const owner = inner.nextOwner++;
283
+ if (slot === "backdrop") {
284
+ if (host.backdrop)
285
+ throw new Error("the backdrop is taken");
286
+ host.backdrop = { owner: plugin, effective: true };
287
+ }
288
+ else {
289
+ if (slot === "bottom" && host.layout.bottom)
290
+ throw new Error("the bottom bar is taken");
291
+ addPanel(host, inner, slot, {
292
+ owner,
293
+ plugin,
294
+ title: options.title ?? plugin,
295
+ });
296
+ }
297
+ inner.others.set(plugin, { slot, owner });
298
+ host.emit("window.state", windowFacts(host, inner));
299
+ }
300
+ /** Implements `TestHost.unmountOther`. */
301
+ export function unmountOther(host, inner, plugin) {
302
+ const other = inner.others.get(plugin);
303
+ if (!other)
304
+ throw new Error(`${plugin} has no panel`);
305
+ inner.others.delete(plugin);
306
+ if (other.slot === "backdrop")
307
+ host.backdrop = null;
308
+ else
309
+ removePanel(host, inner, other.slot, other.owner);
310
+ host.emit("window.state", windowFacts(host, inner));
311
+ }
312
+ /** Implements `TestHost.mounted`. */
313
+ export function mounted(host, _inner, slot) {
314
+ if (slot !== undefined) {
315
+ const panel = host.panels.get(slot);
316
+ if (!panel)
317
+ throw new Error(`no panel mounted in the ${slot}`);
318
+ return panel;
319
+ }
320
+ if (host.panels.size > 1)
321
+ throw new Error(`panels are mounted in ${[...host.panels.keys()].join(", ")}: name the slot`);
322
+ if (!host.panel)
323
+ throw new Error("no panel mounted");
324
+ return host.panel;
325
+ }
326
+ /** Implements `TestHost.setTimelineRanges`. */
327
+ export function setTimelineRanges(host, inner, pluginId, ranges) {
328
+ if (ranges.length)
329
+ host.timeline.rangeLayers.set(pluginId, ranges.map((r) => ({
330
+ start: r.start,
331
+ end: r.end,
332
+ style: r.style,
333
+ title: r.title ?? null,
334
+ })));
335
+ else
336
+ host.timeline.rangeLayers.delete(pluginId);
337
+ timelineChanged(host, inner);
338
+ }
339
+ /** Implements `TestHost.setTimelineLayer`. */
340
+ export function setTimelineLayer(host, inner, pluginId, marks) {
341
+ if (marks.length)
342
+ host.timeline.layers.set(pluginId, marks.map((m) => ({
343
+ time: m.time,
344
+ kind: m.kind,
345
+ title: m.title ?? null,
346
+ })));
347
+ else
348
+ host.timeline.layers.delete(pluginId);
349
+ timelineChanged(host, inner);
350
+ }
351
+ /** The default handlers of this area's wire methods, by method name. */
352
+ export function layoutHandlers(host, inner) {
353
+ return {
354
+ "ui.open": (p) => {
355
+ const id = String(p.id);
356
+ if (host.windows.has(id))
357
+ throw new HostError("conflict", "view already exists");
358
+ resetWrites(host, inner, `w:${id}`);
359
+ const properties = shownValues(host, inner, p.properties);
360
+ checkValues(host, inner, properties);
361
+ seeImages(host, inner, properties);
362
+ host.windows.set(id, {
363
+ file: String(p.file),
364
+ visible: true,
365
+ props: { ...properties },
366
+ callbacks: p.callbacks,
367
+ userClose() {
368
+ host.windows.delete(id);
369
+ host.emit("ui.closed", { view: id });
370
+ },
371
+ emit(callback, ...args) {
372
+ const name = kebab(callback);
373
+ if (!this.callbacks.includes(name))
374
+ throw new Error(`window ${id} did not register callback ${name}`);
375
+ host.emit("ui.callback", { view: id, callback: name, args });
376
+ },
377
+ });
378
+ },
379
+ "ui.set": (p) => {
380
+ const window = host.windows.get(String(p.id));
381
+ if (!window)
382
+ throw new HostError("missing", "view not found");
383
+ const property = String(p.property);
384
+ const next = writeProperty(host, inner, `w:${String(p.id)}`, property, p, window.props[property]);
385
+ // A part of a value sent in parts is held until the last (player 0.6.0).
386
+ if (next !== undefined)
387
+ window.props[property] = next;
388
+ },
389
+ "ui.show": (p) => {
390
+ const window = host.windows.get(String(p.id));
391
+ if (!window)
392
+ throw new HostError("missing", "view not found");
393
+ window.visible = p.visible === true;
394
+ },
395
+ "ui.close": (p) => {
396
+ if (!host.windows.delete(String(p.id)))
397
+ throw new HostError("missing", "view not found");
398
+ },
399
+ "ui.mount": (p) => {
400
+ if (!(String(p.slot) in SLOT_LABELS))
401
+ throw new HostError("invalid", "unknown mount slot");
402
+ // One panel per slot and plugin (player 0.7.6).
403
+ if (host.panels.has(String(p.slot)))
404
+ throw new HostError("conflict", "this plugin already has a panel in this slot");
405
+ // Like the player: one panel in the bottom bar and the backdrop, any
406
+ // number of tabs in the sidebar (player 0.7.3; 0.4.x to 0.7.2 took 8)
407
+ // and in the adjust column (player 0.7.6).
408
+ const holder = p.slot === "backdrop"
409
+ ? host.backdrop?.owner
410
+ : p.slot === "bottom"
411
+ ? host.layout.bottom?.panels?.[0]?.plugin
412
+ : undefined;
413
+ if (holder !== undefined || (p.slot === "bottom" && host.layout.bottom))
414
+ throw taken(hostText(host, inner, "The {} is used by {}", [
415
+ hostText(host, inner, SLOT_LABELS[String(p.slot)]),
416
+ String(holder),
417
+ ]));
418
+ const properties = initialValues(host, inner, shownValues(host, inner, p.properties));
419
+ seeImages(host, inner, properties);
420
+ const panelSlot = String(p.slot);
421
+ resetWrites(host, inner, mountView(panelSlot));
422
+ host.panels.set(panelSlot, {
423
+ slot: panelSlot,
424
+ file: String(p.file),
425
+ component: String(p.component),
426
+ visible: true,
427
+ props: { ...properties },
428
+ locks: null,
429
+ emit(callback, ...args) {
430
+ host.emit("ui.callback", {
431
+ view: "$mount",
432
+ slot: panelSlot,
433
+ callback: kebab(callback),
434
+ args,
435
+ });
436
+ },
437
+ });
438
+ if (p.slot === "backdrop") {
439
+ host.backdrop = { owner: host.manifestId, effective: true };
440
+ return { status: "ready", prepareMs: 0, shellGeneration: 1 };
441
+ }
442
+ const slot = String(p.slot);
443
+ addPanel(host, inner, slot, {
444
+ owner: 1,
445
+ plugin: host.manifestId,
446
+ title: typeof p.title === "string" && p.title
447
+ ? host.translate(p.title)
448
+ : host.manifestId,
449
+ });
450
+ return { status: "ready", prepareMs: 0, shellGeneration: 1 };
451
+ },
452
+ "ui.unmount": (p) => {
453
+ // The slot's panel, or without one every panel (as the player).
454
+ const slots = typeof p.slot === "string" ? [p.slot] : [...host.panels.keys()];
455
+ for (const slot of slots) {
456
+ if (!host.panels.delete(slot))
457
+ continue;
458
+ if (slot === "backdrop")
459
+ host.backdrop = null;
460
+ else
461
+ removePanel(host, inner, slot, 1);
462
+ }
463
+ },
464
+ "ui.menu": (p) => {
465
+ if (!host.panels.size)
466
+ throw new HostError("unavailable", "panel not shown");
467
+ const panel = panelFor(host, inner, p);
468
+ const tabs = panel.slot === "sidebar" || panel.slot === "adjust"
469
+ ? host.layout[panel.slot]
470
+ : null;
471
+ if (tabs && tabs.owner !== 1)
472
+ throw new HostError("unavailable", "the panel's tab is not the current one");
473
+ const items = p.items;
474
+ for (const id of items)
475
+ if (id !== null && !host.commands.has(id))
476
+ throw new HostError("missing", `command ${id} not registered`);
477
+ host.menus.push({ items, x: Number(p.x), y: Number(p.y) });
478
+ },
479
+ "layout.state": () => ({
480
+ ...host.layout,
481
+ backdrop: host.backdrop && {
482
+ owner: 1,
483
+ effective: host.backdrop.effective,
484
+ },
485
+ }),
486
+ "layout.set": (p) => {
487
+ const slot = String(p.slot);
488
+ if (!["bottom", "sidebar", "adjust"].includes(slot))
489
+ throw new HostError("invalid", "unknown slot");
490
+ const state = host.layout[slot];
491
+ if (!state)
492
+ throw new HostError("missing", "no panel at this position");
493
+ if (p.detached !== undefined && slot === "bottom")
494
+ throw new HostError("invalid", "the bottom bar cannot be detached");
495
+ if (p.detached === true && slot === "adjust")
496
+ throw new HostError("invalid", "the adjust column cannot be detached");
497
+ if (p.active !== undefined && p.active !== null) {
498
+ const panel = state.panels?.find((entry) => entry.plugin === p.active);
499
+ if (!panel)
500
+ throw new HostError("missing", `${String(p.active)} has no panel at this position`);
501
+ state.owner = panel.owner;
502
+ }
503
+ if (typeof p.visible === "boolean") {
504
+ const out = state.preferred && state.effective;
505
+ state.preferred = p.visible;
506
+ // In fullscreen a column switched on by a toggle (`reveal: false`)
507
+ // stays collapsed at the edge; `show()` and `setVisible` pop it out.
508
+ const collapsed = p.visible &&
509
+ p.reveal === false &&
510
+ slot !== "bottom" &&
511
+ host.mainWindow.fullscreen &&
512
+ !state.detached &&
513
+ !out;
514
+ state.effective = p.visible && !collapsed;
515
+ state.reason = !p.visible
516
+ ? "userHidden"
517
+ : collapsed
518
+ ? "fullscreen"
519
+ : null;
520
+ const panel = host.panels.get(slot);
521
+ if (panel)
522
+ panel.visible = p.visible;
523
+ }
524
+ if (typeof p.detached === "boolean")
525
+ state.detached = p.detached;
526
+ },
527
+ "ui.setMount": (p) => {
528
+ const panel = panelFor(host, inner, p);
529
+ const property = String(p.property);
530
+ const next = writeProperty(host, inner, mountView(panel.slot), property, p, panel.props[property]);
531
+ // A part of a value sent in parts is held until the last (player 0.6.0).
532
+ if (next !== undefined)
533
+ panel.props[property] = next;
534
+ },
535
+ "timeline.marks": () => ({
536
+ marks: host.timeline.marks,
537
+ ranges: host.timeline.ranges,
538
+ }),
539
+ "timeline.set": (p) => {
540
+ if (p.sourceId !== undefined &&
541
+ p.sourceId !== host.source.stamp.generation)
542
+ return { applied: false };
543
+ const sent = p.marks;
544
+ const duration = host.source.duration;
545
+ if (!Array.isArray(sent) || sent.length > 1000)
546
+ throw new HostError("invalid", "a layer holds at most 1000 marks");
547
+ const marks = sent.map((mark) => ({ ...mark }));
548
+ for (const mark of marks) {
549
+ const time = mark.time;
550
+ if (typeof time !== "number" ||
551
+ !Number.isFinite(time) ||
552
+ time < 0 ||
553
+ (duration !== null && time > duration))
554
+ throw new HostError("invalid", `time ${time} is outside the media`);
555
+ if (!["chapter", "bookmark", "marker"].includes(String(mark.kind)))
556
+ throw new HostError("invalid", "kind is chapter, bookmark or marker");
557
+ // A text value is shown translated (`plugin.i18n.text`).
558
+ if (isTextValue(mark.title))
559
+ mark.title = declaredText(host, inner, mark.title, "title");
560
+ if (mark.title !== undefined &&
561
+ mark.title !== null &&
562
+ (typeof mark.title !== "string" || [...mark.title].length > 100))
563
+ throw new HostError("invalid", "title must be ≤ 100 characters");
564
+ }
565
+ host.setTimelineLayer(host.manifestId, marks.map((m) => ({
566
+ time: m.time,
567
+ kind: String(m.kind),
568
+ title: m.title ?? null,
569
+ })));
570
+ return { applied: true };
571
+ },
572
+ // Player 0.7.6: the layer's ranges, replaced on their own.
573
+ "timeline.setRanges": (p) => {
574
+ if (p.sourceId !== undefined &&
575
+ p.sourceId !== host.source.stamp.generation)
576
+ return { applied: false };
577
+ const sent = p.ranges;
578
+ const duration = host.source.duration;
579
+ if (!Array.isArray(sent) || sent.length > TIMELINE_LIMITS.ranges)
580
+ throw new HostError("invalid", `a layer holds at most ${TIMELINE_LIMITS.ranges} ranges`);
581
+ const ranges = sent.map((range) => ({ ...range }));
582
+ for (const range of ranges) {
583
+ const { start, end } = range;
584
+ const time = (t) => typeof t === "number" && Number.isFinite(t) && t >= 0;
585
+ if (!time(start) || !time(end))
586
+ throw new HostError("invalid", "start and end must be numbers ≥ 0");
587
+ if (end <= start)
588
+ throw new HostError("invalid", `end ${end} must be after start ${start}`);
589
+ // As the player (timeline.rs): 1e-6 s of rounding past the end is allowed.
590
+ if (duration !== null && end > duration + 1e-6)
591
+ throw new HostError("invalid", `end ${end} is after the end of the media`);
592
+ if (!["selection", "region"].includes(String(range.style)))
593
+ throw new HostError("invalid", "style is selection or region");
594
+ if (isTextValue(range.title))
595
+ range.title = declaredText(host, inner, range.title, "title");
596
+ if (range.title !== undefined &&
597
+ range.title !== null &&
598
+ (typeof range.title !== "string" ||
599
+ [...range.title].length > TIMELINE_LIMITS.titleChars))
600
+ throw new HostError("invalid", `title must be ≤ ${TIMELINE_LIMITS.titleChars} characters`);
601
+ }
602
+ host.setTimelineRanges(host.manifestId, ranges.map((r) => ({
603
+ start: r.start,
604
+ end: r.end,
605
+ style: String(r.style),
606
+ title: r.title ?? null,
607
+ })));
608
+ return { applied: true };
609
+ },
610
+ "ui.interaction": (p) => {
611
+ const { slot: _, ...locks } = p;
612
+ panelFor(host, inner, p).locks = locks;
613
+ },
614
+ "ui.markdownScan": (p) => {
615
+ const text = String(p.text);
616
+ const images = [
617
+ ...text.matchAll(/!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/g),
618
+ ].map((m) => m[1]);
619
+ return { images: [...new Set(images)].slice(0, 64) };
620
+ },
621
+ };
622
+ }
@@ -0,0 +1,55 @@
1
+ import type { Json } from "../../wire.ts";
2
+ import type { SourceState } from "../../source.ts";
3
+ import type { TestHost } from "./index.ts";
4
+ import type { Handler, Internals } from "./internals.ts";
5
+ export declare function initialSource(): SourceState;
6
+ export declare const readyCapabilities: {
7
+ pause: boolean;
8
+ seek: boolean;
9
+ rate: boolean;
10
+ restart: boolean;
11
+ close: boolean;
12
+ snapshotSource: boolean;
13
+ };
14
+ export declare function sourceIntent(host: TestHost, inner: Internals, intent: Record<string, Json>): {
15
+ ticket: Json;
16
+ };
17
+ /** The file resolver of this plugin that claims `source`, if any. */
18
+ export declare function fileResolver(host: TestHost, _inner: Internals, source: string): string | null;
19
+ /** Retire the producer that is the player's source, unless it is `keep`. */
20
+ export declare function retireSelected(host: TestHost, inner: Internals, keep: number | null): void;
21
+ /** Whether producer `id` is the player's source. */
22
+ export declare function isCurrentProducer(host: TestHost, inner: Internals, id: number): boolean;
23
+ /** Start resolving `source` for the open just made; its request id, or null. */
24
+ export declare function startResolution(host: TestHost, inner: Internals, source: string, paused: boolean, operation?: number): number | null;
25
+ /** Settle a resolution's open; `error` fails it. */
26
+ export declare function settleResolution(host: TestHost, inner: Internals, requestId: number, error?: string): void;
27
+ /** Resolutions whose open is no longer the source (or whose resolver left). */
28
+ export declare function cancelResolutions(host: TestHost, inner: Internals): void;
29
+ /** `sourceResolvers.register`: the player's rules for both forms. */
30
+ export declare function registerResolver(host: TestHost, inner: Internals, p: Record<string, Json>): void;
31
+ /** `sourceResolvers.respond` for a simulated file resolution. */
32
+ export declare function respondResolution(host: TestHost, inner: Internals, p: Record<string, Json>): {
33
+ stamp: {
34
+ generation: number;
35
+ intentVersion: number;
36
+ };
37
+ } | null;
38
+ /** Audio track calls need ready media and the source the caller saw. */
39
+ export declare function audioCondition(host: TestHost, _inner: Internals, expected: Json | undefined): void;
40
+ /** `stale` unless `expected` is the current subtitle stamp. */
41
+ export declare function subtitleExpected(host: TestHost, _inner: Internals, expected: Json): void;
42
+ export declare function loadedSubtitles(host: TestHost, inner: Internals): Map<string, number>;
43
+ /**
44
+ * The player's `SUBTITLE_LIMITS` for a new resource (`busy`, as the
45
+ * player reports them). External tracks in `subtitles.tracks` that this
46
+ * plugin did not load count as other plugins'. `totalBytes` is not
47
+ * modelled: one plugin's 4 files of at most 2 MiB stay below it, and the
48
+ * bytes of other plugins' tracks are unknown.
49
+ */
50
+ export declare function subtitleRoom(host: TestHost, inner: Internals, resourceId: string): void;
51
+ export declare function subtitleChange(host: TestHost, inner: Internals, expected: Json): void;
52
+ /** Implements `TestHost.setSource`. */
53
+ export declare function setSource(host: TestHost, _inner: Internals, patch: Partial<SourceState>): void;
54
+ /** The default handlers of this area's wire methods, by method name. */
55
+ export declare function mediaHandlers(host: TestHost, inner: Internals): Record<string, Handler>;