@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.
@@ -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))
@@ -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. */