@vestra-player/sdk 1.5.8 → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +4 -1
  3. package/bin/vestra-plugin.mjs +170 -7
  4. package/dist/commands.d.ts +7 -4
  5. package/dist/i18n-tools.d.ts +67 -0
  6. package/dist/i18n-tools.js +787 -0
  7. package/dist/i18n.d.ts +222 -0
  8. package/dist/i18n.js +972 -0
  9. package/dist/player-runtime.d.ts +2 -0
  10. package/dist/player-runtime.js +2 -0
  11. package/dist/plugin/appearance.js +27 -1
  12. package/dist/plugin/bindings.js +1 -0
  13. package/dist/plugin/commands.js +24 -4
  14. package/dist/plugin/index.js +9 -1
  15. package/dist/plugin/language.d.ts +8 -0
  16. package/dist/plugin/language.js +79 -0
  17. package/dist/plugin/osd.d.ts +11 -0
  18. package/dist/plugin/osd.js +64 -9
  19. package/dist/plugin/packages.js +10 -0
  20. package/dist/plugin/runtime.d.ts +3 -1
  21. package/dist/plugin/runtime.js +6 -0
  22. package/dist/plugin/scope.d.ts +1 -0
  23. package/dist/plugin/scope.js +2 -0
  24. package/dist/plugin/subtitles.js +9 -1
  25. package/dist/plugin/testing.d.ts +184 -5
  26. package/dist/plugin/testing.js +459 -67
  27. package/dist/plugin/timeline.js +3 -1
  28. package/dist/plugin/types.d.ts +523 -54
  29. package/dist/plugin/ui.js +29 -10
  30. package/dist/plugin/values.d.ts +55 -0
  31. package/dist/plugin/values.js +117 -0
  32. package/dist/plugin/window.d.ts +2 -0
  33. package/dist/plugin/window.js +4 -0
  34. package/dist/source-resolvers.d.ts +0 -1
  35. package/dist/source-resolvers.js +9 -1
  36. package/dist/subtitles.d.ts +3 -1
  37. package/dist/wire.d.ts +38 -6
  38. package/dist/wire.js +13 -2
  39. package/llms-full.txt +646 -58
  40. package/llms.txt +44 -40
  41. package/package.json +1 -1
  42. package/slint/vestra/markdown.slint +5 -5
  43. package/slint/vestra/widgets.slint +4 -4
@@ -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,14 +66,50 @@ 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";
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";
72
75
  /** Throw one of these from `host.handle(...)` to simulate a definite refusal. */
73
76
  export { HostError };
74
77
  /** The player's command caps, which the test host enforces as well. */
75
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 };
97
+ /**
98
+ * The player's interface languages, English (the source) first, as the
99
+ * player's own table: `["en", "zh-Hans", "ja", "ko", "de", "fr", "es"]`.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * import { LOCALES } from "@vestra-player/sdk/testing";
104
+ *
105
+ * for (const locale of LOCALES.slice(1)) {
106
+ * const { host, stop } = await startPlugin(activate, { directory, locale });
107
+ * assert.notEqual(host.command("open").display.title, "Open…");
108
+ * await stop();
109
+ * }
110
+ * ```
111
+ */
112
+ export { LOCALES };
76
113
  const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
77
114
  const tick = () => new Promise((resolve) => setImmediate(resolve));
78
115
  function initialSource() {
@@ -121,9 +158,15 @@ const taken = (message) => {
121
158
  return error;
122
159
  };
123
160
  const SLOT_LABELS = {
124
- bottom: "底栏",
125
- sidebar: "侧栏",
126
- backdrop: "视频背景",
161
+ bottom: "bottom bar",
162
+ sidebar: "sidebar",
163
+ backdrop: "video backdrop",
164
+ };
165
+ /** Texts only the test host uses, with their Chinese of SDK 1.5.8. */
166
+ const TEST_ZH_HANS = {
167
+ "this site": "这个网站",
168
+ "External SRT": "外部 SRT",
169
+ "External ASS": "外部 ASS",
127
170
  };
128
171
  /** The player's limit of sidebar tabs. */
129
172
  const MAX_SIDEBAR_TABS = 8;
@@ -227,10 +270,177 @@ export class TestHost {
227
270
  bottom: null,
228
271
  sidebar: null,
229
272
  };
230
- /** Every `osd.show` text, newest last. */
273
+ /**
274
+ * Every `osd.show` text as the player shows it (translated with the
275
+ * plugin's catalog and formatted with its args), newest last. The
276
+ * template and args the plugin sent are in `host.sent("osd.show")`.
277
+ */
231
278
  osd = [];
232
- /** The text of this plugin's osd panel, `null` when hidden. */
279
+ /** The text of this plugin's osd panel as shown, `null` when hidden. */
233
280
  osdPanel = null;
281
+ #locale;
282
+ /**
283
+ * The interface language the test host plays (`plugin.i18n.locale`), `"en"`
284
+ * by default. Set it before {@link startPlugin} (or pass its `locale`
285
+ * option): the plugin's catalog for it is read from the package's
286
+ * `locales/` then, and the menus ({@link FakeCommand.display}), OSD
287
+ * messages, dialog texts, settings page and panel titles show its
288
+ * translations, the way the player shows them.
289
+ *
290
+ * The player's own texts (core actions' messages and menus, its
291
+ * refusals) are English, or the player's Simplified Chinese for
292
+ * `"zh-Hans"`. While `locale` was never set they stay in Chinese, as
293
+ * players up to 0.4.2 showed them, so tests written for SDK 1.5.8 keep
294
+ * passing; set `locale` to test what a user sees now.
295
+ *
296
+ * @example
297
+ * ```ts
298
+ * const { host, stop } = await startPlugin(activate, { directory, locale: "zh-Hans" });
299
+ * assert.deepEqual(host.command("blur").display.path, ["视频", "模糊…"]);
300
+ * await stop();
301
+ * ```
302
+ *
303
+ * @since 1.5.9
304
+ */
305
+ get locale() {
306
+ return this.#locale ?? "en";
307
+ }
308
+ set locale(locale) {
309
+ if (!LOCALES.includes(locale))
310
+ throw new Error(`test host: unknown locale ${locale}`);
311
+ this.#locale = locale;
312
+ }
313
+ /** The plugin's translations for `locale` (see `loadCatalog`). */
314
+ #catalog = new Catalog();
315
+ /**
316
+ * Read the plugin's translations for {@link TestHost.locale} from a
317
+ * package directory, as the player would (`locales/<locale>.json` of a
318
+ * built package, else the source `locales/<locale>.po`); `startPlugin`
319
+ * does this. None for English; a missing file means none; a broken `.po`
320
+ * throws.
321
+ *
322
+ * @param directory - The package directory (holding `locales/`).
323
+ * @since 1.5.9
324
+ */
325
+ loadCatalog(directory) {
326
+ this.#catalog = loadPackageCatalog(directory, this.locale);
327
+ }
328
+ /**
329
+ * A text of the plugin as the player shows it: translated with the
330
+ * plugin's catalog ({@link TestHost.loadCatalog}), then formatted with
331
+ * `args` when given.
332
+ *
333
+ * @param text - English source text or template.
334
+ * @param args - Values for its placeholders.
335
+ * @returns The shown text.
336
+ *
337
+ * @example
338
+ * ```ts
339
+ * assert.equal(host.translate("Speed {}×", [2]), "倍速 2×");
340
+ * ```
341
+ *
342
+ * @since 1.5.9
343
+ */
344
+ translate(text, args) {
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;
382
+ }
383
+ /** A text of the player itself, in the player's language (see `locale`). */
384
+ #hostText(text, args) {
385
+ const zh = this.#locale === undefined || this.#locale === "zh-Hans";
386
+ const shown = (zh && (HOST_ZH_HANS[text] ?? TEST_ZH_HANS[text])) || text;
387
+ return args === undefined
388
+ ? shown
389
+ : formatTemplate(shown, args);
390
+ }
391
+ /** A declared menu path and title as the player shows them. */
392
+ #display(path, title, reason = null) {
393
+ const keys = menuKeys(path.map((segment) => typeof segment === "string" ? segment : textKey(segment)));
394
+ const display = {
395
+ title: this.translate(title),
396
+ // The player's submenus by key, everything else as declared.
397
+ path: keys.map((segment, index) => index < keys.length - 1 &&
398
+ HOST_GROUPS.includes(keys.slice(0, index + 1).join("/"))
399
+ ? this.#hostText(segment)
400
+ : this.translate((path[index] ?? segment))),
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;
408
+ }
409
+ /**
410
+ * The saved interface language (`plugin.appearance.language`), changed by
411
+ * `appearance.setLanguage` or {@link TestHost.setLanguage}.
412
+ *
413
+ * @since 1.5.9
414
+ */
415
+ language = "system";
416
+ /**
417
+ * Simulate another plugin (or the user) saving the interface language:
418
+ * pushes `appearance.changed`.
419
+ *
420
+ * @param language - `"system"` or a locale.
421
+ * @since 1.5.9
422
+ */
423
+ setLanguage(language) {
424
+ this.language = language;
425
+ this.emit("appearance.changed", { theme: this.appearance, language });
426
+ }
427
+ /**
428
+ * Every `dialogs.confirm` as the player shows it: its texts translated
429
+ * (message and detail formatted with `args`), newest last. The options
430
+ * the plugin sent are in {@link TestHost.confirms}.
431
+ *
432
+ * @since 1.5.9
433
+ */
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 = [];
234
444
  /** The sequence provider's flags, `null` without a provider. */
235
445
  sequence = null;
236
446
  /**
@@ -432,6 +642,42 @@ export class TestHost {
432
642
  trustedExecution: true,
433
643
  };
434
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
+ }
435
681
  #seeImages(values) {
436
682
  for (const value of Object.values(values)) {
437
683
  const path = this.imagePath(value);
@@ -1013,14 +1259,14 @@ export class TestHost {
1013
1259
  /** Why one frame that way is impossible (B07), or null; the player's rules. */
1014
1260
  #frameCheck(forward) {
1015
1261
  const s = this.source;
1016
- const unsupported = "当前来源不支持此操作";
1017
- const noBack = "当前来源不支持逐帧后退";
1262
+ const unsupported = this.#hostText("Not supported by the current source");
1263
+ const noBack = this.#hostText("The current source cannot step back a frame");
1018
1264
  if (s.phase !== "ready" && s.phase !== "ended")
1019
1265
  return unsupported;
1020
1266
  if (s.kind !== "media")
1021
1267
  return forward ? unsupported : noBack;
1022
1268
  if (s.hasVideo === false)
1023
- return "没有视频";
1269
+ return this.#hostText("No video");
1024
1270
  if (forward && s.phase !== "ready")
1025
1271
  return unsupported;
1026
1272
  if (!forward && !s.capabilities.seek)
@@ -1031,12 +1277,16 @@ export class TestHost {
1031
1277
  #coreState(action) {
1032
1278
  const f = this.#facts();
1033
1279
  const s = this.source;
1034
- const unsupported = "当前来源不支持此操作";
1280
+ const unsupported = this.#hostText("Not supported by the current source");
1035
1281
  const rule = {
1036
1282
  "play-pause": [f.toggle, f.ready ? s.paused : null, unsupported],
1037
1283
  stop: [f.close, null, unsupported],
1038
- previous: [!!this.sequence?.canPrevious, null, "没有上一个"],
1039
- next: [!!this.sequence?.canNext, null, "没有下一个"],
1284
+ previous: [
1285
+ !!this.sequence?.canPrevious,
1286
+ null,
1287
+ this.#hostText("No previous item"),
1288
+ ],
1289
+ next: [!!this.sequence?.canNext, null, this.#hostText("No next item")],
1040
1290
  "frame-back": [
1041
1291
  !this.#frameCheck(false),
1042
1292
  null,
@@ -1061,11 +1311,18 @@ export class TestHost {
1061
1311
  }
1062
1312
  #registerCoreActions() {
1063
1313
  const table = [
1064
- ["stop", ["关闭"], 120, "open", ["F4"], false],
1065
- ["play-pause", ["播放", "播放/暂停"], 201, "domain", ["Space"], false],
1314
+ ["stop", ["Close"], 120, "open", ["F4"], false],
1315
+ [
1316
+ "play-pause",
1317
+ ["Playback", "Play/Pause"],
1318
+ 201,
1319
+ "domain",
1320
+ ["Space"],
1321
+ false,
1322
+ ],
1066
1323
  [
1067
1324
  "previous",
1068
- ["播放", "上一个"],
1325
+ ["Playback", "Previous"],
1069
1326
  202,
1070
1327
  "domain",
1071
1328
  ["PageUp", "Mouse:X1"],
@@ -1073,17 +1330,31 @@ export class TestHost {
1073
1330
  ],
1074
1331
  [
1075
1332
  "next",
1076
- ["播放", "下一个"],
1333
+ ["Playback", "Next"],
1077
1334
  203,
1078
1335
  "domain",
1079
1336
  ["PageDown", "Mouse:X2"],
1080
1337
  false,
1081
1338
  ],
1082
- ["frame-back", ["播放", "跳转", "上一帧"], 204, "frame", ["D"], true],
1083
- ["frame-forward", ["播放", "跳转", "下一帧"], 205, "frame", ["F"], true],
1339
+ [
1340
+ "frame-back",
1341
+ ["Playback", "Seek", "Previous Frame"],
1342
+ 204,
1343
+ "frame",
1344
+ ["D"],
1345
+ true,
1346
+ ],
1347
+ [
1348
+ "frame-forward",
1349
+ ["Playback", "Seek", "Next Frame"],
1350
+ 205,
1351
+ "frame",
1352
+ ["F"],
1353
+ true,
1354
+ ],
1084
1355
  [
1085
1356
  "seek-backward",
1086
- ["播放", "跳转", "后退5秒"],
1357
+ ["Playback", "Seek", "Back 5 s"],
1087
1358
  210,
1088
1359
  "step",
1089
1360
  ["ArrowLeft"],
@@ -1091,7 +1362,7 @@ export class TestHost {
1091
1362
  ],
1092
1363
  [
1093
1364
  "seek-forward",
1094
- ["播放", "跳转", "前进5秒"],
1365
+ ["Playback", "Seek", "Forward 5 s"],
1095
1366
  211,
1096
1367
  "step",
1097
1368
  ["ArrowRight"],
@@ -1099,18 +1370,32 @@ export class TestHost {
1099
1370
  ],
1100
1371
  [
1101
1372
  "rate-normal",
1102
- ["播放", "速度", "正常"],
1373
+ ["Playback", "Speed", "Normal"],
1103
1374
  220,
1104
1375
  "domain",
1105
1376
  ["Z"],
1106
1377
  false,
1107
1378
  true,
1108
1379
  ],
1109
- ["rate-down", ["播放", "速度", "减速 0.1"], 221, "domain", ["X"], false],
1110
- ["rate-up", ["播放", "速度", "加速 0.1"], 222, "domain", ["C"], false],
1380
+ [
1381
+ "rate-down",
1382
+ ["Playback", "Speed", "Slower 0.1"],
1383
+ 221,
1384
+ "domain",
1385
+ ["X"],
1386
+ false,
1387
+ ],
1388
+ [
1389
+ "rate-up",
1390
+ ["Playback", "Speed", "Faster 0.1"],
1391
+ 222,
1392
+ "domain",
1393
+ ["C"],
1394
+ false,
1395
+ ],
1111
1396
  [
1112
1397
  "volume-up",
1113
- ["声音", "音量 +"],
1398
+ ["Audio", "Volume +"],
1114
1399
  501,
1115
1400
  "domain",
1116
1401
  ["ArrowUp", "Mouse:WheelUp"],
@@ -1118,13 +1403,13 @@ export class TestHost {
1118
1403
  ],
1119
1404
  [
1120
1405
  "volume-down",
1121
- ["声音", "音量 −"],
1406
+ ["Audio", "Volume −"],
1122
1407
  502,
1123
1408
  "domain",
1124
1409
  ["ArrowDown", "Mouse:WheelDown"],
1125
1410
  true,
1126
1411
  ],
1127
- ["mute", ["声音", "静音"], 503, "domain", ["M"], false],
1412
+ ["mute", ["Audio", "Mute"], 503, "domain", ["M"], false],
1128
1413
  ];
1129
1414
  const host = this;
1130
1415
  for (const [id, path, order, group, bindings, repeat, radio] of table) {
@@ -1138,6 +1423,18 @@ export class TestHost {
1138
1423
  bindings,
1139
1424
  repeat,
1140
1425
  },
1426
+ // The player's own words in the player's language.
1427
+ get display() {
1428
+ const leaf = path.at(-1);
1429
+ const step = /^(Back|Forward) (\S+) s$/.exec(leaf);
1430
+ const title = step
1431
+ ? host.#hostText(`${step[1]} {} s`, [step[2]])
1432
+ : host.#hostText(leaf);
1433
+ return {
1434
+ title,
1435
+ path: [...path.slice(0, -1).map((s) => host.#hostText(s)), title],
1436
+ };
1437
+ },
1141
1438
  get state() {
1142
1439
  return host.#coreState(id);
1143
1440
  },
@@ -1175,7 +1472,7 @@ export class TestHost {
1175
1472
  kind: "control",
1176
1473
  control: { kind: "pause", paused: !s.paused },
1177
1474
  });
1178
- this.osd.push(s.phase !== "ended" && !s.paused ? "已暂停" : "播放");
1475
+ this.osd.push(this.#hostText(s.phase !== "ended" && !s.paused ? "Paused" : "Playing"));
1179
1476
  return;
1180
1477
  case "stop":
1181
1478
  this.#intent({ kind: "close" });
@@ -1200,7 +1497,7 @@ export class TestHost {
1200
1497
  kind: "control",
1201
1498
  control: { kind: "seek", seconds: target },
1202
1499
  });
1203
- this.osd.push(`${delta < 0 ? "后退" : "前进"} ${Math.abs(delta)}秒: ${time(target)} / ${time(s.duration)}`);
1500
+ this.osd.push(this.#hostText(delta < 0 ? "Back {} s: {} / {}" : "Forward {} s: {} / {}", [Math.abs(delta), time(target), time(s.duration)]));
1204
1501
  return;
1205
1502
  }
1206
1503
  case "rate-normal":
@@ -1210,7 +1507,7 @@ export class TestHost {
1210
1507
  ? 1
1211
1508
  : Math.max(0.25, Math.min(4, Math.round((s.rate + (action === "rate-up" ? 0.1 : -0.1)) * 100) / 100));
1212
1509
  this.#intent({ kind: "control", control: { kind: "rate", rate } });
1213
- this.osd.push(`播放速度: ${rateText(rate)}x`);
1510
+ this.osd.push(this.#hostText("Speed: {}x", [rateText(rate)]));
1214
1511
  return;
1215
1512
  }
1216
1513
  case "volume-up":
@@ -1221,12 +1518,12 @@ export class TestHost {
1221
1518
  ? Math.max(s.output.volume, Math.min(100, Math.round(s.output.volume + 5)))
1222
1519
  : Math.max(0, Math.min(200, Math.round(s.output.volume - 5)));
1223
1520
  this.setSource({ output: { volume, muted: false } });
1224
- this.osd.push(`音量: ${volume}%`);
1521
+ this.osd.push(this.#hostText("Volume: {}%", [volume]));
1225
1522
  return;
1226
1523
  }
1227
1524
  case "mute":
1228
1525
  this.setSource({ output: { ...s.output, muted: !s.output.muted } });
1229
- this.osd.push(s.output.muted ? "静音: 关" : "静音: 开");
1526
+ this.osd.push(this.#hostText(s.output.muted ? "Mute: Off" : "Mute: On"));
1230
1527
  return;
1231
1528
  }
1232
1529
  }
@@ -1399,6 +1696,8 @@ export class TestHost {
1399
1696
  pictureInPicture: { ...this.mainWindow.pictureInPicture },
1400
1697
  appearance: this.appearance,
1401
1698
  fileTypes: this.fileTypes(),
1699
+ language: this.language,
1700
+ locale: this.locale,
1402
1701
  };
1403
1702
  }
1404
1703
  /**
@@ -1586,10 +1885,17 @@ export class TestHost {
1586
1885
  const key = this.#bindingKey(id);
1587
1886
  const scope = declaration.focus ? "panel" : "global";
1588
1887
  const current = this.bindings.overlay.get(key) ?? declaration.bindings;
1888
+ const shown = this.commands.get(id) ?? this.coreCommands.get(id);
1589
1889
  return {
1590
1890
  ...this.#split(key),
1591
- title: declaration.title,
1592
- path: declaration.path?.length ? declaration.path : null,
1891
+ // What the user sees, like the player's binding table.
1892
+ title: shown?.display.title ?? String(declaration.title),
1893
+ path: declaration.path?.length
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)))
1898
+ : null,
1593
1899
  scope: scope,
1594
1900
  defaults: declaration.bindings,
1595
1901
  current,
@@ -1898,8 +2204,10 @@ export class TestHost {
1898
2204
  for (const other of this.otherResolvers) {
1899
2205
  const what = claims(other);
1900
2206
  if (what) {
1901
- const target = what === "host" ? "这个网站" : `文件类型 .${what}`;
1902
- throw taken(`${target} 已由 ${other.owner} 解析`);
2207
+ const target = what === "host"
2208
+ ? this.#hostText("this site")
2209
+ : this.#hostText("File type .{}", [what]);
2210
+ throw taken(this.#hostText("{} is resolved by {}", [target, other.owner]));
1903
2211
  }
1904
2212
  }
1905
2213
  this.resolvers.set(id, p);
@@ -1922,8 +2230,12 @@ export class TestHost {
1922
2230
  });
1923
2231
  throw new HostError("resolution", message);
1924
2232
  };
1925
- if (typeof p.error === "string")
1926
- 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");
1927
2239
  const result = (p.result ?? {});
1928
2240
  const producer = this.producers.get(Number(result.producerId));
1929
2241
  if (result.kind !== "stream")
@@ -2026,8 +2338,17 @@ export class TestHost {
2026
2338
  this.commands.size + this.coreCommands.size >= COMMAND_LIMITS.total)
2027
2339
  throw new HostError("limit", "command registry limit reached");
2028
2340
  const host = this;
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);
2029
2347
  this.commands.set(id, {
2030
- declaration: p.declaration,
2348
+ declaration,
2349
+ get display() {
2350
+ return host.#display(declaration.path ?? [], declaration.title, this.state.reason);
2351
+ },
2031
2352
  state: p.state,
2032
2353
  revision: Number(p.revision),
2033
2354
  async run() {
@@ -2047,6 +2368,7 @@ export class TestHost {
2047
2368
  const command = this.command(String(p.id));
2048
2369
  if (Number(p.revision) <= command.revision)
2049
2370
  throw new HostError("command", `stale command revision ${p.revision}`);
2371
+ this.#checkReason(p.state);
2050
2372
  command.revision = Number(p.revision);
2051
2373
  command.state = p.state;
2052
2374
  return { revision: command.revision };
@@ -2055,12 +2377,14 @@ export class TestHost {
2055
2377
  const id = String(p.id);
2056
2378
  if (this.windows.has(id))
2057
2379
  throw new HostError("conflict", "view already exists");
2058
- this.#seeImages(p.properties);
2380
+ const properties = this.#shownValues(p.properties);
2381
+ this.#checkValues(properties);
2382
+ this.#seeImages(properties);
2059
2383
  const host = this;
2060
2384
  this.windows.set(id, {
2061
2385
  file: String(p.file),
2062
2386
  visible: true,
2063
- props: { ...p.properties },
2387
+ props: { ...properties },
2064
2388
  callbacks: p.callbacks,
2065
2389
  userClose() {
2066
2390
  host.windows.delete(id);
@@ -2078,8 +2402,10 @@ export class TestHost {
2078
2402
  const window = this.windows.get(String(p.id));
2079
2403
  if (!window)
2080
2404
  throw new HostError("missing", "view not found");
2081
- this.#seeImages([p.value]);
2082
- 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;
2083
2409
  },
2084
2410
  "ui.show": (p) => {
2085
2411
  const window = this.windows.get(String(p.id));
@@ -2102,18 +2428,24 @@ export class TestHost {
2102
2428
  ? this.layout.bottom?.panels?.[0]?.plugin
2103
2429
  : undefined;
2104
2430
  if (holder !== undefined || (p.slot === "bottom" && this.layout.bottom))
2105
- throw taken(`${SLOT_LABELS[String(p.slot)]}已被 ${holder} 使用`);
2431
+ throw taken(this.#hostText("The {} is used by {}", [
2432
+ this.#hostText(SLOT_LABELS[String(p.slot)]),
2433
+ String(holder),
2434
+ ]));
2106
2435
  if (p.slot === "sidebar" &&
2107
2436
  (this.layout.sidebar?.panels?.length ?? 0) >= MAX_SIDEBAR_TABS)
2108
- throw taken(`侧栏最多 ${MAX_SIDEBAR_TABS} 个页签`);
2109
- this.#seeImages(p.properties);
2437
+ throw taken(this.#hostText("The sidebar holds at most {} tabs", [
2438
+ MAX_SIDEBAR_TABS,
2439
+ ]));
2440
+ const properties = this.#initialValues(this.#shownValues(p.properties));
2441
+ this.#seeImages(properties);
2110
2442
  const host = this;
2111
2443
  this.panel = {
2112
2444
  slot: String(p.slot),
2113
2445
  file: String(p.file),
2114
2446
  component: String(p.component),
2115
2447
  visible: true,
2116
- props: { ...p.properties },
2448
+ props: { ...properties },
2117
2449
  locks: null,
2118
2450
  emit(callback, ...args) {
2119
2451
  host.emit("ui.callback", {
@@ -2131,7 +2463,9 @@ export class TestHost {
2131
2463
  this.#addPanel(slot, {
2132
2464
  owner: 1,
2133
2465
  plugin: this.manifestId,
2134
- title: typeof p.title === "string" && p.title ? p.title : this.manifestId,
2466
+ title: typeof p.title === "string" && p.title
2467
+ ? this.translate(p.title)
2468
+ : this.manifestId,
2135
2469
  });
2136
2470
  return { status: "ready", prepareMs: 0, shellGeneration: 1 };
2137
2471
  },
@@ -2184,12 +2518,21 @@ export class TestHost {
2184
2518
  state.detached = p.detached;
2185
2519
  },
2186
2520
  "osd.show": (p) => {
2187
- const text = String(p.text);
2188
- if (!text || [...text].length > 200)
2521
+ const text = this.#shown(p.text, p.args, "text").replace(/[\n\r]/g, " ");
2522
+ if (!text.trim() || [...text].length > 200)
2189
2523
  throw new HostError("invalid", "osd text needs 1..200 characters");
2190
2524
  this.osd.push(text);
2191
2525
  },
2192
- "osd.panel": (p) => void (this.osdPanel = p.text === null ? null : String(p.text)),
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
+ },
2193
2536
  "playback.stats": () => this.stats,
2194
2537
  "audio.addTrack": (p) => {
2195
2538
  this.#audioCondition(p.expected);
@@ -2292,7 +2635,7 @@ export class TestHost {
2292
2635
  const reason = p.unit === "keyframe"
2293
2636
  ? s.phase === "ready" && s.capabilities.seek && s.hasVideo !== false
2294
2637
  ? null
2295
- : "当前来源不支持此操作"
2638
+ : this.#hostText("Not supported by the current source")
2296
2639
  : this.#frameCheck(p.direction === "forward");
2297
2640
  if (reason)
2298
2641
  throw new HostError("unsupported", reason);
@@ -2322,8 +2665,11 @@ export class TestHost {
2322
2665
  this.emit("sequence.step", { direction });
2323
2666
  },
2324
2667
  "ui.setMount": (p) => {
2325
- this.#seeImages([p.value]);
2326
- 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;
2327
2673
  },
2328
2674
  "timeline.marks": () => ({ marks: this.timeline.marks }),
2329
2675
  "drops.provide": (p) => {
@@ -2339,6 +2685,14 @@ export class TestHost {
2339
2685
  this.dropProviders.splice(index, 1);
2340
2686
  },
2341
2687
  "drops.listen": (p) => void (this.panelDrops = p.listen === true),
2688
+ "appearance.setLanguage": (p) => {
2689
+ const language = String(p.language);
2690
+ if (language !== "system" &&
2691
+ !LOCALES.includes(language))
2692
+ throw new HostError("invalid", `unknown language ${language}`);
2693
+ if (language !== this.language)
2694
+ this.setLanguage(language);
2695
+ },
2342
2696
  "appearance.set": (p) => {
2343
2697
  // Like the player: an id it does not have selects the default (the
2344
2698
  // SDK's own `set` refuses one before it gets here).
@@ -2352,16 +2706,17 @@ export class TestHost {
2352
2706
  throw new HostError("invalid", `page ${id} already exists`);
2353
2707
  if (this.settings.pages.size >= 8)
2354
2708
  throw new HostError("invalid", "a plugin has at most 8 settings pages");
2355
- this.#seeImages(p.properties);
2709
+ const properties = this.#initialValues(this.#shownValues(p.properties));
2710
+ this.#seeImages(properties);
2356
2711
  const host = this;
2357
2712
  this.settings.pages.set(id, {
2358
2713
  category: String(p.category),
2359
- title: String(p.title),
2714
+ title: this.translate(String(p.title)),
2360
2715
  order: Number(p.order ?? 0),
2361
2716
  file: String(p.file),
2362
2717
  component: String(p.component),
2363
2718
  global: String(p.global),
2364
- props: { ...p.properties },
2719
+ props: { ...properties },
2365
2720
  emit(callback, ...args) {
2366
2721
  host.emit("ui.callback", {
2367
2722
  view: `$settings:${id}`,
@@ -2375,8 +2730,10 @@ export class TestHost {
2375
2730
  const page = this.settings.pages.get(String(p.id));
2376
2731
  if (!page)
2377
2732
  throw new HostError("missing", "page not registered");
2378
- this.#seeImages([p.value]);
2379
- 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;
2380
2737
  },
2381
2738
  "settings.unregister": (p) => {
2382
2739
  this.settings.pages.delete(String(p.id));
@@ -2440,10 +2797,11 @@ export class TestHost {
2440
2797
  if (p.sourceId !== undefined &&
2441
2798
  p.sourceId !== this.source.stamp.generation)
2442
2799
  return { applied: false };
2443
- const marks = p.marks;
2800
+ const sent = p.marks;
2444
2801
  const duration = this.source.duration;
2445
- if (!Array.isArray(marks) || marks.length > 1000)
2802
+ if (!Array.isArray(sent) || sent.length > 1000)
2446
2803
  throw new HostError("invalid", "a layer holds at most 1000 marks");
2804
+ const marks = sent.map((mark) => ({ ...mark }));
2447
2805
  for (const mark of marks) {
2448
2806
  const time = mark.time;
2449
2807
  if (typeof time !== "number" ||
@@ -2453,6 +2811,9 @@ export class TestHost {
2453
2811
  throw new HostError("invalid", `time ${time} is outside the media`);
2454
2812
  if (!["chapter", "bookmark", "marker"].includes(String(mark.kind)))
2455
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");
2456
2817
  if (mark.title !== undefined &&
2457
2818
  mark.title !== null &&
2458
2819
  (typeof mark.title !== "string" || [...mark.title].length > 100))
@@ -2468,6 +2829,16 @@ export class TestHost {
2468
2829
  "ui.interaction": (p) => void (this.mounted().locks = p),
2469
2830
  "dialogs.confirm": (p) => {
2470
2831
  this.confirms.push(p);
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);
2835
+ this.confirmTexts.push({
2836
+ message: shown("message", true),
2837
+ ...(p.detail === undefined ? {} : { detail: shown("detail", true) }),
2838
+ confirm: shown("confirm"),
2839
+ cancel: shown("cancel") ?? this.#hostText("Cancel"),
2840
+ ...(p.checkbox === undefined ? {} : { checkbox: shown("checkbox") }),
2841
+ });
2471
2842
  const answer = this.dialogAnswers.shift();
2472
2843
  if (answer === undefined)
2473
2844
  throw new Error("test host: no dialog answer queued");
@@ -2478,7 +2849,7 @@ export class TestHost {
2478
2849
  "window.state": () => this.#windowFacts(),
2479
2850
  "window.setVideoArea": (p) => {
2480
2851
  if (this.mainWindow.fullscreen)
2481
- throw new HostError("unavailable", "全屏时不可用");
2852
+ throw new HostError("unavailable", this.#hostText("Not available in fullscreen"));
2482
2853
  const width = Math.max(318, Number(p.width));
2483
2854
  const height = Math.max(120, Number(p.height));
2484
2855
  const clamped = width !== p.width || height !== p.height;
@@ -2498,10 +2869,10 @@ export class TestHost {
2498
2869
  if (p.on === pip.active)
2499
2870
  return;
2500
2871
  if (p.on && !pip.supported)
2501
- throw new HostError("unsupported", "此系统不支持画中画");
2872
+ throw new HostError("unsupported", this.#hostText("Picture-in-picture is not supported on this system"));
2502
2873
  const video = this.source.phase !== "empty" && this.source.hasVideo !== false;
2503
2874
  if (p.on && !video)
2504
- throw new HostError("unavailable", "没有视频");
2875
+ throw new HostError("unavailable", this.#hostText("No video"));
2505
2876
  this.setMainWindow({
2506
2877
  // The player leaves fullscreen before the window opens.
2507
2878
  fullscreen: p.on ? false : this.mainWindow.fullscreen,
@@ -2531,7 +2902,20 @@ export class TestHost {
2531
2902
  loop: loop && { a: loop.a, b: loop.b, count: loop.count ?? null },
2532
2903
  });
2533
2904
  },
2534
- "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
+ });
2535
2919
  const answer = this.dialogAnswers.shift();
2536
2920
  if (answer === undefined)
2537
2921
  throw new Error("test host: no dialog answer queued");
@@ -2860,7 +3244,11 @@ export class TestHost {
2860
3244
  : p.select === "secondary"
2861
3245
  ? "secondary"
2862
3246
  : "primary";
2863
- 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;
2864
3252
  if (title !== null && ([...title].length < 1 || [...title].length > 200))
2865
3253
  throw new HostError("invalid", "subtitle title needs 1..200 characters");
2866
3254
  const bytes = Buffer.concat(upload.bytes);
@@ -2877,7 +3265,8 @@ export class TestHost {
2877
3265
  };
2878
3266
  this.subtitles.tracks.push({
2879
3267
  target,
2880
- title: title ?? (format === "ass" ? "外部 ASS" : "外部 SRT"),
3268
+ title: title ??
3269
+ this.#hostText(format === "ass" ? "External ASS" : "External SRT"),
2881
3270
  language: null,
2882
3271
  styleSupported: true,
2883
3272
  styleReason: null,
@@ -3157,6 +3546,8 @@ export class TestHost {
3157
3546
  */
3158
3547
  export async function startPlugin(activate, options = {}) {
3159
3548
  const host = options.host ?? new TestHost();
3549
+ if (options.locale !== undefined)
3550
+ host.locale = options.locale;
3160
3551
  const directory = options.directory instanceof URL
3161
3552
  ? fileURLToPath(options.directory)
3162
3553
  : (options.directory ?? process.cwd());
@@ -3169,6 +3560,7 @@ export async function startPlugin(activate, options = {}) {
3169
3560
  manifest = { id: read.id, capabilities: read.capabilities };
3170
3561
  }
3171
3562
  host.manifestId = manifest.id;
3563
+ host.loadCatalog(directory);
3172
3564
  const data = options.dataDirectory ?? host.dataDirectory;
3173
3565
  mkdirSync(data, { recursive: true });
3174
3566
  const instance = await createPlugin(host, manifest, directory, data, host.app);