@vestra-player/sdk 1.5.9 → 1.5.12

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.
@@ -3,17 +3,36 @@ import { HostError } from "../wire.ts";
3
3
  import type { PackageManifest, PackageSource } from "./types.ts";
4
4
  import type { Json, LayoutSlot, LocalServices, PlaybackStats, Transport, WireSystemInfo } from "../wire.ts";
5
5
  import { COMMAND_LIMITS, MENU_IDS } from "../commands.ts";
6
+ import { UI_VALUE_LIMITS } from "./values.ts";
6
7
  import type { CommandDeclaration } from "../commands.ts";
7
8
  import type { SourceState } from "../source.ts";
8
9
  import type { SubtitleState } from "../subtitles.ts";
9
10
  import type { RenderSettings, ViewSettings } from "./types.ts";
10
11
  import type { MediaJob } from "../media.ts";
11
- import type { LanguagePreference, Locale, Plugin, TemplateArgs, ThemeId } from "./types.ts";
12
- import { LOCALES } from "../i18n.ts";
12
+ import type { LanguagePreference, Locale, Plugin, TemplateArgs, TextValue, ThemeId } from "./types.ts";
13
+ import { LOCALES, LIST_SEPARATORS, TEXT_LIMITS } from "../i18n.ts";
13
14
  /** Throw one of these from `host.handle(...)` to simulate a definite refusal. */
14
15
  export { HostError };
15
16
  /** The player's command caps, which the test host enforces as well. */
16
17
  export { COMMAND_LIMITS, MENU_IDS };
18
+ /** The player's property value limits, which the test host enforces as well. */
19
+ export { UI_VALUE_LIMITS };
20
+ /**
21
+ * The player's limits on one text value ({@link Text}, {@link TextList},
22
+ * with everything nested in it), which the test host enforces as well:
23
+ * nesting depth 8, 1024 texts, lists and arguments in all, 16 arguments per
24
+ * text, 256 items per list, 4096 bytes per source text or string argument.
25
+ *
26
+ * @since 1.5.11
27
+ */
28
+ export { TEXT_LIMITS };
29
+ /**
30
+ * The separator of each {@link ListStyle} per interface language, as the
31
+ * player joins a {@link TextList}.
32
+ *
33
+ * @since 1.5.11
34
+ */
35
+ export { LIST_SEPARATORS };
17
36
  /**
18
37
  * The player's interface languages, English (the source) first, as the
19
38
  * player's own table: `["en", "zh-Hans", "ja", "ko", "de", "fr", "es"]`.
@@ -76,6 +95,19 @@ export interface FakeWindow {
76
95
  */
77
96
  userClose(): void;
78
97
  }
98
+ /**
99
+ * A file dialog as the player shows it ({@link TestHost.fileDialogs}).
100
+ *
101
+ * @since 1.5.11
102
+ */
103
+ export interface FileDialogText {
104
+ /** Which dialog. */
105
+ kind: "openFile" | "openFiles" | "openDirectory" | "saveFile";
106
+ /** Its title as shown, if the plugin gave one. */
107
+ title?: string;
108
+ /** The proposed file name as shown, if the plugin gave one. */
109
+ fileName?: string;
110
+ }
79
111
  /**
80
112
  * A command the plugin registered with `plugin.commands.add`, as the test
81
113
  * host keeps it. Get one with {@link TestHost.command} (the plugin's own) or
@@ -110,6 +142,14 @@ export interface FakeCommand {
110
142
  title: string;
111
143
  /** Each segment of the path as shown; empty without a path. */
112
144
  path: string[];
145
+ /**
146
+ * The disabled or busy reason as shown, `null` without one (a plugin
147
+ * command's; not enumerable, so comparing `display` with
148
+ * `{ title, path }` still works).
149
+ *
150
+ * @since 1.5.11
151
+ */
152
+ reason?: string | null;
113
153
  };
114
154
  /** The command state the player last received (after the update pass). */
115
155
  state: {
@@ -119,8 +159,8 @@ export interface FakeCommand {
119
159
  checked: boolean | null;
120
160
  /** `true` while the plugin reports the command as busy. */
121
161
  busy: boolean;
122
- /** The reason shown when the command is disabled or busy. */
123
- reason: string | null;
162
+ /** The reason the plugin sent (source text or a text value; see `display.reason`). */
163
+ reason: TextValue | null;
124
164
  };
125
165
  /**
126
166
  * The state revision the player holds; increases with every state update
@@ -348,7 +388,7 @@ export declare class TestHost implements Transport {
348
388
  *
349
389
  * @since 1.5.9
350
390
  */
351
- translate(text: string, args?: TemplateArgs): string;
391
+ translate(text: TextValue, args?: TemplateArgs): string;
352
392
  /**
353
393
  * The saved interface language (`plugin.appearance.language`), changed by
354
394
  * `appearance.setLanguage` or {@link TestHost.setLanguage}.
@@ -383,6 +423,15 @@ export declare class TestHost implements Transport {
383
423
  /** The checkbox, if any. */
384
424
  checkbox?: string;
385
425
  }[];
426
+ /**
427
+ * Every file dialog (`openFile`, `openFiles`, `openDirectory`,
428
+ * `saveFile`) as the player shows it: the title translated, a text file
429
+ * name translated, newest last. The options the plugin sent are in
430
+ * `host.sent("dialogs.open")`.
431
+ *
432
+ * @since 1.5.11
433
+ */
434
+ readonly fileDialogs: FileDialogText[];
386
435
  /** The sequence provider's flags, `null` without a provider. */
387
436
  sequence: {
388
437
  /** The provider's `canNext`, as last evaluated. */
@@ -1193,11 +1242,20 @@ export declare class TestHost implements Transport {
1193
1242
  failStream(producerId: number, message: string): void;
1194
1243
  /**
1195
1244
  * Press a binding like the host's table would: panel-focus bindings first
1196
- * when `panel` is true, then global ones; the earliest registration wins a
1197
- * conflict. Resolves after the command ran; rejects when nothing is bound
1198
- * or the command is disabled or busy. Bindings use the reference syntax
1199
- * (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`); the player's core actions
1200
- * match first, and user overrides in `bindings.overlay` apply.
1245
+ * when `panel` is true, then global ones; within a scope the earliest
1246
+ * registration first (the player's own commands before the plugin's), as
1247
+ * the conflict rule orders them. A disabled command lets the key pass to
1248
+ * the next command bound to it (SDK 1.5.12, as player 0.4.3): the one it
1249
+ * shadowed, then the global scope. The first enabled one runs; `press`
1250
+ * resolves after it ran (`"ran"`), or rejects when it is busy. When only
1251
+ * disabled commands hold the key, the first one with a `reason` shows the
1252
+ * reason on the OSD and eats the key (`"reason"`, see `host.osd`);
1253
+ * without a reason the key goes on to the focused control (`"passed"`,
1254
+ * nothing runs). Rejects when nothing is bound. Bindings use the
1255
+ * reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`), and user
1256
+ * overrides in `bindings.overlay` apply.
1257
+ *
1258
+ * @returns What the key did: `"ran"`, `"reason"` or `"passed"`.
1201
1259
  *
1202
1260
  * @example
1203
1261
  * ```ts
@@ -1207,7 +1265,7 @@ export declare class TestHost implements Transport {
1207
1265
  */
1208
1266
  press(binding: string, options?: {
1209
1267
  panel?: boolean;
1210
- }): Promise<void>;
1268
+ }): Promise<"ran" | "reason" | "passed">;
1211
1269
  /**
1212
1270
  * Bindings declared by more than one command, each as
1213
1271
  * `[binding, winner, ...ignored]` (command ids; core actions as
@@ -18,8 +18,9 @@
18
18
  * value ranges (an out-of-range `filter.set` ends in `failed` with
19
19
  * `invalid`), an empty chain removes the plugin's chain, a chain on a
20
20
  * generated stream fails `unsupported` (the player's own conformance
21
- * cases check all three), and the command limits ({@link COMMAND_LIMITS})
22
- * and menu ids ({@link MENU_IDS}) are enforced.
21
+ * cases check all three), and the command limits ({@link COMMAND_LIMITS}),
22
+ * property value limits ({@link UI_VALUE_LIMITS}) and menu ids
23
+ * ({@link MENU_IDS}) are enforced.
23
24
  *
24
25
  * Typical workflow: start the plugin with {@link startPlugin} (it reads the
25
26
  * plugin's `manifest.json`), drive it the way the user or another plugin
@@ -65,15 +66,34 @@ import { extname, isAbsolute, join } from "node:path";
65
66
  import { fileURLToPath } from "node:url";
66
67
  import { HostError } from "../wire.js";
67
68
  import { COMMAND_LIMITS, MENU_IDS } from "../commands.js";
69
+ import { UI_VALUE_LIMITS, valueLimitProblem } from "./values.js";
68
70
  import { nodeProblem } from "../processing.js";
69
71
  import { RENDER_DEFAULTS, renderProblem, VIEW_DEFAULTS, viewProblem, } from "./view.js";
70
72
  import { createPlugin } from "./index.js";
71
73
  import { THEMES } from "./appearance.js";
72
- import { Catalog, compileCatalog, format as formatTemplate, HOST_GROUPS, HOST_ZH_HANS, LOCALES, menuKeys, parsePo, } from "../i18n.js";
74
+ import { Catalog, format as formatTemplate, HOST_GROUPS, HOST_ZH_HANS, LOCALES, fitColumns, isTextValue, LIST_SEPARATORS, loadPackageCatalog, menuKeys, renderTemplateArgs, renderText, resolveTexts, TEXT_LIMITS, textKey, textProblem, } from "../i18n.js";
73
75
  /** Throw one of these from `host.handle(...)` to simulate a definite refusal. */
74
76
  export { HostError };
75
77
  /** The player's command caps, which the test host enforces as well. */
76
78
  export { COMMAND_LIMITS, MENU_IDS };
79
+ /** The player's property value limits, which the test host enforces as well. */
80
+ export { UI_VALUE_LIMITS };
81
+ /**
82
+ * The player's limits on one text value ({@link Text}, {@link TextList},
83
+ * with everything nested in it), which the test host enforces as well:
84
+ * nesting depth 8, 1024 texts, lists and arguments in all, 16 arguments per
85
+ * text, 256 items per list, 4096 bytes per source text or string argument.
86
+ *
87
+ * @since 1.5.11
88
+ */
89
+ export { TEXT_LIMITS };
90
+ /**
91
+ * The separator of each {@link ListStyle} per interface language, as the
92
+ * player joins a {@link TextList}.
93
+ *
94
+ * @since 1.5.11
95
+ */
96
+ export { LIST_SEPARATORS };
77
97
  /**
78
98
  * The player's interface languages, English (the source) first, as the
79
99
  * player's own table: `["en", "zh-Hans", "ja", "ko", "de", "fr", "es"]`.
@@ -303,16 +323,7 @@ export class TestHost {
303
323
  * @since 1.5.9
304
324
  */
305
325
  loadCatalog(directory) {
306
- this.#catalog = new Catalog();
307
- if (this.locale === "en")
308
- return;
309
- const base = join(directory, "locales", this.locale);
310
- if (existsSync(`${base}.json`)) {
311
- const compiled = JSON.parse(readFileSync(`${base}.json`, "utf8"));
312
- this.#catalog = new Catalog(compiled.messages ?? {});
313
- }
314
- else if (existsSync(`${base}.po`))
315
- this.#catalog = new Catalog(compileCatalog(this.locale, parsePo(readFileSync(`${base}.po`, "utf8")).entries).messages);
326
+ this.#catalog = loadPackageCatalog(directory, this.locale);
316
327
  }
317
328
  /**
318
329
  * A text of the plugin as the player shows it: translated with the
@@ -331,25 +342,69 @@ export class TestHost {
331
342
  * @since 1.5.9
332
343
  */
333
344
  translate(text, args) {
334
- return this.#catalog.template(text, args ?? null);
345
+ if (typeof text !== "string")
346
+ return renderText(text, this.#catalog, this.locale);
347
+ const values = renderTemplateArgs(args, this.#catalog, this.locale);
348
+ return this.#catalog.template(text, values ?? null);
349
+ }
350
+ /**
351
+ * A text of a request as the player shows it, refused as the player
352
+ * refuses it: a string is a template (formatted with `args`, if given), a
353
+ * text value carries its own arguments.
354
+ */
355
+ #shown(text, args, what) {
356
+ if (args !== undefined && args !== null) {
357
+ const problem = textProblem(args, "args");
358
+ if (problem)
359
+ throw new HostError("invalid", problem);
360
+ }
361
+ if (typeof text === "string")
362
+ return this.translate(text, (args ?? undefined));
363
+ if (!isTextValue(text))
364
+ throw new HostError("invalid", `${what} requires a string or a text`);
365
+ const problem = textProblem(text);
366
+ if (problem)
367
+ throw new HostError("invalid", `${what}: ${problem}`);
368
+ if (args !== undefined && args !== null)
369
+ throw new HostError("invalid", `${what}: a text value takes no args`);
370
+ return this.translate(text);
371
+ }
372
+ /** A declared text: a string is translated as it is, a text rendered. */
373
+ #declared(text, what) {
374
+ return this.#shown(text, undefined, what);
375
+ }
376
+ /** Property values with their text values shown, as the player does first. */
377
+ #shownValues(values) {
378
+ const resolved = resolveTexts(values, this.#catalog, this.locale);
379
+ if ("problem" in resolved)
380
+ throw new HostError("invalid", resolved.problem);
381
+ return resolved.value;
335
382
  }
336
383
  /** A text of the player itself, in the player's language (see `locale`). */
337
384
  #hostText(text, args) {
338
385
  const zh = this.#locale === undefined || this.#locale === "zh-Hans";
339
386
  const shown = (zh && (HOST_ZH_HANS[text] ?? TEST_ZH_HANS[text])) || text;
340
- return args === undefined ? shown : formatTemplate(shown, args);
387
+ return args === undefined
388
+ ? shown
389
+ : formatTemplate(shown, args);
341
390
  }
342
391
  /** A declared menu path and title as the player shows them. */
343
- #display(path, title) {
344
- const keys = menuKeys(path);
345
- return {
392
+ #display(path, title, reason = null) {
393
+ const keys = menuKeys(path.map((segment) => typeof segment === "string" ? segment : textKey(segment)));
394
+ const display = {
346
395
  title: this.translate(title),
347
396
  // The player's submenus by key, everything else as declared.
348
397
  path: keys.map((segment, index) => index < keys.length - 1 &&
349
398
  HOST_GROUPS.includes(keys.slice(0, index + 1).join("/"))
350
399
  ? this.#hostText(segment)
351
- : this.translate(path[index] ?? segment)),
400
+ : this.translate((path[index] ?? segment))),
352
401
  };
402
+ // Not enumerable: `deepEqual(display, { title, path })` keeps passing.
403
+ Object.defineProperty(display, "reason", {
404
+ value: reason === null ? null : this.translate(reason),
405
+ enumerable: false,
406
+ });
407
+ return display;
353
408
  }
354
409
  /**
355
410
  * The saved interface language (`plugin.appearance.language`), changed by
@@ -377,6 +432,15 @@ export class TestHost {
377
432
  * @since 1.5.9
378
433
  */
379
434
  confirmTexts = [];
435
+ /**
436
+ * Every file dialog (`openFile`, `openFiles`, `openDirectory`,
437
+ * `saveFile`) as the player shows it: the title translated, a text file
438
+ * name translated, newest last. The options the plugin sent are in
439
+ * `host.sent("dialogs.open")`.
440
+ *
441
+ * @since 1.5.11
442
+ */
443
+ fileDialogs = [];
380
444
  /** The sequence provider's flags, `null` without a provider. */
381
445
  sequence = null;
382
446
  /**
@@ -578,6 +642,42 @@ export class TestHost {
578
642
  trustedExecution: true,
579
643
  };
580
644
  }
645
+ /** A command state's reason, refused as the player refuses it. */
646
+ #checkReason(state) {
647
+ const reason = state?.reason;
648
+ if (reason !== null && reason !== undefined)
649
+ this.#declared(reason, "reason");
650
+ }
651
+ /**
652
+ * Refuse property values over the player's limits ({@link UI_VALUE_LIMITS})
653
+ * with `limit`, naming the property, as the player does.
654
+ */
655
+ #checkValues(values) {
656
+ for (const [property, value] of Object.entries(values)) {
657
+ const problem = valueLimitProblem(value);
658
+ if (problem)
659
+ throw new HostError("limit", `${property}: ${problem}`);
660
+ }
661
+ }
662
+ /**
663
+ * A panel's or settings page's initial properties, as the player keeps
664
+ * them: one over the limits is left out (the property keeps its default)
665
+ * and reported with `ui.error`, the request itself succeeds.
666
+ */
667
+ #initialValues(values) {
668
+ const kept = {};
669
+ for (const [property, value] of Object.entries(values)) {
670
+ const problem = valueLimitProblem(value);
671
+ if (problem)
672
+ this.emit("ui.error", {
673
+ code: "limit",
674
+ message: `${property}: ${problem}`,
675
+ });
676
+ else
677
+ kept[property] = value;
678
+ }
679
+ return kept;
680
+ }
581
681
  #seeImages(values) {
582
682
  for (const value of Object.values(values)) {
583
683
  const path = this.imagePath(value);
@@ -1649,11 +1749,20 @@ export class TestHost {
1649
1749
  }
1650
1750
  /**
1651
1751
  * Press a binding like the host's table would: panel-focus bindings first
1652
- * when `panel` is true, then global ones; the earliest registration wins a
1653
- * conflict. Resolves after the command ran; rejects when nothing is bound
1654
- * or the command is disabled or busy. Bindings use the reference syntax
1655
- * (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`); the player's core actions
1656
- * match first, and user overrides in `bindings.overlay` apply.
1752
+ * when `panel` is true, then global ones; within a scope the earliest
1753
+ * registration first (the player's own commands before the plugin's), as
1754
+ * the conflict rule orders them. A disabled command lets the key pass to
1755
+ * the next command bound to it (SDK 1.5.12, as player 0.4.3): the one it
1756
+ * shadowed, then the global scope. The first enabled one runs; `press`
1757
+ * resolves after it ran (`"ran"`), or rejects when it is busy. When only
1758
+ * disabled commands hold the key, the first one with a `reason` shows the
1759
+ * reason on the OSD and eats the key (`"reason"`, see `host.osd`);
1760
+ * without a reason the key goes on to the focused control (`"passed"`,
1761
+ * nothing runs). Rejects when nothing is bound. Bindings use the
1762
+ * reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`), and user
1763
+ * overrides in `bindings.overlay` apply.
1764
+ *
1765
+ * @returns What the key did: `"ran"`, `"reason"` or `"passed"`.
1657
1766
  *
1658
1767
  * @example
1659
1768
  * ```ts
@@ -1663,11 +1772,28 @@ export class TestHost {
1663
1772
  */
1664
1773
  async press(binding, options = {}) {
1665
1774
  const claims = this.#claims();
1666
- const id = (options.panel ? claims.get(`panel ${binding}`)?.[0] : undefined) ??
1667
- claims.get(`global ${binding}`)?.[0];
1668
- if (!id)
1775
+ const ids = [
1776
+ ...(options.panel ? (claims.get(`panel ${binding}`) ?? []) : []),
1777
+ ...(claims.get(`global ${binding}`) ?? []),
1778
+ ];
1779
+ if (!ids.length)
1669
1780
  throw new Error(`test host: nothing is bound to ${binding}`);
1670
- await (this.coreCommands.get(id) ?? this.command(id)).run();
1781
+ const command = (id) => this.coreCommands.get(id) ?? this.command(id);
1782
+ const enabled = ids.find((id) => command(id).state.enabled);
1783
+ if (enabled !== undefined) {
1784
+ await command(enabled).run();
1785
+ return "ran";
1786
+ }
1787
+ for (const id of ids) {
1788
+ const reason = this.commands.has(id)
1789
+ ? command(id).display.reason
1790
+ : command(id).state.reason;
1791
+ if (typeof reason === "string" && reason !== "") {
1792
+ this.osd.push(reason);
1793
+ return "reason";
1794
+ }
1795
+ }
1796
+ return "passed";
1671
1797
  }
1672
1798
  /**
1673
1799
  * Bindings declared by more than one command, each as
@@ -1789,9 +1915,12 @@ export class TestHost {
1789
1915
  return {
1790
1916
  ...this.#split(key),
1791
1917
  // What the user sees, like the player's binding table.
1792
- title: shown?.display.title ?? declaration.title,
1918
+ title: shown?.display.title ?? String(declaration.title),
1793
1919
  path: declaration.path?.length
1794
- ? (shown?.display.path ?? declaration.path)
1920
+ ? (shown?.display.path ?? declaration.path.map(String))
1921
+ : null,
1922
+ key: declaration.path?.length
1923
+ ? menuKeys(declaration.path.map((segment) => typeof segment === "string" ? segment : textKey(segment)))
1795
1924
  : null,
1796
1925
  scope: scope,
1797
1926
  defaults: declaration.bindings,
@@ -2127,8 +2256,12 @@ export class TestHost {
2127
2256
  });
2128
2257
  throw new HostError("resolution", message);
2129
2258
  };
2130
- if (typeof p.error === "string")
2131
- fail(p.error.trim() ? p.error.slice(0, 2048) : "resolver failed");
2259
+ // A text value (`plugin.i18n.error`) is shown translated, like the player.
2260
+ const error = isTextValue(p.error)
2261
+ ? this.#declared(p.error, "error")
2262
+ : p.error;
2263
+ if (typeof error === "string")
2264
+ fail(error.trim() ? [...error].slice(0, 2048).join("") : "resolver failed");
2132
2265
  const result = (p.result ?? {});
2133
2266
  const producer = this.producers.get(Number(result.producerId));
2134
2267
  if (result.kind !== "stream")
@@ -2232,10 +2365,15 @@ export class TestHost {
2232
2365
  throw new HostError("limit", "command registry limit reached");
2233
2366
  const host = this;
2234
2367
  const declaration = p.declaration;
2368
+ // Like the player: the menu texts and the reason are checked (and
2369
+ // shown) when they arrive.
2370
+ for (const segment of [declaration.title, ...(declaration.path ?? [])])
2371
+ this.#declared(segment, "a menu text");
2372
+ this.#checkReason(p.state);
2235
2373
  this.commands.set(id, {
2236
2374
  declaration,
2237
2375
  get display() {
2238
- return host.#display(declaration.path ?? [], declaration.title);
2376
+ return host.#display(declaration.path ?? [], declaration.title, this.state.reason);
2239
2377
  },
2240
2378
  state: p.state,
2241
2379
  revision: Number(p.revision),
@@ -2256,6 +2394,7 @@ export class TestHost {
2256
2394
  const command = this.command(String(p.id));
2257
2395
  if (Number(p.revision) <= command.revision)
2258
2396
  throw new HostError("command", `stale command revision ${p.revision}`);
2397
+ this.#checkReason(p.state);
2259
2398
  command.revision = Number(p.revision);
2260
2399
  command.state = p.state;
2261
2400
  return { revision: command.revision };
@@ -2264,12 +2403,14 @@ export class TestHost {
2264
2403
  const id = String(p.id);
2265
2404
  if (this.windows.has(id))
2266
2405
  throw new HostError("conflict", "view already exists");
2267
- this.#seeImages(p.properties);
2406
+ const properties = this.#shownValues(p.properties);
2407
+ this.#checkValues(properties);
2408
+ this.#seeImages(properties);
2268
2409
  const host = this;
2269
2410
  this.windows.set(id, {
2270
2411
  file: String(p.file),
2271
2412
  visible: true,
2272
- props: { ...p.properties },
2413
+ props: { ...properties },
2273
2414
  callbacks: p.callbacks,
2274
2415
  userClose() {
2275
2416
  host.windows.delete(id);
@@ -2287,8 +2428,10 @@ export class TestHost {
2287
2428
  const window = this.windows.get(String(p.id));
2288
2429
  if (!window)
2289
2430
  throw new HostError("missing", "view not found");
2290
- this.#seeImages([p.value]);
2291
- window.props[String(p.property)] = p.value;
2431
+ const value = this.#shownValues(p.value);
2432
+ this.#checkValues({ [String(p.property)]: value });
2433
+ this.#seeImages([value]);
2434
+ window.props[String(p.property)] = value;
2292
2435
  },
2293
2436
  "ui.show": (p) => {
2294
2437
  const window = this.windows.get(String(p.id));
@@ -2320,14 +2463,15 @@ export class TestHost {
2320
2463
  throw taken(this.#hostText("The sidebar holds at most {} tabs", [
2321
2464
  MAX_SIDEBAR_TABS,
2322
2465
  ]));
2323
- this.#seeImages(p.properties);
2466
+ const properties = this.#initialValues(this.#shownValues(p.properties));
2467
+ this.#seeImages(properties);
2324
2468
  const host = this;
2325
2469
  this.panel = {
2326
2470
  slot: String(p.slot),
2327
2471
  file: String(p.file),
2328
2472
  component: String(p.component),
2329
2473
  visible: true,
2330
- props: { ...p.properties },
2474
+ props: { ...properties },
2331
2475
  locks: null,
2332
2476
  emit(callback, ...args) {
2333
2477
  host.emit("ui.callback", {
@@ -2400,15 +2544,21 @@ export class TestHost {
2400
2544
  state.detached = p.detached;
2401
2545
  },
2402
2546
  "osd.show": (p) => {
2403
- const text = this.translate(String(p.text), p.args).replace(/[\n\r]/g, " ");
2547
+ const text = this.#shown(p.text, p.args, "text").replace(/[\n\r]/g, " ");
2404
2548
  if (!text.trim() || [...text].length > 200)
2405
2549
  throw new HostError("invalid", "osd text needs 1..200 characters");
2406
2550
  this.osd.push(text);
2407
2551
  },
2408
- "osd.panel": (p) => void (this.osdPanel =
2409
- p.text === null
2410
- ? null
2411
- : this.translate(String(p.text), p.args)),
2552
+ "osd.panel": (p) => {
2553
+ if (p.text === null)
2554
+ return void (this.osdPanel = null);
2555
+ const max = p.maxColumns;
2556
+ if (max !== undefined &&
2557
+ !(Number.isInteger(max) && Number(max) >= 8 && Number(max) <= 1000))
2558
+ throw new HostError("invalid", "maxColumns is an integer from 8 to 1000");
2559
+ const text = this.#shown(p.text, p.args, "text");
2560
+ this.osdPanel = max === undefined ? text : fitColumns(text, Number(max));
2561
+ },
2412
2562
  "playback.stats": () => this.stats,
2413
2563
  "audio.addTrack": (p) => {
2414
2564
  this.#audioCondition(p.expected);
@@ -2541,8 +2691,11 @@ export class TestHost {
2541
2691
  this.emit("sequence.step", { direction });
2542
2692
  },
2543
2693
  "ui.setMount": (p) => {
2544
- this.#seeImages([p.value]);
2545
- this.mounted().props[String(p.property)] = p.value;
2694
+ this.mounted();
2695
+ const value = this.#shownValues(p.value);
2696
+ this.#checkValues({ [String(p.property)]: value });
2697
+ this.#seeImages([value]);
2698
+ this.mounted().props[String(p.property)] = value;
2546
2699
  },
2547
2700
  "timeline.marks": () => ({ marks: this.timeline.marks }),
2548
2701
  "drops.provide": (p) => {
@@ -2579,7 +2732,8 @@ export class TestHost {
2579
2732
  throw new HostError("invalid", `page ${id} already exists`);
2580
2733
  if (this.settings.pages.size >= 8)
2581
2734
  throw new HostError("invalid", "a plugin has at most 8 settings pages");
2582
- this.#seeImages(p.properties);
2735
+ const properties = this.#initialValues(this.#shownValues(p.properties));
2736
+ this.#seeImages(properties);
2583
2737
  const host = this;
2584
2738
  this.settings.pages.set(id, {
2585
2739
  category: String(p.category),
@@ -2588,7 +2742,7 @@ export class TestHost {
2588
2742
  file: String(p.file),
2589
2743
  component: String(p.component),
2590
2744
  global: String(p.global),
2591
- props: { ...p.properties },
2745
+ props: { ...properties },
2592
2746
  emit(callback, ...args) {
2593
2747
  host.emit("ui.callback", {
2594
2748
  view: `$settings:${id}`,
@@ -2602,8 +2756,10 @@ export class TestHost {
2602
2756
  const page = this.settings.pages.get(String(p.id));
2603
2757
  if (!page)
2604
2758
  throw new HostError("missing", "page not registered");
2605
- this.#seeImages([p.value]);
2606
- page.props[String(p.property)] = p.value;
2759
+ const value = this.#shownValues(p.value);
2760
+ this.#checkValues({ [String(p.property)]: value });
2761
+ this.#seeImages([value]);
2762
+ page.props[String(p.property)] = value;
2607
2763
  },
2608
2764
  "settings.unregister": (p) => {
2609
2765
  this.settings.pages.delete(String(p.id));
@@ -2667,10 +2823,11 @@ export class TestHost {
2667
2823
  if (p.sourceId !== undefined &&
2668
2824
  p.sourceId !== this.source.stamp.generation)
2669
2825
  return { applied: false };
2670
- const marks = p.marks;
2826
+ const sent = p.marks;
2671
2827
  const duration = this.source.duration;
2672
- if (!Array.isArray(marks) || marks.length > 1000)
2828
+ if (!Array.isArray(sent) || sent.length > 1000)
2673
2829
  throw new HostError("invalid", "a layer holds at most 1000 marks");
2830
+ const marks = sent.map((mark) => ({ ...mark }));
2674
2831
  for (const mark of marks) {
2675
2832
  const time = mark.time;
2676
2833
  if (typeof time !== "number" ||
@@ -2680,6 +2837,9 @@ export class TestHost {
2680
2837
  throw new HostError("invalid", `time ${time} is outside the media`);
2681
2838
  if (!["chapter", "bookmark", "marker"].includes(String(mark.kind)))
2682
2839
  throw new HostError("invalid", "kind is chapter, bookmark or marker");
2840
+ // A text value is shown translated (`plugin.i18n.text`).
2841
+ if (isTextValue(mark.title))
2842
+ mark.title = this.#declared(mark.title, "title");
2683
2843
  if (mark.title !== undefined &&
2684
2844
  mark.title !== null &&
2685
2845
  (typeof mark.title !== "string" || [...mark.title].length > 100))
@@ -2695,10 +2855,9 @@ export class TestHost {
2695
2855
  "ui.interaction": (p) => void (this.mounted().locks = p),
2696
2856
  "dialogs.confirm": (p) => {
2697
2857
  this.confirms.push(p);
2698
- const args = p.args;
2699
- const shown = (field, formatted = false) => typeof p[field] === "string"
2700
- ? this.translate(p[field], formatted ? args : undefined)
2701
- : undefined;
2858
+ const shown = (field, formatted = false) => p[field] === undefined
2859
+ ? undefined
2860
+ : this.#shown(p[field], formatted && typeof p[field] === "string" ? p.args : undefined, field);
2702
2861
  this.confirmTexts.push({
2703
2862
  message: shown("message", true),
2704
2863
  ...(p.detail === undefined ? {} : { detail: shown("detail", true) }),
@@ -2769,7 +2928,20 @@ export class TestHost {
2769
2928
  loop: loop && { a: loop.a, b: loop.b, count: loop.count ?? null },
2770
2929
  });
2771
2930
  },
2772
- "dialogs.open": () => {
2931
+ "dialogs.open": (p) => {
2932
+ const title = p.title === undefined ? undefined : this.#declared(p.title, "title");
2933
+ // A string file name is data; a text is shown translated, a path
2934
+ // separator in the translation replaced.
2935
+ const fileName = p.fileName === undefined
2936
+ ? undefined
2937
+ : typeof p.fileName === "string"
2938
+ ? p.fileName
2939
+ : this.#declared(p.fileName, "fileName").replace(/[/\\]/g, "-");
2940
+ this.fileDialogs.push({
2941
+ kind: String(p.kind),
2942
+ ...(title === undefined ? {} : { title }),
2943
+ ...(fileName === undefined ? {} : { fileName }),
2944
+ });
2773
2945
  const answer = this.dialogAnswers.shift();
2774
2946
  if (answer === undefined)
2775
2947
  throw new Error("test host: no dialog answer queued");
@@ -3098,7 +3270,11 @@ export class TestHost {
3098
3270
  : p.select === "secondary"
3099
3271
  ? "secondary"
3100
3272
  : "primary";
3101
- const title = typeof p.title === "string" ? p.title : null;
3273
+ const title = isTextValue(p.title)
3274
+ ? this.#declared(p.title, "title")
3275
+ : typeof p.title === "string"
3276
+ ? p.title
3277
+ : null;
3102
3278
  if (title !== null && ([...title].length < 1 || [...title].length > 200))
3103
3279
  throw new HostError("invalid", "subtitle title needs 1..200 characters");
3104
3280
  const bytes = Buffer.concat(upload.bytes);
@@ -104,7 +104,9 @@ class Layer {
104
104
  marks: marks.map((mark) => ({
105
105
  time: mark?.time,
106
106
  kind: mark?.kind,
107
- ...(mark?.title === undefined ? {} : { title: mark.title }),
107
+ ...(mark?.title === undefined
108
+ ? {}
109
+ : { title: mark.title }),
108
110
  })),
109
111
  };
110
112
  if (options.ifCurrent)