@vestra-player/sdk 1.5.7 → 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.
@@ -69,10 +69,27 @@ import { nodeProblem } from "../processing.js";
69
69
  import { RENDER_DEFAULTS, renderProblem, VIEW_DEFAULTS, viewProblem, } from "./view.js";
70
70
  import { createPlugin } from "./index.js";
71
71
  import { THEMES } from "./appearance.js";
72
+ import { Catalog, compileCatalog, format as formatTemplate, HOST_GROUPS, HOST_ZH_HANS, LOCALES, menuKeys, parsePo, } from "../i18n.js";
72
73
  /** Throw one of these from `host.handle(...)` to simulate a definite refusal. */
73
74
  export { HostError };
74
75
  /** The player's command caps, which the test host enforces as well. */
75
76
  export { COMMAND_LIMITS, MENU_IDS };
77
+ /**
78
+ * The player's interface languages, English (the source) first, as the
79
+ * player's own table: `["en", "zh-Hans", "ja", "ko", "de", "fr", "es"]`.
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * import { LOCALES } from "@vestra-player/sdk/testing";
84
+ *
85
+ * for (const locale of LOCALES.slice(1)) {
86
+ * const { host, stop } = await startPlugin(activate, { directory, locale });
87
+ * assert.notEqual(host.command("open").display.title, "Open…");
88
+ * await stop();
89
+ * }
90
+ * ```
91
+ */
92
+ export { LOCALES };
76
93
  const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
77
94
  const tick = () => new Promise((resolve) => setImmediate(resolve));
78
95
  function initialSource() {
@@ -121,9 +138,15 @@ const taken = (message) => {
121
138
  return error;
122
139
  };
123
140
  const SLOT_LABELS = {
124
- bottom: "底栏",
125
- sidebar: "侧栏",
126
- backdrop: "视频背景",
141
+ bottom: "bottom bar",
142
+ sidebar: "sidebar",
143
+ backdrop: "video backdrop",
144
+ };
145
+ /** Texts only the test host uses, with their Chinese of SDK 1.5.8. */
146
+ const TEST_ZH_HANS = {
147
+ "this site": "这个网站",
148
+ "External SRT": "外部 SRT",
149
+ "External ASS": "外部 ASS",
127
150
  };
128
151
  /** The player's limit of sidebar tabs. */
129
152
  const MAX_SIDEBAR_TABS = 8;
@@ -227,10 +250,133 @@ export class TestHost {
227
250
  bottom: null,
228
251
  sidebar: null,
229
252
  };
230
- /** Every `osd.show` text, newest last. */
253
+ /**
254
+ * Every `osd.show` text as the player shows it (translated with the
255
+ * plugin's catalog and formatted with its args), newest last. The
256
+ * template and args the plugin sent are in `host.sent("osd.show")`.
257
+ */
231
258
  osd = [];
232
- /** The text of this plugin's osd panel, `null` when hidden. */
259
+ /** The text of this plugin's osd panel as shown, `null` when hidden. */
233
260
  osdPanel = null;
261
+ #locale;
262
+ /**
263
+ * The interface language the test host plays (`plugin.i18n.locale`), `"en"`
264
+ * by default. Set it before {@link startPlugin} (or pass its `locale`
265
+ * option): the plugin's catalog for it is read from the package's
266
+ * `locales/` then, and the menus ({@link FakeCommand.display}), OSD
267
+ * messages, dialog texts, settings page and panel titles show its
268
+ * translations, the way the player shows them.
269
+ *
270
+ * The player's own texts (core actions' messages and menus, its
271
+ * refusals) are English, or the player's Simplified Chinese for
272
+ * `"zh-Hans"`. While `locale` was never set they stay in Chinese, as
273
+ * players up to 0.4.2 showed them, so tests written for SDK 1.5.8 keep
274
+ * passing; set `locale` to test what a user sees now.
275
+ *
276
+ * @example
277
+ * ```ts
278
+ * const { host, stop } = await startPlugin(activate, { directory, locale: "zh-Hans" });
279
+ * assert.deepEqual(host.command("blur").display.path, ["视频", "模糊…"]);
280
+ * await stop();
281
+ * ```
282
+ *
283
+ * @since 1.5.9
284
+ */
285
+ get locale() {
286
+ return this.#locale ?? "en";
287
+ }
288
+ set locale(locale) {
289
+ if (!LOCALES.includes(locale))
290
+ throw new Error(`test host: unknown locale ${locale}`);
291
+ this.#locale = locale;
292
+ }
293
+ /** The plugin's translations for `locale` (see `loadCatalog`). */
294
+ #catalog = new Catalog();
295
+ /**
296
+ * Read the plugin's translations for {@link TestHost.locale} from a
297
+ * package directory, as the player would (`locales/<locale>.json` of a
298
+ * built package, else the source `locales/<locale>.po`); `startPlugin`
299
+ * does this. None for English; a missing file means none; a broken `.po`
300
+ * throws.
301
+ *
302
+ * @param directory - The package directory (holding `locales/`).
303
+ * @since 1.5.9
304
+ */
305
+ loadCatalog(directory) {
306
+ this.#catalog = new Catalog();
307
+ if (this.locale === "en")
308
+ return;
309
+ const base = join(directory, "locales", this.locale);
310
+ if (existsSync(`${base}.json`)) {
311
+ const compiled = JSON.parse(readFileSync(`${base}.json`, "utf8"));
312
+ this.#catalog = new Catalog(compiled.messages ?? {});
313
+ }
314
+ else if (existsSync(`${base}.po`))
315
+ this.#catalog = new Catalog(compileCatalog(this.locale, parsePo(readFileSync(`${base}.po`, "utf8")).entries).messages);
316
+ }
317
+ /**
318
+ * A text of the plugin as the player shows it: translated with the
319
+ * plugin's catalog ({@link TestHost.loadCatalog}), then formatted with
320
+ * `args` when given.
321
+ *
322
+ * @param text - English source text or template.
323
+ * @param args - Values for its placeholders.
324
+ * @returns The shown text.
325
+ *
326
+ * @example
327
+ * ```ts
328
+ * assert.equal(host.translate("Speed {}×", [2]), "倍速 2×");
329
+ * ```
330
+ *
331
+ * @since 1.5.9
332
+ */
333
+ translate(text, args) {
334
+ return this.#catalog.template(text, args ?? null);
335
+ }
336
+ /** A text of the player itself, in the player's language (see `locale`). */
337
+ #hostText(text, args) {
338
+ const zh = this.#locale === undefined || this.#locale === "zh-Hans";
339
+ const shown = (zh && (HOST_ZH_HANS[text] ?? TEST_ZH_HANS[text])) || text;
340
+ return args === undefined ? shown : formatTemplate(shown, args);
341
+ }
342
+ /** A declared menu path and title as the player shows them. */
343
+ #display(path, title) {
344
+ const keys = menuKeys(path);
345
+ return {
346
+ title: this.translate(title),
347
+ // The player's submenus by key, everything else as declared.
348
+ path: keys.map((segment, index) => index < keys.length - 1 &&
349
+ HOST_GROUPS.includes(keys.slice(0, index + 1).join("/"))
350
+ ? this.#hostText(segment)
351
+ : this.translate(path[index] ?? segment)),
352
+ };
353
+ }
354
+ /**
355
+ * The saved interface language (`plugin.appearance.language`), changed by
356
+ * `appearance.setLanguage` or {@link TestHost.setLanguage}.
357
+ *
358
+ * @since 1.5.9
359
+ */
360
+ language = "system";
361
+ /**
362
+ * Simulate another plugin (or the user) saving the interface language:
363
+ * pushes `appearance.changed`.
364
+ *
365
+ * @param language - `"system"` or a locale.
366
+ * @since 1.5.9
367
+ */
368
+ setLanguage(language) {
369
+ this.language = language;
370
+ this.emit("appearance.changed", { theme: this.appearance, language });
371
+ }
372
+ /**
373
+ * Every `dialogs.confirm` as the player shows it: its texts translated
374
+ * (message and detail formatted with `args`), newest last. The options
375
+ * the plugin sent are in {@link TestHost.confirms}.
376
+ *
377
+ * @since 1.5.9
378
+ */
379
+ confirmTexts = [];
234
380
  /** The sequence provider's flags, `null` without a provider. */
235
381
  sequence = null;
236
382
  /**
@@ -773,6 +919,43 @@ export class TestHost {
773
919
  * ```
774
920
  */
775
921
  otherResolvers = [];
922
+ /**
923
+ * What `plugin.resolvers.fileTypes` reports: the extensions of this
924
+ * plugin's file resolvers and of {@link TestHost.otherResolvers}, sorted,
925
+ * each once.
926
+ */
927
+ fileTypes() {
928
+ const types = new Set();
929
+ for (const declaration of [
930
+ ...this.resolvers.values(),
931
+ ...this.otherResolvers,
932
+ ])
933
+ for (const type of declaration.extensions ?? [])
934
+ types.add(type);
935
+ return [...types].sort();
936
+ }
937
+ #sentTypes = "[]";
938
+ /**
939
+ * Push `resolvers.fileTypes` when {@link TestHost.fileTypes} changed:
940
+ * called after this plugin's resolvers come and go; call it yourself
941
+ * after changing {@link TestHost.otherResolvers} (another plugin started
942
+ * or stopped).
943
+ *
944
+ * @example
945
+ * ```ts
946
+ * host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
947
+ * host.updateFileTypes();
948
+ * await host.settle();
949
+ * assert.deepEqual(plugin.resolvers.fileTypes, ["nes"]);
950
+ * ```
951
+ */
952
+ updateFileTypes() {
953
+ const types = this.fileTypes();
954
+ if (JSON.stringify(types) === this.#sentTypes)
955
+ return;
956
+ this.#sentTypes = JSON.stringify(types);
957
+ this.emit("resolvers.fileTypes", { fileTypes: types });
958
+ }
776
959
  /**
777
960
  * File opens waiting for this plugin's file resolver (`extensions`), by
778
961
  * request id: the path and how it was opened. Opening a matching path
@@ -976,14 +1159,14 @@ export class TestHost {
976
1159
  /** Why one frame that way is impossible (B07), or null; the player's rules. */
977
1160
  #frameCheck(forward) {
978
1161
  const s = this.source;
979
- const unsupported = "当前来源不支持此操作";
980
- const noBack = "当前来源不支持逐帧后退";
1162
+ const unsupported = this.#hostText("Not supported by the current source");
1163
+ const noBack = this.#hostText("The current source cannot step back a frame");
981
1164
  if (s.phase !== "ready" && s.phase !== "ended")
982
1165
  return unsupported;
983
1166
  if (s.kind !== "media")
984
1167
  return forward ? unsupported : noBack;
985
1168
  if (s.hasVideo === false)
986
- return "没有视频";
1169
+ return this.#hostText("No video");
987
1170
  if (forward && s.phase !== "ready")
988
1171
  return unsupported;
989
1172
  if (!forward && !s.capabilities.seek)
@@ -994,12 +1177,16 @@ export class TestHost {
994
1177
  #coreState(action) {
995
1178
  const f = this.#facts();
996
1179
  const s = this.source;
997
- const unsupported = "当前来源不支持此操作";
1180
+ const unsupported = this.#hostText("Not supported by the current source");
998
1181
  const rule = {
999
1182
  "play-pause": [f.toggle, f.ready ? s.paused : null, unsupported],
1000
1183
  stop: [f.close, null, unsupported],
1001
- previous: [!!this.sequence?.canPrevious, null, "没有上一个"],
1002
- next: [!!this.sequence?.canNext, null, "没有下一个"],
1184
+ previous: [
1185
+ !!this.sequence?.canPrevious,
1186
+ null,
1187
+ this.#hostText("No previous item"),
1188
+ ],
1189
+ next: [!!this.sequence?.canNext, null, this.#hostText("No next item")],
1003
1190
  "frame-back": [
1004
1191
  !this.#frameCheck(false),
1005
1192
  null,
@@ -1024,11 +1211,18 @@ export class TestHost {
1024
1211
  }
1025
1212
  #registerCoreActions() {
1026
1213
  const table = [
1027
- ["stop", ["关闭"], 120, "open", ["F4"], false],
1028
- ["play-pause", ["播放", "播放/暂停"], 201, "domain", ["Space"], false],
1214
+ ["stop", ["Close"], 120, "open", ["F4"], false],
1215
+ [
1216
+ "play-pause",
1217
+ ["Playback", "Play/Pause"],
1218
+ 201,
1219
+ "domain",
1220
+ ["Space"],
1221
+ false,
1222
+ ],
1029
1223
  [
1030
1224
  "previous",
1031
- ["播放", "上一个"],
1225
+ ["Playback", "Previous"],
1032
1226
  202,
1033
1227
  "domain",
1034
1228
  ["PageUp", "Mouse:X1"],
@@ -1036,17 +1230,31 @@ export class TestHost {
1036
1230
  ],
1037
1231
  [
1038
1232
  "next",
1039
- ["播放", "下一个"],
1233
+ ["Playback", "Next"],
1040
1234
  203,
1041
1235
  "domain",
1042
1236
  ["PageDown", "Mouse:X2"],
1043
1237
  false,
1044
1238
  ],
1045
- ["frame-back", ["播放", "跳转", "上一帧"], 204, "frame", ["D"], true],
1046
- ["frame-forward", ["播放", "跳转", "下一帧"], 205, "frame", ["F"], true],
1239
+ [
1240
+ "frame-back",
1241
+ ["Playback", "Seek", "Previous Frame"],
1242
+ 204,
1243
+ "frame",
1244
+ ["D"],
1245
+ true,
1246
+ ],
1247
+ [
1248
+ "frame-forward",
1249
+ ["Playback", "Seek", "Next Frame"],
1250
+ 205,
1251
+ "frame",
1252
+ ["F"],
1253
+ true,
1254
+ ],
1047
1255
  [
1048
1256
  "seek-backward",
1049
- ["播放", "跳转", "后退5秒"],
1257
+ ["Playback", "Seek", "Back 5 s"],
1050
1258
  210,
1051
1259
  "step",
1052
1260
  ["ArrowLeft"],
@@ -1054,7 +1262,7 @@ export class TestHost {
1054
1262
  ],
1055
1263
  [
1056
1264
  "seek-forward",
1057
- ["播放", "跳转", "前进5秒"],
1265
+ ["Playback", "Seek", "Forward 5 s"],
1058
1266
  211,
1059
1267
  "step",
1060
1268
  ["ArrowRight"],
@@ -1062,18 +1270,32 @@ export class TestHost {
1062
1270
  ],
1063
1271
  [
1064
1272
  "rate-normal",
1065
- ["播放", "速度", "正常"],
1273
+ ["Playback", "Speed", "Normal"],
1066
1274
  220,
1067
1275
  "domain",
1068
1276
  ["Z"],
1069
1277
  false,
1070
1278
  true,
1071
1279
  ],
1072
- ["rate-down", ["播放", "速度", "减速 0.1"], 221, "domain", ["X"], false],
1073
- ["rate-up", ["播放", "速度", "加速 0.1"], 222, "domain", ["C"], false],
1280
+ [
1281
+ "rate-down",
1282
+ ["Playback", "Speed", "Slower 0.1"],
1283
+ 221,
1284
+ "domain",
1285
+ ["X"],
1286
+ false,
1287
+ ],
1288
+ [
1289
+ "rate-up",
1290
+ ["Playback", "Speed", "Faster 0.1"],
1291
+ 222,
1292
+ "domain",
1293
+ ["C"],
1294
+ false,
1295
+ ],
1074
1296
  [
1075
1297
  "volume-up",
1076
- ["声音", "音量 +"],
1298
+ ["Audio", "Volume +"],
1077
1299
  501,
1078
1300
  "domain",
1079
1301
  ["ArrowUp", "Mouse:WheelUp"],
@@ -1081,13 +1303,13 @@ export class TestHost {
1081
1303
  ],
1082
1304
  [
1083
1305
  "volume-down",
1084
- ["声音", "音量 −"],
1306
+ ["Audio", "Volume −"],
1085
1307
  502,
1086
1308
  "domain",
1087
1309
  ["ArrowDown", "Mouse:WheelDown"],
1088
1310
  true,
1089
1311
  ],
1090
- ["mute", ["声音", "静音"], 503, "domain", ["M"], false],
1312
+ ["mute", ["Audio", "Mute"], 503, "domain", ["M"], false],
1091
1313
  ];
1092
1314
  const host = this;
1093
1315
  for (const [id, path, order, group, bindings, repeat, radio] of table) {
@@ -1101,6 +1323,18 @@ export class TestHost {
1101
1323
  bindings,
1102
1324
  repeat,
1103
1325
  },
1326
+ // The player's own words in the player's language.
1327
+ get display() {
1328
+ const leaf = path.at(-1);
1329
+ const step = /^(Back|Forward) (\S+) s$/.exec(leaf);
1330
+ const title = step
1331
+ ? host.#hostText(`${step[1]} {} s`, [step[2]])
1332
+ : host.#hostText(leaf);
1333
+ return {
1334
+ title,
1335
+ path: [...path.slice(0, -1).map((s) => host.#hostText(s)), title],
1336
+ };
1337
+ },
1104
1338
  get state() {
1105
1339
  return host.#coreState(id);
1106
1340
  },
@@ -1138,7 +1372,7 @@ export class TestHost {
1138
1372
  kind: "control",
1139
1373
  control: { kind: "pause", paused: !s.paused },
1140
1374
  });
1141
- this.osd.push(s.phase !== "ended" && !s.paused ? "已暂停" : "播放");
1375
+ this.osd.push(this.#hostText(s.phase !== "ended" && !s.paused ? "Paused" : "Playing"));
1142
1376
  return;
1143
1377
  case "stop":
1144
1378
  this.#intent({ kind: "close" });
@@ -1163,7 +1397,7 @@ export class TestHost {
1163
1397
  kind: "control",
1164
1398
  control: { kind: "seek", seconds: target },
1165
1399
  });
1166
- this.osd.push(`${delta < 0 ? "后退" : "前进"} ${Math.abs(delta)}秒: ${time(target)} / ${time(s.duration)}`);
1400
+ this.osd.push(this.#hostText(delta < 0 ? "Back {} s: {} / {}" : "Forward {} s: {} / {}", [Math.abs(delta), time(target), time(s.duration)]));
1167
1401
  return;
1168
1402
  }
1169
1403
  case "rate-normal":
@@ -1173,7 +1407,7 @@ export class TestHost {
1173
1407
  ? 1
1174
1408
  : Math.max(0.25, Math.min(4, Math.round((s.rate + (action === "rate-up" ? 0.1 : -0.1)) * 100) / 100));
1175
1409
  this.#intent({ kind: "control", control: { kind: "rate", rate } });
1176
- this.osd.push(`播放速度: ${rateText(rate)}x`);
1410
+ this.osd.push(this.#hostText("Speed: {}x", [rateText(rate)]));
1177
1411
  return;
1178
1412
  }
1179
1413
  case "volume-up":
@@ -1184,12 +1418,12 @@ export class TestHost {
1184
1418
  ? Math.max(s.output.volume, Math.min(100, Math.round(s.output.volume + 5)))
1185
1419
  : Math.max(0, Math.min(200, Math.round(s.output.volume - 5)));
1186
1420
  this.setSource({ output: { volume, muted: false } });
1187
- this.osd.push(`音量: ${volume}%`);
1421
+ this.osd.push(this.#hostText("Volume: {}%", [volume]));
1188
1422
  return;
1189
1423
  }
1190
1424
  case "mute":
1191
1425
  this.setSource({ output: { ...s.output, muted: !s.output.muted } });
1192
- this.osd.push(s.output.muted ? "静音: 关" : "静音: 开");
1426
+ this.osd.push(this.#hostText(s.output.muted ? "Mute: Off" : "Mute: On"));
1193
1427
  return;
1194
1428
  }
1195
1429
  }
@@ -1361,6 +1595,9 @@ export class TestHost {
1361
1595
  videoArea: { ...this.mainWindow.videoArea },
1362
1596
  pictureInPicture: { ...this.mainWindow.pictureInPicture },
1363
1597
  appearance: this.appearance,
1598
+ fileTypes: this.fileTypes(),
1599
+ language: this.language,
1600
+ locale: this.locale,
1364
1601
  };
1365
1602
  }
1366
1603
  /**
@@ -1548,10 +1785,14 @@ export class TestHost {
1548
1785
  const key = this.#bindingKey(id);
1549
1786
  const scope = declaration.focus ? "panel" : "global";
1550
1787
  const current = this.bindings.overlay.get(key) ?? declaration.bindings;
1788
+ const shown = this.commands.get(id) ?? this.coreCommands.get(id);
1551
1789
  return {
1552
1790
  ...this.#split(key),
1553
- title: declaration.title,
1554
- path: declaration.path?.length ? declaration.path : null,
1791
+ // What the user sees, like the player's binding table.
1792
+ title: shown?.display.title ?? declaration.title,
1793
+ path: declaration.path?.length
1794
+ ? (shown?.display.path ?? declaration.path)
1795
+ : null,
1555
1796
  scope: scope,
1556
1797
  defaults: declaration.bindings,
1557
1798
  current,
@@ -1860,8 +2101,10 @@ export class TestHost {
1860
2101
  for (const other of this.otherResolvers) {
1861
2102
  const what = claims(other);
1862
2103
  if (what) {
1863
- const target = what === "host" ? "这个网站" : `文件类型 .${what}`;
1864
- throw taken(`${target} 已由 ${other.owner} 解析`);
2104
+ const target = what === "host"
2105
+ ? this.#hostText("this site")
2106
+ : this.#hostText("File type .{}", [what]);
2107
+ throw taken(this.#hostText("{} is resolved by {}", [target, other.owner]));
1865
2108
  }
1866
2109
  }
1867
2110
  this.resolvers.set(id, p);
@@ -1988,8 +2231,12 @@ export class TestHost {
1988
2231
  this.commands.size + this.coreCommands.size >= COMMAND_LIMITS.total)
1989
2232
  throw new HostError("limit", "command registry limit reached");
1990
2233
  const host = this;
2234
+ const declaration = p.declaration;
1991
2235
  this.commands.set(id, {
1992
- declaration: p.declaration,
2236
+ declaration,
2237
+ get display() {
2238
+ return host.#display(declaration.path ?? [], declaration.title);
2239
+ },
1993
2240
  state: p.state,
1994
2241
  revision: Number(p.revision),
1995
2242
  async run() {
@@ -2064,10 +2311,15 @@ export class TestHost {
2064
2311
  ? this.layout.bottom?.panels?.[0]?.plugin
2065
2312
  : undefined;
2066
2313
  if (holder !== undefined || (p.slot === "bottom" && this.layout.bottom))
2067
- throw taken(`${SLOT_LABELS[String(p.slot)]}已被 ${holder} 使用`);
2314
+ throw taken(this.#hostText("The {} is used by {}", [
2315
+ this.#hostText(SLOT_LABELS[String(p.slot)]),
2316
+ String(holder),
2317
+ ]));
2068
2318
  if (p.slot === "sidebar" &&
2069
2319
  (this.layout.sidebar?.panels?.length ?? 0) >= MAX_SIDEBAR_TABS)
2070
- throw taken(`侧栏最多 ${MAX_SIDEBAR_TABS} 个页签`);
2320
+ throw taken(this.#hostText("The sidebar holds at most {} tabs", [
2321
+ MAX_SIDEBAR_TABS,
2322
+ ]));
2071
2323
  this.#seeImages(p.properties);
2072
2324
  const host = this;
2073
2325
  this.panel = {
@@ -2093,7 +2345,9 @@ export class TestHost {
2093
2345
  this.#addPanel(slot, {
2094
2346
  owner: 1,
2095
2347
  plugin: this.manifestId,
2096
- title: typeof p.title === "string" && p.title ? p.title : this.manifestId,
2348
+ title: typeof p.title === "string" && p.title
2349
+ ? this.translate(p.title)
2350
+ : this.manifestId,
2097
2351
  });
2098
2352
  return { status: "ready", prepareMs: 0, shellGeneration: 1 };
2099
2353
  },
@@ -2146,12 +2400,15 @@ export class TestHost {
2146
2400
  state.detached = p.detached;
2147
2401
  },
2148
2402
  "osd.show": (p) => {
2149
- const text = String(p.text);
2150
- if (!text || [...text].length > 200)
2403
+ const text = this.translate(String(p.text), p.args).replace(/[\n\r]/g, " ");
2404
+ if (!text.trim() || [...text].length > 200)
2151
2405
  throw new HostError("invalid", "osd text needs 1..200 characters");
2152
2406
  this.osd.push(text);
2153
2407
  },
2154
- "osd.panel": (p) => void (this.osdPanel = p.text === null ? null : String(p.text)),
2408
+ "osd.panel": (p) => void (this.osdPanel =
2409
+ p.text === null
2410
+ ? null
2411
+ : this.translate(String(p.text), p.args)),
2155
2412
  "playback.stats": () => this.stats,
2156
2413
  "audio.addTrack": (p) => {
2157
2414
  this.#audioCondition(p.expected);
@@ -2254,7 +2511,7 @@ export class TestHost {
2254
2511
  const reason = p.unit === "keyframe"
2255
2512
  ? s.phase === "ready" && s.capabilities.seek && s.hasVideo !== false
2256
2513
  ? null
2257
- : "当前来源不支持此操作"
2514
+ : this.#hostText("Not supported by the current source")
2258
2515
  : this.#frameCheck(p.direction === "forward");
2259
2516
  if (reason)
2260
2517
  throw new HostError("unsupported", reason);
@@ -2301,6 +2558,14 @@ export class TestHost {
2301
2558
  this.dropProviders.splice(index, 1);
2302
2559
  },
2303
2560
  "drops.listen": (p) => void (this.panelDrops = p.listen === true),
2561
+ "appearance.setLanguage": (p) => {
2562
+ const language = String(p.language);
2563
+ if (language !== "system" &&
2564
+ !LOCALES.includes(language))
2565
+ throw new HostError("invalid", `unknown language ${language}`);
2566
+ if (language !== this.language)
2567
+ this.setLanguage(language);
2568
+ },
2304
2569
  "appearance.set": (p) => {
2305
2570
  // Like the player: an id it does not have selects the default (the
2306
2571
  // SDK's own `set` refuses one before it gets here).
@@ -2318,7 +2583,7 @@ export class TestHost {
2318
2583
  const host = this;
2319
2584
  this.settings.pages.set(id, {
2320
2585
  category: String(p.category),
2321
- title: String(p.title),
2586
+ title: this.translate(String(p.title)),
2322
2587
  order: Number(p.order ?? 0),
2323
2588
  file: String(p.file),
2324
2589
  component: String(p.component),
@@ -2430,6 +2695,17 @@ export class TestHost {
2430
2695
  "ui.interaction": (p) => void (this.mounted().locks = p),
2431
2696
  "dialogs.confirm": (p) => {
2432
2697
  this.confirms.push(p);
2698
+ const args = p.args;
2699
+ const shown = (field, formatted = false) => typeof p[field] === "string"
2700
+ ? this.translate(p[field], formatted ? args : undefined)
2701
+ : undefined;
2702
+ this.confirmTexts.push({
2703
+ message: shown("message", true),
2704
+ ...(p.detail === undefined ? {} : { detail: shown("detail", true) }),
2705
+ confirm: shown("confirm"),
2706
+ cancel: shown("cancel") ?? this.#hostText("Cancel"),
2707
+ ...(p.checkbox === undefined ? {} : { checkbox: shown("checkbox") }),
2708
+ });
2433
2709
  const answer = this.dialogAnswers.shift();
2434
2710
  if (answer === undefined)
2435
2711
  throw new Error("test host: no dialog answer queued");
@@ -2440,7 +2716,7 @@ export class TestHost {
2440
2716
  "window.state": () => this.#windowFacts(),
2441
2717
  "window.setVideoArea": (p) => {
2442
2718
  if (this.mainWindow.fullscreen)
2443
- throw new HostError("unavailable", "全屏时不可用");
2719
+ throw new HostError("unavailable", this.#hostText("Not available in fullscreen"));
2444
2720
  const width = Math.max(318, Number(p.width));
2445
2721
  const height = Math.max(120, Number(p.height));
2446
2722
  const clamped = width !== p.width || height !== p.height;
@@ -2460,10 +2736,10 @@ export class TestHost {
2460
2736
  if (p.on === pip.active)
2461
2737
  return;
2462
2738
  if (p.on && !pip.supported)
2463
- throw new HostError("unsupported", "此系统不支持画中画");
2739
+ throw new HostError("unsupported", this.#hostText("Picture-in-picture is not supported on this system"));
2464
2740
  const video = this.source.phase !== "empty" && this.source.hasVideo !== false;
2465
2741
  if (p.on && !video)
2466
- throw new HostError("unavailable", "没有视频");
2742
+ throw new HostError("unavailable", this.#hostText("No video"));
2467
2743
  this.setMainWindow({
2468
2744
  // The player leaves fullscreen before the window opens.
2469
2745
  fullscreen: p.on ? false : this.mainWindow.fullscreen,
@@ -2683,8 +2959,6 @@ export class TestHost {
2683
2959
  const entry = this.packages.find((each) => each.id === id);
2684
2960
  if (!entry)
2685
2961
  throw new HostError("missing", `no plugin ${id}`);
2686
- if (entry.origin === "trial")
2687
- throw new HostError("unsupported", "a trial is stopped with its handle");
2688
2962
  this.packageCalls.push({
2689
2963
  method: "setEnabled",
2690
2964
  id,
@@ -2841,7 +3115,8 @@ export class TestHost {
2841
3115
  };
2842
3116
  this.subtitles.tracks.push({
2843
3117
  target,
2844
- title: title ?? (format === "ass" ? "外部 ASS" : "外部 SRT"),
3118
+ title: title ??
3119
+ this.#hostText(format === "ass" ? "External ASS" : "External SRT"),
2845
3120
  language: null,
2846
3121
  styleSupported: true,
2847
3122
  styleReason: null,
@@ -2944,10 +3219,14 @@ export class TestHost {
2944
3219
  return { jobId: job.jobId, cancel: "requested" };
2945
3220
  },
2946
3221
  "jobs.operation": () => ({ status: "unknown" }),
2947
- "sourceResolvers.register": (p) => void this.#register(p),
3222
+ "sourceResolvers.register": (p) => {
3223
+ this.#register(p);
3224
+ this.updateFileTypes();
3225
+ },
2948
3226
  "sourceResolvers.unregister": (p) => {
2949
3227
  this.resolvers.delete(String(p.id));
2950
3228
  this.#cancelResolutions();
3229
+ this.updateFileTypes();
2951
3230
  },
2952
3231
  "sourceResolvers.respond": (p) => this.#respond(p),
2953
3232
  "producer.prepare": async () => {
@@ -3117,6 +3396,8 @@ export class TestHost {
3117
3396
  */
3118
3397
  export async function startPlugin(activate, options = {}) {
3119
3398
  const host = options.host ?? new TestHost();
3399
+ if (options.locale !== undefined)
3400
+ host.locale = options.locale;
3120
3401
  const directory = options.directory instanceof URL
3121
3402
  ? fileURLToPath(options.directory)
3122
3403
  : (options.directory ?? process.cwd());
@@ -3129,6 +3410,7 @@ export async function startPlugin(activate, options = {}) {
3129
3410
  manifest = { id: read.id, capabilities: read.capabilities };
3130
3411
  }
3131
3412
  host.manifestId = manifest.id;
3413
+ host.loadCatalog(directory);
3132
3414
  const data = options.dataDirectory ?? host.dataDirectory;
3133
3415
  mkdirSync(data, { recursive: true });
3134
3416
  const instance = await createPlugin(host, manifest, directory, data, host.app);