@vestra-player/sdk 1.5.8 → 1.5.9

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.
@@ -1,6 +1,10 @@
1
1
  /** The player's palette (`plugin.appearance`; `ui.theme` to change it). */
2
2
  import { failure } from "./runtime.js";
3
3
  import { windowOf } from "./window.js";
4
+ import { LOCALES } from "../i18n.js";
5
+ const preference = (value) => value === "system" || LOCALES.includes(value)
6
+ ? value
7
+ : undefined;
4
8
  /** The palettes this SDK knows, the default first. */
5
9
  export const THEMES = ["vestra", "vestra-light"];
6
10
  /**
@@ -11,19 +15,28 @@ export const THEMES = ["vestra", "vestra-light"];
11
15
  class AppearanceService {
12
16
  #runtime;
13
17
  theme;
18
+ /** The saved interface language (the next start's). */
19
+ language;
14
20
  #listeners = new Set();
15
21
  #started = false;
16
22
  constructor(runtime) {
17
23
  this.#runtime = runtime;
18
24
  this.theme =
19
25
  windowOf(runtime).appearance ?? "vestra";
26
+ this.language = preference(windowOf(runtime).language) ?? "system";
20
27
  }
21
28
  start() {
22
29
  if (this.#started)
23
30
  return;
24
31
  this.#started = true;
25
32
  this.#runtime.on("appearance.changed", (data) => {
26
- const theme = data?.theme;
33
+ const event = data;
34
+ const language = preference(event?.language);
35
+ if (language && language !== this.language) {
36
+ this.language = language;
37
+ this.#runtime.invalidate();
38
+ }
39
+ const theme = event?.theme;
27
40
  if (theme && THEMES.includes(theme))
28
41
  this.accept(theme);
29
42
  });
@@ -65,5 +78,18 @@ export function appearance(scope) {
65
78
  await scope.runtime.host.appearance.set(theme);
66
79
  service().accept(theme);
67
80
  },
81
+ get language() {
82
+ service().start();
83
+ return service().language;
84
+ },
85
+ async setLanguage(language) {
86
+ if (!preference(language))
87
+ throw failure("invalid", `language is "system" or one of ${LOCALES.map((l) => `"${l}"`).join(", ")}`);
88
+ await scope.runtime.host.appearance.setLanguage(language);
89
+ if (service().language !== language) {
90
+ service().language = language;
91
+ scope.runtime.invalidate();
92
+ }
93
+ },
68
94
  };
69
95
  }
@@ -0,0 +1,4 @@
1
+ /** `plugin.i18n`: the interface language of this run (read-only). */
2
+ import type { ScopeImpl } from "./scope.ts";
3
+ import type { I18n } from "./types.ts";
4
+ export declare const i18n: (scope: ScopeImpl) => I18n;
@@ -0,0 +1,5 @@
1
+ export const i18n = (scope) => Object.freeze({
2
+ get locale() {
3
+ return scope.runtime.locale;
4
+ },
5
+ });
@@ -1,3 +1,6 @@
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. */
5
+ export declare function templateArgs(args: unknown): TemplateArgsJson | undefined;
3
6
  export declare const osd: (scope: ScopeImpl) => Osd;
@@ -1,23 +1,59 @@
1
1
  /** On-screen display: a transient line and a persistent information block. */
2
2
  import { Latest } from "./runtime.js";
3
+ import { failure } from "./runtime.js";
4
+ /** Copy and check template arguments: an array or object of ≤ 16 strings and numbers. */
5
+ export function templateArgs(args) {
6
+ if (args === undefined || args === null)
7
+ 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");
18
+ return Array.isArray(args)
19
+ ? [...args]
20
+ : { ...args };
21
+ }
3
22
  class PanelHandle {
4
23
  #scope;
5
24
  #pump;
6
- #last = null;
25
+ /** The last value sent, as JSON (`null` text: hidden). */
26
+ #last = JSON.stringify({ text: null });
7
27
  #disposed = false;
8
28
  constructor(scope) {
9
29
  if (scope.runtime.services.has("osd.panel"))
10
30
  throw new Error("a plugin can have only one osd panel");
11
31
  scope.runtime.services.set("osd.panel", this);
12
32
  this.#scope = scope;
13
- this.#pump = new Latest((text) => scope.runtime.host.osd.panel(text));
33
+ this.#pump = new Latest(({ text, args }) => scope.runtime.host.osd.panel(text, args));
14
34
  scope.add(this);
15
35
  }
16
- set(text) {
17
- if (this.#disposed || text === this.#last)
36
+ set(text, args) {
37
+ if (this.#disposed)
38
+ return;
39
+ let value;
40
+ try {
41
+ value = {
42
+ text,
43
+ ...(text === null || args === undefined
44
+ ? {}
45
+ : { args: templateArgs(args) }),
46
+ };
47
+ }
48
+ catch (error) {
49
+ this.#scope.report(error);
50
+ return;
51
+ }
52
+ const key = JSON.stringify(value);
53
+ if (key === this.#last)
18
54
  return;
19
- this.#last = text;
20
- this.#pump.push(text).catch((error) => this.#scope.report(error));
55
+ this.#last = key;
56
+ this.#pump.push(value).catch((error) => this.#scope.report(error));
21
57
  }
22
58
  async dispose() {
23
59
  if (this.#disposed)
@@ -25,12 +61,12 @@ class PanelHandle {
25
61
  this.#disposed = true;
26
62
  this.#scope.forget(this);
27
63
  this.#scope.runtime.services.delete("osd.panel");
28
- if (this.#last !== null)
29
- await this.#pump.push(null).catch(() => { });
64
+ if (this.#last !== JSON.stringify({ text: null }))
65
+ await this.#pump.push({ text: null }).catch(() => { });
30
66
  this.#pump.stop();
31
67
  }
32
68
  }
33
69
  export const osd = (scope) => ({
34
- show: (text) => scope.runtime.host.osd.show(text),
70
+ show: async (text, args) => scope.runtime.host.osd.show(text, templateArgs(args)),
35
71
  panel: () => new PanelHandle(scope),
36
72
  });
@@ -161,4 +161,14 @@ const info = (plugin) => Object.freeze({
161
161
  ...(warning.slot === undefined ? {} : { slot: warning.slot }),
162
162
  ...(warning.owner === undefined ? {} : { owner: warning.owner }),
163
163
  }))),
164
+ ...(plugin.display
165
+ ? {
166
+ display: Object.freeze({
167
+ name: plugin.display.name ?? null,
168
+ punchline: plugin.display.punchline ?? null,
169
+ description: plugin.display.description ?? null,
170
+ tags: Object.freeze([...(plugin.display.tags ?? [])]),
171
+ }),
172
+ }
173
+ : {}),
164
174
  });
@@ -1,5 +1,5 @@
1
1
  import type { Json, LocalServices, Transport, Wire } from "../wire.ts";
2
- import type { App, Disposable, PluginError, SystemInfo } from "./types.ts";
2
+ import type { App, Disposable, Locale, PluginError, SystemInfo } from "./types.ts";
3
3
  export interface PluginManifest {
4
4
  id: string;
5
5
  capabilities: string[];
@@ -40,6 +40,8 @@ export declare class Runtime {
40
40
  readonly services: Map<string, unknown>;
41
41
  /** `plugin.app`, from the start-up handshake; `system()` asks the player. */
42
42
  readonly app: App;
43
+ /** `plugin.i18n.locale`, from the start-up handshake; English when unknown. */
44
+ readonly locale: Locale;
43
45
  constructor(transport: Transport, manifest: PluginManifest, directory: string, dataDirectory?: string, app?: unknown);
44
46
  can(capability: string): boolean;
45
47
  /** Lazily created singletons shared by every scope of this plugin. */
@@ -1,5 +1,6 @@
1
1
  /** Shared machinery behind every scope: invocation, update passes, events. */
2
2
  import { createWire, HostError } from "../wire.js";
3
+ import { LOCALES } from "../i18n.js";
3
4
  export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
5
  export function toError(error) {
5
6
  if (error instanceof Error) {
@@ -94,6 +95,8 @@ export class Runtime {
94
95
  services = new Map();
95
96
  /** `plugin.app`, from the start-up handshake; `system()` asks the player. */
96
97
  app;
98
+ /** `plugin.i18n.locale`, from the start-up handshake; English when unknown. */
99
+ locale;
97
100
  constructor(transport, manifest, directory, dataDirectory, app) {
98
101
  this.#transport = transport;
99
102
  this.dataDirectory = dataDirectory;
@@ -101,6 +104,9 @@ export class Runtime {
101
104
  this.host = createWire(transport);
102
105
  const host = this.host;
103
106
  this.app = appInfo(app, () => host.app.system());
107
+ this.locale = LOCALES.includes(transport.locale ?? "")
108
+ ? transport.locale
109
+ : "en";
104
110
  this.manifest = manifest;
105
111
  this.directory = directory;
106
112
  transport.on("ui.callback", (data) => {
@@ -39,6 +39,7 @@ export declare class ScopeImpl implements Plugin {
39
39
  readonly drops: import("./types.ts").Drops;
40
40
  readonly appearance: import("./types.ts").Appearance;
41
41
  readonly settings: import("./types.ts").Settings;
42
+ readonly i18n: import("./types.ts").I18n;
42
43
  get app(): import("./types.ts").App;
43
44
  scope(): Scope;
44
45
  add<T extends Disposable>(item: T): T;
@@ -6,6 +6,7 @@ import { timeline } from "./timeline.js";
6
6
  import { drops } from "./drops.js";
7
7
  import { appearance } from "./appearance.js";
8
8
  import { settings } from "./settings.js";
9
+ import { i18n } from "./language.js";
9
10
  import { player } from "./player.js";
10
11
  import { audioFilters, videoFilters } from "./filters.js";
11
12
  import { videoRender, videoView } from "./view.js";
@@ -69,6 +70,7 @@ export class ScopeImpl {
69
70
  drops = drops(this);
70
71
  appearance = appearance(this);
71
72
  settings = settings(this);
73
+ i18n = i18n(this);
72
74
  get app() {
73
75
  return this.runtime.app;
74
76
  }
@@ -8,11 +8,28 @@ import type { SourceState } from "../source.ts";
8
8
  import type { SubtitleState } from "../subtitles.ts";
9
9
  import type { RenderSettings, ViewSettings } from "./types.ts";
10
10
  import type { MediaJob } from "../media.ts";
11
- import type { Plugin, ThemeId } from "./types.ts";
11
+ import type { LanguagePreference, Locale, Plugin, TemplateArgs, ThemeId } from "./types.ts";
12
+ import { LOCALES } from "../i18n.ts";
12
13
  /** Throw one of these from `host.handle(...)` to simulate a definite refusal. */
13
14
  export { HostError };
14
15
  /** The player's command caps, which the test host enforces as well. */
15
16
  export { COMMAND_LIMITS, MENU_IDS };
17
+ /**
18
+ * The player's interface languages, English (the source) first, as the
19
+ * player's own table: `["en", "zh-Hans", "ja", "ko", "de", "fr", "es"]`.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * import { LOCALES } from "@vestra-player/sdk/testing";
24
+ *
25
+ * for (const locale of LOCALES.slice(1)) {
26
+ * const { host, stop } = await startPlugin(activate, { directory, locale });
27
+ * assert.notEqual(host.command("open").display.title, "Open…");
28
+ * await stop();
29
+ * }
30
+ * ```
31
+ */
32
+ export { LOCALES };
16
33
  type Handler = (params: Record<string, Json>) => unknown;
17
34
  type Call = {
18
35
  method: string;
@@ -80,6 +97,20 @@ export interface FakeCommand {
80
97
  * versions.
81
98
  */
82
99
  declaration: CommandDeclaration;
100
+ /**
101
+ * What the menu shows, as the player shows it in the test host's
102
+ * {@link TestHost.locale}: the title and each path segment translated with
103
+ * the plugin's catalog (the player's own menus in the player's words), the
104
+ * keys normalized (`@video` is "Video").
105
+ *
106
+ * @since 1.5.9
107
+ */
108
+ readonly display: {
109
+ /** The menu text of the item. */
110
+ title: string;
111
+ /** Each segment of the path as shown; empty without a path. */
112
+ path: string[];
113
+ };
83
114
  /** The command state the player last received (after the update pass). */
84
115
  state: {
85
116
  /** `false` when the plugin disabled the command. */
@@ -257,10 +288,101 @@ export declare class TestHost implements Transport {
257
288
  */
258
289
  sidebar: LayoutSlot | null;
259
290
  };
260
- /** Every `osd.show` text, newest last. */
291
+ /**
292
+ * Every `osd.show` text as the player shows it (translated with the
293
+ * plugin's catalog and formatted with its args), newest last. The
294
+ * template and args the plugin sent are in `host.sent("osd.show")`.
295
+ */
261
296
  readonly osd: string[];
262
- /** The text of this plugin's osd panel, `null` when hidden. */
297
+ /** The text of this plugin's osd panel as shown, `null` when hidden. */
263
298
  osdPanel: string | null;
299
+ /**
300
+ * The interface language the test host plays (`plugin.i18n.locale`), `"en"`
301
+ * by default. Set it before {@link startPlugin} (or pass its `locale`
302
+ * option): the plugin's catalog for it is read from the package's
303
+ * `locales/` then, and the menus ({@link FakeCommand.display}), OSD
304
+ * messages, dialog texts, settings page and panel titles show its
305
+ * translations, the way the player shows them.
306
+ *
307
+ * The player's own texts (core actions' messages and menus, its
308
+ * refusals) are English, or the player's Simplified Chinese for
309
+ * `"zh-Hans"`. While `locale` was never set they stay in Chinese, as
310
+ * players up to 0.4.2 showed them, so tests written for SDK 1.5.8 keep
311
+ * passing; set `locale` to test what a user sees now.
312
+ *
313
+ * @example
314
+ * ```ts
315
+ * const { host, stop } = await startPlugin(activate, { directory, locale: "zh-Hans" });
316
+ * assert.deepEqual(host.command("blur").display.path, ["视频", "模糊…"]);
317
+ * await stop();
318
+ * ```
319
+ *
320
+ * @since 1.5.9
321
+ */
322
+ get locale(): Locale;
323
+ set locale(locale: Locale);
324
+ /**
325
+ * Read the plugin's translations for {@link TestHost.locale} from a
326
+ * package directory, as the player would (`locales/<locale>.json` of a
327
+ * built package, else the source `locales/<locale>.po`); `startPlugin`
328
+ * does this. None for English; a missing file means none; a broken `.po`
329
+ * throws.
330
+ *
331
+ * @param directory - The package directory (holding `locales/`).
332
+ * @since 1.5.9
333
+ */
334
+ loadCatalog(directory: string): void;
335
+ /**
336
+ * A text of the plugin as the player shows it: translated with the
337
+ * plugin's catalog ({@link TestHost.loadCatalog}), then formatted with
338
+ * `args` when given.
339
+ *
340
+ * @param text - English source text or template.
341
+ * @param args - Values for its placeholders.
342
+ * @returns The shown text.
343
+ *
344
+ * @example
345
+ * ```ts
346
+ * assert.equal(host.translate("Speed {}×", [2]), "倍速 2×");
347
+ * ```
348
+ *
349
+ * @since 1.5.9
350
+ */
351
+ translate(text: string, args?: TemplateArgs): string;
352
+ /**
353
+ * The saved interface language (`plugin.appearance.language`), changed by
354
+ * `appearance.setLanguage` or {@link TestHost.setLanguage}.
355
+ *
356
+ * @since 1.5.9
357
+ */
358
+ language: LanguagePreference;
359
+ /**
360
+ * Simulate another plugin (or the user) saving the interface language:
361
+ * pushes `appearance.changed`.
362
+ *
363
+ * @param language - `"system"` or a locale.
364
+ * @since 1.5.9
365
+ */
366
+ setLanguage(language: LanguagePreference): void;
367
+ /**
368
+ * Every `dialogs.confirm` as the player shows it: its texts translated
369
+ * (message and detail formatted with `args`), newest last. The options
370
+ * the plugin sent are in {@link TestHost.confirms}.
371
+ *
372
+ * @since 1.5.9
373
+ */
374
+ readonly confirmTexts: {
375
+ /** The question. */
376
+ message: string;
377
+ /** The text under it, if any. */
378
+ detail?: string;
379
+ /** The confirm button. */
380
+ confirm: string;
381
+ /** The cancel button (the player's "Cancel" by default). */
382
+ cancel: string;
383
+ /** The checkbox, if any. */
384
+ checkbox?: string;
385
+ }[];
264
386
  /** The sequence provider's flags, `null` without a provider. */
265
387
  sequence: {
266
388
  /** The provider's `canNext`, as last evaluated. */
@@ -1341,6 +1463,14 @@ export declare function startPlugin(activate: (plugin: Plugin) => unknown, optio
1341
1463
  host?: TestHost;
1342
1464
  /** Use an existing directory as `plugin.dataDirectory` (to test a restart). */
1343
1465
  dataDirectory?: string;
1466
+ /**
1467
+ * The interface language to play ({@link TestHost.locale}): the plugin's
1468
+ * `locales/<locale>.po` (or `.json`) in `directory` is loaded and its
1469
+ * menus, messages and titles show translated. Default: the host's.
1470
+ *
1471
+ * @since 1.5.9
1472
+ */
1473
+ locale?: Locale;
1344
1474
  }): Promise<{
1345
1475
  /** The test host the plugin talks to. */
1346
1476
  host: TestHost;