@vestra-player/sdk 1.5.9 → 1.5.11

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.
@@ -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);
@@ -1789,9 +1889,12 @@ export class TestHost {
1789
1889
  return {
1790
1890
  ...this.#split(key),
1791
1891
  // What the user sees, like the player's binding table.
1792
- title: shown?.display.title ?? declaration.title,
1892
+ title: shown?.display.title ?? String(declaration.title),
1793
1893
  path: declaration.path?.length
1794
- ? (shown?.display.path ?? declaration.path)
1894
+ ? (shown?.display.path ?? declaration.path.map(String))
1895
+ : null,
1896
+ key: declaration.path?.length
1897
+ ? menuKeys(declaration.path.map((segment) => typeof segment === "string" ? segment : textKey(segment)))
1795
1898
  : null,
1796
1899
  scope: scope,
1797
1900
  defaults: declaration.bindings,
@@ -2127,8 +2230,12 @@ export class TestHost {
2127
2230
  });
2128
2231
  throw new HostError("resolution", message);
2129
2232
  };
2130
- if (typeof p.error === "string")
2131
- fail(p.error.trim() ? p.error.slice(0, 2048) : "resolver failed");
2233
+ // A text value (`plugin.i18n.error`) is shown translated, like the player.
2234
+ const error = isTextValue(p.error)
2235
+ ? this.#declared(p.error, "error")
2236
+ : p.error;
2237
+ if (typeof error === "string")
2238
+ fail(error.trim() ? [...error].slice(0, 2048).join("") : "resolver failed");
2132
2239
  const result = (p.result ?? {});
2133
2240
  const producer = this.producers.get(Number(result.producerId));
2134
2241
  if (result.kind !== "stream")
@@ -2232,10 +2339,15 @@ export class TestHost {
2232
2339
  throw new HostError("limit", "command registry limit reached");
2233
2340
  const host = this;
2234
2341
  const declaration = p.declaration;
2342
+ // Like the player: the menu texts and the reason are checked (and
2343
+ // shown) when they arrive.
2344
+ for (const segment of [declaration.title, ...(declaration.path ?? [])])
2345
+ this.#declared(segment, "a menu text");
2346
+ this.#checkReason(p.state);
2235
2347
  this.commands.set(id, {
2236
2348
  declaration,
2237
2349
  get display() {
2238
- return host.#display(declaration.path ?? [], declaration.title);
2350
+ return host.#display(declaration.path ?? [], declaration.title, this.state.reason);
2239
2351
  },
2240
2352
  state: p.state,
2241
2353
  revision: Number(p.revision),
@@ -2256,6 +2368,7 @@ export class TestHost {
2256
2368
  const command = this.command(String(p.id));
2257
2369
  if (Number(p.revision) <= command.revision)
2258
2370
  throw new HostError("command", `stale command revision ${p.revision}`);
2371
+ this.#checkReason(p.state);
2259
2372
  command.revision = Number(p.revision);
2260
2373
  command.state = p.state;
2261
2374
  return { revision: command.revision };
@@ -2264,12 +2377,14 @@ export class TestHost {
2264
2377
  const id = String(p.id);
2265
2378
  if (this.windows.has(id))
2266
2379
  throw new HostError("conflict", "view already exists");
2267
- this.#seeImages(p.properties);
2380
+ const properties = this.#shownValues(p.properties);
2381
+ this.#checkValues(properties);
2382
+ this.#seeImages(properties);
2268
2383
  const host = this;
2269
2384
  this.windows.set(id, {
2270
2385
  file: String(p.file),
2271
2386
  visible: true,
2272
- props: { ...p.properties },
2387
+ props: { ...properties },
2273
2388
  callbacks: p.callbacks,
2274
2389
  userClose() {
2275
2390
  host.windows.delete(id);
@@ -2287,8 +2402,10 @@ export class TestHost {
2287
2402
  const window = this.windows.get(String(p.id));
2288
2403
  if (!window)
2289
2404
  throw new HostError("missing", "view not found");
2290
- this.#seeImages([p.value]);
2291
- window.props[String(p.property)] = p.value;
2405
+ const value = this.#shownValues(p.value);
2406
+ this.#checkValues({ [String(p.property)]: value });
2407
+ this.#seeImages([value]);
2408
+ window.props[String(p.property)] = value;
2292
2409
  },
2293
2410
  "ui.show": (p) => {
2294
2411
  const window = this.windows.get(String(p.id));
@@ -2320,14 +2437,15 @@ export class TestHost {
2320
2437
  throw taken(this.#hostText("The sidebar holds at most {} tabs", [
2321
2438
  MAX_SIDEBAR_TABS,
2322
2439
  ]));
2323
- this.#seeImages(p.properties);
2440
+ const properties = this.#initialValues(this.#shownValues(p.properties));
2441
+ this.#seeImages(properties);
2324
2442
  const host = this;
2325
2443
  this.panel = {
2326
2444
  slot: String(p.slot),
2327
2445
  file: String(p.file),
2328
2446
  component: String(p.component),
2329
2447
  visible: true,
2330
- props: { ...p.properties },
2448
+ props: { ...properties },
2331
2449
  locks: null,
2332
2450
  emit(callback, ...args) {
2333
2451
  host.emit("ui.callback", {
@@ -2400,15 +2518,21 @@ export class TestHost {
2400
2518
  state.detached = p.detached;
2401
2519
  },
2402
2520
  "osd.show": (p) => {
2403
- const text = this.translate(String(p.text), p.args).replace(/[\n\r]/g, " ");
2521
+ const text = this.#shown(p.text, p.args, "text").replace(/[\n\r]/g, " ");
2404
2522
  if (!text.trim() || [...text].length > 200)
2405
2523
  throw new HostError("invalid", "osd text needs 1..200 characters");
2406
2524
  this.osd.push(text);
2407
2525
  },
2408
- "osd.panel": (p) => void (this.osdPanel =
2409
- p.text === null
2410
- ? null
2411
- : this.translate(String(p.text), p.args)),
2526
+ "osd.panel": (p) => {
2527
+ if (p.text === null)
2528
+ return void (this.osdPanel = null);
2529
+ const max = p.maxColumns;
2530
+ if (max !== undefined &&
2531
+ !(Number.isInteger(max) && Number(max) >= 8 && Number(max) <= 1000))
2532
+ throw new HostError("invalid", "maxColumns is an integer from 8 to 1000");
2533
+ const text = this.#shown(p.text, p.args, "text");
2534
+ this.osdPanel = max === undefined ? text : fitColumns(text, Number(max));
2535
+ },
2412
2536
  "playback.stats": () => this.stats,
2413
2537
  "audio.addTrack": (p) => {
2414
2538
  this.#audioCondition(p.expected);
@@ -2541,8 +2665,11 @@ export class TestHost {
2541
2665
  this.emit("sequence.step", { direction });
2542
2666
  },
2543
2667
  "ui.setMount": (p) => {
2544
- this.#seeImages([p.value]);
2545
- this.mounted().props[String(p.property)] = p.value;
2668
+ this.mounted();
2669
+ const value = this.#shownValues(p.value);
2670
+ this.#checkValues({ [String(p.property)]: value });
2671
+ this.#seeImages([value]);
2672
+ this.mounted().props[String(p.property)] = value;
2546
2673
  },
2547
2674
  "timeline.marks": () => ({ marks: this.timeline.marks }),
2548
2675
  "drops.provide": (p) => {
@@ -2579,7 +2706,8 @@ export class TestHost {
2579
2706
  throw new HostError("invalid", `page ${id} already exists`);
2580
2707
  if (this.settings.pages.size >= 8)
2581
2708
  throw new HostError("invalid", "a plugin has at most 8 settings pages");
2582
- this.#seeImages(p.properties);
2709
+ const properties = this.#initialValues(this.#shownValues(p.properties));
2710
+ this.#seeImages(properties);
2583
2711
  const host = this;
2584
2712
  this.settings.pages.set(id, {
2585
2713
  category: String(p.category),
@@ -2588,7 +2716,7 @@ export class TestHost {
2588
2716
  file: String(p.file),
2589
2717
  component: String(p.component),
2590
2718
  global: String(p.global),
2591
- props: { ...p.properties },
2719
+ props: { ...properties },
2592
2720
  emit(callback, ...args) {
2593
2721
  host.emit("ui.callback", {
2594
2722
  view: `$settings:${id}`,
@@ -2602,8 +2730,10 @@ export class TestHost {
2602
2730
  const page = this.settings.pages.get(String(p.id));
2603
2731
  if (!page)
2604
2732
  throw new HostError("missing", "page not registered");
2605
- this.#seeImages([p.value]);
2606
- page.props[String(p.property)] = p.value;
2733
+ const value = this.#shownValues(p.value);
2734
+ this.#checkValues({ [String(p.property)]: value });
2735
+ this.#seeImages([value]);
2736
+ page.props[String(p.property)] = value;
2607
2737
  },
2608
2738
  "settings.unregister": (p) => {
2609
2739
  this.settings.pages.delete(String(p.id));
@@ -2667,10 +2797,11 @@ export class TestHost {
2667
2797
  if (p.sourceId !== undefined &&
2668
2798
  p.sourceId !== this.source.stamp.generation)
2669
2799
  return { applied: false };
2670
- const marks = p.marks;
2800
+ const sent = p.marks;
2671
2801
  const duration = this.source.duration;
2672
- if (!Array.isArray(marks) || marks.length > 1000)
2802
+ if (!Array.isArray(sent) || sent.length > 1000)
2673
2803
  throw new HostError("invalid", "a layer holds at most 1000 marks");
2804
+ const marks = sent.map((mark) => ({ ...mark }));
2674
2805
  for (const mark of marks) {
2675
2806
  const time = mark.time;
2676
2807
  if (typeof time !== "number" ||
@@ -2680,6 +2811,9 @@ export class TestHost {
2680
2811
  throw new HostError("invalid", `time ${time} is outside the media`);
2681
2812
  if (!["chapter", "bookmark", "marker"].includes(String(mark.kind)))
2682
2813
  throw new HostError("invalid", "kind is chapter, bookmark or marker");
2814
+ // A text value is shown translated (`plugin.i18n.text`).
2815
+ if (isTextValue(mark.title))
2816
+ mark.title = this.#declared(mark.title, "title");
2683
2817
  if (mark.title !== undefined &&
2684
2818
  mark.title !== null &&
2685
2819
  (typeof mark.title !== "string" || [...mark.title].length > 100))
@@ -2695,10 +2829,9 @@ export class TestHost {
2695
2829
  "ui.interaction": (p) => void (this.mounted().locks = p),
2696
2830
  "dialogs.confirm": (p) => {
2697
2831
  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;
2832
+ const shown = (field, formatted = false) => p[field] === undefined
2833
+ ? undefined
2834
+ : this.#shown(p[field], formatted && typeof p[field] === "string" ? p.args : undefined, field);
2702
2835
  this.confirmTexts.push({
2703
2836
  message: shown("message", true),
2704
2837
  ...(p.detail === undefined ? {} : { detail: shown("detail", true) }),
@@ -2769,7 +2902,20 @@ export class TestHost {
2769
2902
  loop: loop && { a: loop.a, b: loop.b, count: loop.count ?? null },
2770
2903
  });
2771
2904
  },
2772
- "dialogs.open": () => {
2905
+ "dialogs.open": (p) => {
2906
+ const title = p.title === undefined ? undefined : this.#declared(p.title, "title");
2907
+ // A string file name is data; a text is shown translated, a path
2908
+ // separator in the translation replaced.
2909
+ const fileName = p.fileName === undefined
2910
+ ? undefined
2911
+ : typeof p.fileName === "string"
2912
+ ? p.fileName
2913
+ : this.#declared(p.fileName, "fileName").replace(/[/\\]/g, "-");
2914
+ this.fileDialogs.push({
2915
+ kind: String(p.kind),
2916
+ ...(title === undefined ? {} : { title }),
2917
+ ...(fileName === undefined ? {} : { fileName }),
2918
+ });
2773
2919
  const answer = this.dialogAnswers.shift();
2774
2920
  if (answer === undefined)
2775
2921
  throw new Error("test host: no dialog answer queued");
@@ -3098,7 +3244,11 @@ export class TestHost {
3098
3244
  : p.select === "secondary"
3099
3245
  ? "secondary"
3100
3246
  : "primary";
3101
- const title = typeof p.title === "string" ? p.title : null;
3247
+ const title = isTextValue(p.title)
3248
+ ? this.#declared(p.title, "title")
3249
+ : typeof p.title === "string"
3250
+ ? p.title
3251
+ : null;
3102
3252
  if (title !== null && ([...title].length < 1 || [...title].length > 200))
3103
3253
  throw new HostError("invalid", "subtitle title needs 1..200 characters");
3104
3254
  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)