@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.
package/dist/i18n.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * Internal: the player's runner and tests reach it through
9
9
  * `@vestra-player/sdk/runtime`, `LOCALES` through `./testing`.
10
10
  */
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
11
13
  /**
12
14
  * The player's interface languages: English (the source language) first,
13
15
  * then the translations, as the player's own table (its tests check that
@@ -326,6 +328,254 @@ export function placeholders(text) {
326
328
  found.push(match[1]);
327
329
  return found.sort();
328
330
  }
331
+ // ---------------------------------------------------------------- texts
332
+ /**
333
+ * The ways a `TextList` is joined (`ListStyle`), as the player
334
+ * joins them in each interface language (`LIST_SEPARATORS`).
335
+ */
336
+ export const LIST_STYLES = [
337
+ "list",
338
+ "clauses",
339
+ "semicolons",
340
+ "sentences",
341
+ "lines",
342
+ ];
343
+ /**
344
+ * The separator of each list style per interface language, as the player's
345
+ * table (`i18n::text::SEPARATORS`; its tests check that they agree).
346
+ */
347
+ export const LIST_SEPARATORS = {
348
+ en: {
349
+ list: ", ",
350
+ clauses: ", ",
351
+ semicolons: "; ",
352
+ sentences: ". ",
353
+ lines: "\n",
354
+ },
355
+ "zh-Hans": {
356
+ list: "、",
357
+ clauses: ",",
358
+ semicolons: ";",
359
+ sentences: "。",
360
+ lines: "\n",
361
+ },
362
+ ja: {
363
+ list: "、",
364
+ clauses: "、",
365
+ semicolons: ";",
366
+ sentences: "。",
367
+ lines: "\n",
368
+ },
369
+ ko: {
370
+ list: ", ",
371
+ clauses: ", ",
372
+ semicolons: "; ",
373
+ sentences: ". ",
374
+ lines: "\n",
375
+ },
376
+ de: {
377
+ list: ", ",
378
+ clauses: ", ",
379
+ semicolons: "; ",
380
+ sentences: ". ",
381
+ lines: "\n",
382
+ },
383
+ fr: {
384
+ list: ", ",
385
+ clauses: ", ",
386
+ semicolons: " ; ",
387
+ sentences: ". ",
388
+ lines: "\n",
389
+ },
390
+ es: {
391
+ list: ", ",
392
+ clauses: ", ",
393
+ semicolons: "; ",
394
+ sentences: ". ",
395
+ lines: "\n",
396
+ },
397
+ };
398
+ /**
399
+ * The player's limits on one text value (a
400
+ * {@link @vestra-player/sdk!Text | Text} or
401
+ * {@link @vestra-player/sdk!TextList | TextList} with everything nested in it), as the player's
402
+ * `i18n::text` (its tests check that they agree).
403
+ */
404
+ export const TEXT_LIMITS = {
405
+ /** Nesting depth of texts and lists; the value itself is depth 0 (8). */
406
+ depth: 8,
407
+ /** Texts, lists and arguments in all, the value itself included (1024). */
408
+ nodes: 1024,
409
+ /** Arguments of one text (16). */
410
+ args: 16,
411
+ /** Items of one list (256). */
412
+ items: 256,
413
+ /** UTF-8 bytes of a source text or a string argument (4096). */
414
+ stringBytes: 4096,
415
+ };
416
+ const plainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
417
+ /** A text or list value (by its marker key). */
418
+ export function isTextValue(value) {
419
+ return (plainObject(value) && ("$vestraText" in value || "$vestraList" in value));
420
+ }
421
+ const utf8 = (text) => new TextEncoder().encode(text).length;
422
+ /**
423
+ * Why the player would refuse a text value or template arguments, or
424
+ * `null`. `args` mode checks template arguments (an array or object of
425
+ * arguments) instead of one value. The same rules and messages as the
426
+ * player's `i18n::text::check`.
427
+ */
428
+ export function textProblem(value, mode = "value") {
429
+ let nodes = 0;
430
+ const L = TEXT_LIMITS;
431
+ const args = (list, depth) => {
432
+ const values = Array.isArray(list)
433
+ ? list
434
+ : plainObject(list)
435
+ ? Object.values(list)
436
+ : null;
437
+ if (!values)
438
+ return "args is an array or an object";
439
+ if (values.length > L.args)
440
+ return `args holds at most ${L.args} values`;
441
+ for (const item of values) {
442
+ const problem = arg(item, depth);
443
+ if (problem)
444
+ return problem;
445
+ }
446
+ return null;
447
+ };
448
+ const arg = (item, depth) => {
449
+ if (typeof item === "number")
450
+ return Number.isFinite(item) ? count() : "a number argument is finite";
451
+ if (typeof item === "string")
452
+ return utf8(item) > L.stringBytes
453
+ ? `a string argument is at most ${L.stringBytes} bytes`
454
+ : count();
455
+ if (isTextValue(item))
456
+ return text(item, depth + 1);
457
+ return "args are strings, numbers, texts and lists";
458
+ };
459
+ const count = () => ++nodes > L.nodes ? `a text holds at most ${L.nodes} values` : null;
460
+ const text = (item, depth) => {
461
+ if (depth > L.depth)
462
+ return `texts nest at most ${L.depth} deep`;
463
+ if (!plainObject(item))
464
+ return "a text is a $vestraText or $vestraList object";
465
+ const over = count();
466
+ if (over)
467
+ return over;
468
+ if ("$vestraText" in item) {
469
+ const keys = Object.keys(item);
470
+ if (keys.some((key) => key !== "$vestraText" && key !== "args"))
471
+ return "a text has only $vestraText and args";
472
+ const source = item.$vestraText;
473
+ if (typeof source !== "string" || source === "")
474
+ return "a text's source is a non-empty string";
475
+ if (utf8(source) > L.stringBytes)
476
+ return `a text's source is at most ${L.stringBytes} bytes`;
477
+ return item.args === undefined ? null : args(item.args, depth);
478
+ }
479
+ const keys = Object.keys(item);
480
+ if (keys.some((key) => key !== "$vestraList" && key !== "style"))
481
+ return "a list has only $vestraList and style";
482
+ const items = item.$vestraList;
483
+ if (!Array.isArray(items))
484
+ return "a list's items are an array";
485
+ if (items.length > L.items)
486
+ return `a list holds at most ${L.items} items`;
487
+ if (item.style !== undefined &&
488
+ !LIST_STYLES.includes(item.style))
489
+ return `a list's style is one of ${LIST_STYLES.join(", ")}`;
490
+ for (const entry of items) {
491
+ const problem = arg(entry, depth);
492
+ if (problem)
493
+ return problem;
494
+ }
495
+ return null;
496
+ };
497
+ return mode === "args" ? args(value, 0) : text(value, 0);
498
+ }
499
+ function renderArg(arg, r) {
500
+ return isTextValue(arg) ? renderValue(arg, r) : arg;
501
+ }
502
+ function renderValue(value, r) {
503
+ if ("$vestraText" in value) {
504
+ const template = r.catalog.text(value.$vestraText);
505
+ return format(template, renderArgs(value.args, r) ?? []);
506
+ }
507
+ const locale = LOCALES.includes(r.locale)
508
+ ? r.locale
509
+ : "en";
510
+ const separator = LIST_SEPARATORS[locale][value.style ?? "list"];
511
+ return value.$vestraList
512
+ .map((item) => String(argumentText(renderArg(item, r))))
513
+ .join(separator);
514
+ }
515
+ const argumentText = (value) => typeof value === "number" ? format("{}", [value]) : value;
516
+ function renderArgs(args, r) {
517
+ if (args === undefined || args === null)
518
+ return undefined;
519
+ if (Array.isArray(args))
520
+ return args.map((arg) => renderArg(arg, r));
521
+ return Object.fromEntries(Object.entries(args).map(([name, arg]) => [name, renderArg(arg, r)]));
522
+ }
523
+ /**
524
+ * A text value as the player shows it: the source translated with the
525
+ * plugin's catalog and formatted with its (rendered) arguments; a list's
526
+ * items joined with the locale's separator.
527
+ */
528
+ export function renderText(value, catalog, locale) {
529
+ return renderValue(value, { catalog, locale });
530
+ }
531
+ /**
532
+ * Template arguments with their texts rendered (for `format`): what the
533
+ * player fills a template with.
534
+ */
535
+ export function renderTemplateArgs(args, catalog, locale) {
536
+ return renderArgs(args ?? undefined, { catalog, locale });
537
+ }
538
+ /** The English rendering of a text value: the key a menu merges by. */
539
+ export function textKey(value) {
540
+ return renderValue(value, { catalog: new Catalog(), locale: "en" });
541
+ }
542
+ /**
543
+ * A property value with every text value in it replaced by its shown
544
+ * string, as the player does before it hands the value to Slint. Returns
545
+ * a problem (`textProblem`) instead when a text value is malformed.
546
+ */
547
+ export function resolveTexts(value, catalog, locale) {
548
+ let problem = null;
549
+ const walk = (item) => {
550
+ if (problem)
551
+ return item;
552
+ if (isTextValue(item)) {
553
+ problem = textProblem(item);
554
+ return problem ? item : renderText(item, catalog, locale);
555
+ }
556
+ if (Array.isArray(item))
557
+ return item.map(walk);
558
+ if (plainObject(item)) {
559
+ // A runtime image or Markdown document stays as it is.
560
+ if ("$vestraImage" in item || "$vestraMarkdown" in item)
561
+ return item;
562
+ return Object.fromEntries(Object.entries(item).map(([key, entry]) => [key, walk(entry)]));
563
+ }
564
+ return item;
565
+ };
566
+ const out = walk(value);
567
+ return problem ? { problem } : { value: out };
568
+ }
569
+ /** Whether a value holds a text value anywhere (a cheap pre-check). */
570
+ export function hasTexts(value) {
571
+ if (isTextValue(value))
572
+ return true;
573
+ if (Array.isArray(value))
574
+ return value.some(hasTexts);
575
+ if (plainObject(value))
576
+ return Object.values(value).some(hasTexts);
577
+ return false;
578
+ }
329
579
  const lineEnd = (s, i) => {
330
580
  const found = s.slice(i).search(/[\n\r]/);
331
581
  return found < 0 ? s.length : i + found;
@@ -671,3 +921,52 @@ export const HOST_ZH_HANS = {
671
921
  "Not available in fullscreen": "全屏时不可用",
672
922
  "Picture-in-picture is not supported on this system": "此系统不支持画中画",
673
923
  };
924
+ // ---------------------------------------------------------------- packages
925
+ /**
926
+ * The catalog of a package directory for `locale`, as the player loads it:
927
+ * the compiled `locales/<lang>.json` of a built package, else the source
928
+ * `locales/<lang>.po`. Empty for English and without a file; a broken file
929
+ * throws.
930
+ */
931
+ export function loadPackageCatalog(directory, locale) {
932
+ if (locale === "en")
933
+ return new Catalog();
934
+ const base = join(directory, "locales", locale);
935
+ if (existsSync(`${base}.json`)) {
936
+ const compiled = JSON.parse(readFileSync(`${base}.json`, "utf8"));
937
+ return new Catalog(compiled.messages ?? {});
938
+ }
939
+ if (existsSync(`${base}.po`))
940
+ return new Catalog(compileCatalog(locale, parsePo(readFileSync(`${base}.po`, "utf8")).entries).messages);
941
+ return new Catalog();
942
+ }
943
+ // ---------------------------------------------------------------- columns
944
+ const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1F300}-\u{1F64F}\u{1F900}-\u{1F9FF}\u{20000}-\u{3FFFD}]/u;
945
+ const ZERO = /[\p{Mn}\p{Me}​-‏]/u;
946
+ /** Columns of one character: 2 wide (East Asian), 0 combining, else 1. */
947
+ const columnsOf = (c) => (ZERO.test(c) ? 0 : WIDE.test(c) ? 2 : 1);
948
+ /**
949
+ * Each line of `text` cut to `max` columns, ending with `…` when cut, as
950
+ * the player cuts an OSD panel's lines (`OsdPanelOptions.maxColumns`; the
951
+ * player measures with Unicode's East Asian Width, this is close to it).
952
+ */
953
+ export function fitColumns(text, max) {
954
+ return text
955
+ .split("\n")
956
+ .map((line) => {
957
+ let used = 0;
958
+ const chars = [...line];
959
+ if (chars.reduce((n, c) => n + columnsOf(c), 0) <= max)
960
+ return line;
961
+ let out = "";
962
+ for (const c of chars) {
963
+ const width = columnsOf(c);
964
+ if (used + width > max - 1)
965
+ break;
966
+ out += c;
967
+ used += width;
968
+ }
969
+ return `${out}…`;
970
+ })
971
+ .join("\n");
972
+ }
@@ -13,6 +13,7 @@ export { HostError } from "./wire.ts";
13
13
  export type { Json, Transport } from "./wire.ts";
14
14
  export { API_VERSION, CAPABILITIES } from "./capabilities.ts";
15
15
  export { COMMAND_LIMITS, MENU_IDS } from "./commands.ts";
16
- export { Catalog, HOST_ZH_HANS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, scanSlint, } from "./i18n.ts";
16
+ export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, renderText, scanSlint, TEXT_LIMITS, textKey, textProblem, } from "./i18n.ts";
17
17
  export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.ts";
18
18
  export { MARKDOWN_LIMITS } from "./plugin/markdown.ts";
19
+ export { UI_VALUE_LIMITS } from "./plugin/values.ts";
@@ -11,6 +11,7 @@ export { createPlugin } from "./plugin/index.js";
11
11
  export { HostError } from "./wire.js";
12
12
  export { API_VERSION, CAPABILITIES } from "./capabilities.js";
13
13
  export { COMMAND_LIMITS, MENU_IDS } from "./commands.js";
14
- export { Catalog, HOST_ZH_HANS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, scanSlint, } from "./i18n.js";
14
+ export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, renderText, scanSlint, TEXT_LIMITS, textKey, textProblem, } from "./i18n.js";
15
15
  export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.js";
16
16
  export { MARKDOWN_LIMITS } from "./plugin/markdown.js";
17
+ export { UI_VALUE_LIMITS } from "./plugin/values.js";
@@ -110,6 +110,7 @@ export const bindingsApi = (scope) => ({
110
110
  command: info.command,
111
111
  title: info.title,
112
112
  path: info.path,
113
+ key: info.key ?? null,
113
114
  scope: info.scope,
114
115
  defaults: info.defaults,
115
116
  current: info.current,
@@ -2,6 +2,7 @@
2
2
  import { failure, Latest, quiet } from "./runtime.js";
3
3
  import { checkBindings } from "./bindings.js";
4
4
  import { MENU_IDS } from "../commands.js";
5
+ import { isTextValue, textProblem } from "../i18n.js";
5
6
  const value = (item) => typeof item === "function" ? item() : item;
6
7
  /** Validate options and build the wire declaration. */
7
8
  function declare(options) {
@@ -11,7 +12,18 @@ function declare(options) {
11
12
  throw failure("invalid", `command ${options.id}: title or path required`);
12
13
  if (path.length > 8)
13
14
  throw failure("invalid", `command ${options.id}: path has 1–8 segments`);
14
- const menu = path[0]?.startsWith("@") ? path[0].slice(1) : null;
15
+ for (const segment of [title, ...path])
16
+ if (typeof segment !== "string") {
17
+ const problem = isTextValue(segment)
18
+ ? "$vestraList" in segment
19
+ ? "a menu text is a string or a text, not a list"
20
+ : textProblem(segment)
21
+ : "title and path segments are strings or texts";
22
+ if (problem)
23
+ throw failure("invalid", `command ${options.id}: ${problem}`);
24
+ }
25
+ const first = path[0];
26
+ const menu = typeof first === "string" && first.startsWith("@") ? first.slice(1) : null;
15
27
  if (menu !== null && !MENU_IDS.includes(menu))
16
28
  throw failure("invalid", `command ${options.id}: unknown menu id @${menu}; the built-in menus are ${MENU_IDS.map((id) => `@${id}`).join(", ")}`);
17
29
  if (menu !== null && path.length < 2)
@@ -21,8 +33,8 @@ function declare(options) {
21
33
  throw failure("invalid", `command ${options.id}: order must be an integer within ±1000000`);
22
34
  const bindings = [...(options.bindings ?? [])];
23
35
  return {
24
- title,
25
- path,
36
+ title: title,
37
+ path: path,
26
38
  order,
27
39
  group: options.group ?? "domain",
28
40
  radio: options.radio ?? false,
@@ -89,11 +101,19 @@ class CommandHandle {
89
101
  scope.add(this);
90
102
  }
91
103
  #state() {
104
+ const reason = value(this.#spec.reason);
105
+ if (reason !== null && typeof reason !== "string") {
106
+ const problem = isTextValue(reason)
107
+ ? textProblem(reason)
108
+ : "reason is a string, a text or null";
109
+ if (problem)
110
+ throw failure("invalid", `command ${this.id}: ${problem}`);
111
+ }
92
112
  return {
93
113
  enabled: value(this.#spec.enabled),
94
114
  checked: value(this.#spec.checked),
95
115
  busy: value(this.#spec.busy),
96
- reason: value(this.#spec.reason),
116
+ reason,
97
117
  };
98
118
  }
99
119
  #sync() {
@@ -1,4 +1,4 @@
1
- import { Runtime } from "./runtime.js";
1
+ import { failure, Runtime } from "./runtime.js";
2
2
  import { ScopeImpl } from "./scope.js";
3
3
  import { playerOf } from "./player.js";
4
4
  import { windowOf } from "./window.js";
@@ -14,6 +14,14 @@ app) {
14
14
  timelineOf(runtime).init(),
15
15
  ]);
16
16
  const root = new ScopeImpl(runtime);
17
+ // Failures the plugin did not ask about: a property the player could not
18
+ // show when it rebuilt a panel or the Options window (an image removed
19
+ // since), a callback whose arguments it could not convert, a detached
20
+ // sidebar that could not be prepared. Older players send no code.
21
+ root.add(runtime.raw("ui.error", (data) => {
22
+ const { code, message } = (data ?? {});
23
+ root.report(failure(code ?? "ui", message ?? "user interface error"));
24
+ }));
17
25
  return {
18
26
  plugin: root,
19
27
  activated: () => runtime.activated(),
@@ -1,4 +1,8 @@
1
- /** `plugin.i18n`: the interface language of this run (read-only). */
2
1
  import type { ScopeImpl } from "./scope.ts";
3
- import type { I18n } from "./types.ts";
2
+ import type { I18n, ListStyle, TemplateArgs, Text, TextArg, TextError, TextList } from "./types.ts";
3
+ /** `plugin.i18n.text`: a plain, frozen text value (no translation here). */
4
+ export declare function text(source: string, args?: TemplateArgs): Text;
5
+ /** `plugin.i18n.list`: a plain, frozen list value. */
6
+ export declare function list(items: readonly TextArg[], style?: ListStyle): TextList;
7
+ export declare const error: (source: string, args?: TemplateArgs) => TextError;
4
8
  export declare const i18n: (scope: ScopeImpl) => I18n;
@@ -1,5 +1,79 @@
1
+ /** `plugin.i18n`: the interface language of this run and text values (read-only). */
2
+ import { Catalog, isTextValue, LIST_STYLES, loadPackageCatalog, renderText, textKey, textProblem, } from "../i18n.js";
3
+ import { failure } from "./runtime.js";
4
+ /** Copy arguments (arrays and objects only; texts are already frozen). */
5
+ const copyArgs = (args) => Array.isArray(args) ? Object.freeze([...args]) : Object.freeze({ ...args });
6
+ /** `plugin.i18n.text`: a plain, frozen text value (no translation here). */
7
+ export function text(source, args) {
8
+ const value = { $vestraText: source };
9
+ if (args !== undefined && args !== null)
10
+ value.args = copyArgs(args);
11
+ const problem = textProblem(value);
12
+ if (problem)
13
+ throw failure("invalid", `text: ${problem}`);
14
+ return Object.freeze(value);
15
+ }
16
+ /** `plugin.i18n.list`: a plain, frozen list value. */
17
+ export function list(items, style) {
18
+ if (!Array.isArray(items))
19
+ throw failure("invalid", "list: items is an array");
20
+ if (style !== undefined &&
21
+ !LIST_STYLES.includes(style))
22
+ throw failure("invalid", `list: style is one of ${LIST_STYLES.join(", ")}`);
23
+ const value = {
24
+ $vestraList: Object.freeze([
25
+ ...items,
26
+ ]),
27
+ ...(style === undefined ? {} : { style }),
28
+ };
29
+ const problem = textProblem(value);
30
+ if (problem)
31
+ throw failure("invalid", `list: ${problem}`);
32
+ return Object.freeze(value);
33
+ }
34
+ /** `plugin.i18n.error`: an Error whose message the player shows translated. */
35
+ class TextErrorImpl extends Error {
36
+ text;
37
+ constructor(value) {
38
+ super(textKey(value));
39
+ this.name = "TextError";
40
+ this.text = value;
41
+ }
42
+ }
43
+ export const error = (source, args) => new TextErrorImpl(text(source, args));
44
+ /** The plugin's catalog for this run's language, read once per runtime. */
45
+ const catalogs = new WeakMap();
46
+ function catalogOf(scope) {
47
+ const runtime = scope.runtime;
48
+ let catalog = catalogs.get(runtime);
49
+ if (!catalog) {
50
+ try {
51
+ catalog = loadPackageCatalog(runtime.directory, runtime.locale);
52
+ }
53
+ catch (error) {
54
+ // As the player: a broken catalog is reported and treated as missing.
55
+ console.error(`[${scope.id}] i18n: ${error.message}`);
56
+ catalog = new Catalog();
57
+ }
58
+ catalogs.set(runtime, catalog);
59
+ }
60
+ return catalog;
61
+ }
1
62
  export const i18n = (scope) => Object.freeze({
2
63
  get locale() {
3
64
  return scope.runtime.locale;
4
65
  },
66
+ text,
67
+ list,
68
+ error,
69
+ resolve(value) {
70
+ if (typeof value === "string")
71
+ return catalogOf(scope).text(value);
72
+ const problem = isTextValue(value)
73
+ ? textProblem(value)
74
+ : "resolve takes a string or a text value";
75
+ if (problem)
76
+ throw failure("invalid", `resolve: ${problem}`);
77
+ return renderText(value, catalogOf(scope), scope.runtime.locale);
78
+ },
5
79
  });
@@ -1,6 +1,14 @@
1
1
  import type { ScopeImpl } from "./scope.ts";
2
2
  import type { Osd } from "./types.ts";
3
- import type { TemplateArgsJson } from "../wire.ts";
4
- /** Copy and check template arguments: an array or object of ≤ 16 strings and numbers. */
3
+ import type { TemplateArgsJson, TextValueJson } from "../wire.ts";
4
+ /**
5
+ * Copy and check template arguments: an array or object of at most 16
6
+ * strings, finite numbers, texts and lists (`TEXT_LIMITS`).
7
+ */
5
8
  export declare function templateArgs(args: unknown): TemplateArgsJson | undefined;
9
+ /**
10
+ * Check a shown text (`osd.show`, dialogs): source text, or a text value
11
+ * that carries its own arguments (then `args` is not given).
12
+ */
13
+ export declare function shownText(text: unknown, args: unknown, what?: string): TextValueJson;
6
14
  export declare const osd: (scope: ScopeImpl) => Osd;
@@ -1,24 +1,38 @@
1
1
  /** On-screen display: a transient line and a persistent information block. */
2
2
  import { Latest } from "./runtime.js";
3
3
  import { failure } from "./runtime.js";
4
- /** Copy and check template arguments: an array or object of ≤ 16 strings and numbers. */
4
+ import { isTextValue, textProblem } from "../i18n.js";
5
+ /**
6
+ * Copy and check template arguments: an array or object of at most 16
7
+ * strings, finite numbers, texts and lists (`TEXT_LIMITS`).
8
+ */
5
9
  export function templateArgs(args) {
6
10
  if (args === undefined || args === null)
7
11
  return undefined;
8
- const values = Array.isArray(args)
9
- ? args
10
- : typeof args === "object"
11
- ? Object.values(args)
12
- : null;
13
- if (!values ||
14
- values.length > 16 ||
15
- values.some((value) => !((typeof value === "number" && Number.isFinite(value)) ||
16
- typeof value === "string")))
17
- throw failure("invalid", "args is an array or object of at most 16 strings and finite numbers");
12
+ const problem = textProblem(args, "args");
13
+ if (problem)
14
+ throw failure("invalid", problem);
18
15
  return Array.isArray(args)
19
16
  ? [...args]
20
17
  : { ...args };
21
18
  }
19
+ /**
20
+ * Check a shown text (`osd.show`, dialogs): source text, or a text value
21
+ * that carries its own arguments (then `args` is not given).
22
+ */
23
+ export function shownText(text, args, what = "text") {
24
+ if (typeof text === "string")
25
+ return text;
26
+ if (isTextValue(text)) {
27
+ const problem = textProblem(text);
28
+ if (problem)
29
+ throw failure("invalid", `${what}: ${problem}`);
30
+ if (args !== undefined && args !== null)
31
+ throw failure("invalid", `${what} is a text value: its args go inside it, not in args`);
32
+ return text;
33
+ }
34
+ throw failure("invalid", `${what} is a string or a text value`);
35
+ }
22
36
  class PanelHandle {
23
37
  #scope;
24
38
  #pump;
@@ -30,19 +44,24 @@ class PanelHandle {
30
44
  throw new Error("a plugin can have only one osd panel");
31
45
  scope.runtime.services.set("osd.panel", this);
32
46
  this.#scope = scope;
33
- this.#pump = new Latest(({ text, args }) => scope.runtime.host.osd.panel(text, args));
47
+ this.#pump = new Latest(({ text, args, maxColumns }) => scope.runtime.host.osd.panel(text, args, maxColumns));
34
48
  scope.add(this);
35
49
  }
36
- set(text, args) {
50
+ set(text, args, options = {}) {
37
51
  if (this.#disposed)
38
52
  return;
39
53
  let value;
40
54
  try {
55
+ const max = options?.maxColumns;
56
+ if (max !== undefined &&
57
+ !(Number.isInteger(max) && max >= 8 && max <= 1000))
58
+ throw failure("invalid", "maxColumns is an integer from 8 to 1000");
41
59
  value = {
42
- text,
43
- ...(text === null || args === undefined
60
+ text: text === null ? null : shownText(text, args),
61
+ ...(text === null || args === undefined || args === null
44
62
  ? {}
45
63
  : { args: templateArgs(args) }),
64
+ ...(text === null || max === undefined ? {} : { maxColumns: max }),
46
65
  };
47
66
  }
48
67
  catch (error) {
@@ -67,6 +86,6 @@ class PanelHandle {
67
86
  }
68
87
  }
69
88
  export const osd = (scope) => ({
70
- show: async (text, args) => scope.runtime.host.osd.show(text, templateArgs(args)),
89
+ show: async (text, args) => scope.runtime.host.osd.show(shownText(text, args), templateArgs(args)),
71
90
  panel: () => new PanelHandle(scope),
72
91
  });
@@ -1,5 +1,6 @@
1
1
  /** Subtitle selection, external SRT resources and this plugin's style layer. */
2
2
  import { createHash } from "node:crypto";
3
+ import { isTextValue, textProblem } from "../i18n.js";
3
4
  import { Latest, failure, sleep, uncertain } from "./runtime.js";
4
5
  import { abortError } from "./player.js";
5
6
  const raws = new WeakMap();
@@ -226,7 +227,14 @@ export function subtitles(scope) {
226
227
  if (select !== "primary" && select !== "secondary" && select !== "none")
227
228
  throw failure("invalid", 'select must be "primary", "secondary" or "none"');
228
229
  const title = options?.title;
229
- if (title !== undefined &&
230
+ // A text value (`plugin.i18n.text`) is checked here and its shown
231
+ // length by the player.
232
+ if (isTextValue(title)) {
233
+ const problem = textProblem(title);
234
+ if (problem)
235
+ throw failure("invalid", `title: ${problem}`);
236
+ }
237
+ else if (title !== undefined &&
230
238
  (typeof title !== "string" ||
231
239
  [...title].length < 1 ||
232
240
  [...title].length > 200))