@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.
@@ -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
  /**
@@ -1013,14 +1159,14 @@ export class TestHost {
1013
1159
  /** Why one frame that way is impossible (B07), or null; the player's rules. */
1014
1160
  #frameCheck(forward) {
1015
1161
  const s = this.source;
1016
- const unsupported = "当前来源不支持此操作";
1017
- 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");
1018
1164
  if (s.phase !== "ready" && s.phase !== "ended")
1019
1165
  return unsupported;
1020
1166
  if (s.kind !== "media")
1021
1167
  return forward ? unsupported : noBack;
1022
1168
  if (s.hasVideo === false)
1023
- return "没有视频";
1169
+ return this.#hostText("No video");
1024
1170
  if (forward && s.phase !== "ready")
1025
1171
  return unsupported;
1026
1172
  if (!forward && !s.capabilities.seek)
@@ -1031,12 +1177,16 @@ export class TestHost {
1031
1177
  #coreState(action) {
1032
1178
  const f = this.#facts();
1033
1179
  const s = this.source;
1034
- const unsupported = "当前来源不支持此操作";
1180
+ const unsupported = this.#hostText("Not supported by the current source");
1035
1181
  const rule = {
1036
1182
  "play-pause": [f.toggle, f.ready ? s.paused : null, unsupported],
1037
1183
  stop: [f.close, null, unsupported],
1038
- previous: [!!this.sequence?.canPrevious, null, "没有上一个"],
1039
- 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")],
1040
1190
  "frame-back": [
1041
1191
  !this.#frameCheck(false),
1042
1192
  null,
@@ -1061,11 +1211,18 @@ export class TestHost {
1061
1211
  }
1062
1212
  #registerCoreActions() {
1063
1213
  const table = [
1064
- ["stop", ["关闭"], 120, "open", ["F4"], false],
1065
- ["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
+ ],
1066
1223
  [
1067
1224
  "previous",
1068
- ["播放", "上一个"],
1225
+ ["Playback", "Previous"],
1069
1226
  202,
1070
1227
  "domain",
1071
1228
  ["PageUp", "Mouse:X1"],
@@ -1073,17 +1230,31 @@ export class TestHost {
1073
1230
  ],
1074
1231
  [
1075
1232
  "next",
1076
- ["播放", "下一个"],
1233
+ ["Playback", "Next"],
1077
1234
  203,
1078
1235
  "domain",
1079
1236
  ["PageDown", "Mouse:X2"],
1080
1237
  false,
1081
1238
  ],
1082
- ["frame-back", ["播放", "跳转", "上一帧"], 204, "frame", ["D"], true],
1083
- ["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
+ ],
1084
1255
  [
1085
1256
  "seek-backward",
1086
- ["播放", "跳转", "后退5秒"],
1257
+ ["Playback", "Seek", "Back 5 s"],
1087
1258
  210,
1088
1259
  "step",
1089
1260
  ["ArrowLeft"],
@@ -1091,7 +1262,7 @@ export class TestHost {
1091
1262
  ],
1092
1263
  [
1093
1264
  "seek-forward",
1094
- ["播放", "跳转", "前进5秒"],
1265
+ ["Playback", "Seek", "Forward 5 s"],
1095
1266
  211,
1096
1267
  "step",
1097
1268
  ["ArrowRight"],
@@ -1099,18 +1270,32 @@ export class TestHost {
1099
1270
  ],
1100
1271
  [
1101
1272
  "rate-normal",
1102
- ["播放", "速度", "正常"],
1273
+ ["Playback", "Speed", "Normal"],
1103
1274
  220,
1104
1275
  "domain",
1105
1276
  ["Z"],
1106
1277
  false,
1107
1278
  true,
1108
1279
  ],
1109
- ["rate-down", ["播放", "速度", "减速 0.1"], 221, "domain", ["X"], false],
1110
- ["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
+ ],
1111
1296
  [
1112
1297
  "volume-up",
1113
- ["声音", "音量 +"],
1298
+ ["Audio", "Volume +"],
1114
1299
  501,
1115
1300
  "domain",
1116
1301
  ["ArrowUp", "Mouse:WheelUp"],
@@ -1118,13 +1303,13 @@ export class TestHost {
1118
1303
  ],
1119
1304
  [
1120
1305
  "volume-down",
1121
- ["声音", "音量 −"],
1306
+ ["Audio", "Volume −"],
1122
1307
  502,
1123
1308
  "domain",
1124
1309
  ["ArrowDown", "Mouse:WheelDown"],
1125
1310
  true,
1126
1311
  ],
1127
- ["mute", ["声音", "静音"], 503, "domain", ["M"], false],
1312
+ ["mute", ["Audio", "Mute"], 503, "domain", ["M"], false],
1128
1313
  ];
1129
1314
  const host = this;
1130
1315
  for (const [id, path, order, group, bindings, repeat, radio] of table) {
@@ -1138,6 +1323,18 @@ export class TestHost {
1138
1323
  bindings,
1139
1324
  repeat,
1140
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
+ },
1141
1338
  get state() {
1142
1339
  return host.#coreState(id);
1143
1340
  },
@@ -1175,7 +1372,7 @@ export class TestHost {
1175
1372
  kind: "control",
1176
1373
  control: { kind: "pause", paused: !s.paused },
1177
1374
  });
1178
- this.osd.push(s.phase !== "ended" && !s.paused ? "已暂停" : "播放");
1375
+ this.osd.push(this.#hostText(s.phase !== "ended" && !s.paused ? "Paused" : "Playing"));
1179
1376
  return;
1180
1377
  case "stop":
1181
1378
  this.#intent({ kind: "close" });
@@ -1200,7 +1397,7 @@ export class TestHost {
1200
1397
  kind: "control",
1201
1398
  control: { kind: "seek", seconds: target },
1202
1399
  });
1203
- 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)]));
1204
1401
  return;
1205
1402
  }
1206
1403
  case "rate-normal":
@@ -1210,7 +1407,7 @@ export class TestHost {
1210
1407
  ? 1
1211
1408
  : Math.max(0.25, Math.min(4, Math.round((s.rate + (action === "rate-up" ? 0.1 : -0.1)) * 100) / 100));
1212
1409
  this.#intent({ kind: "control", control: { kind: "rate", rate } });
1213
- this.osd.push(`播放速度: ${rateText(rate)}x`);
1410
+ this.osd.push(this.#hostText("Speed: {}x", [rateText(rate)]));
1214
1411
  return;
1215
1412
  }
1216
1413
  case "volume-up":
@@ -1221,12 +1418,12 @@ export class TestHost {
1221
1418
  ? Math.max(s.output.volume, Math.min(100, Math.round(s.output.volume + 5)))
1222
1419
  : Math.max(0, Math.min(200, Math.round(s.output.volume - 5)));
1223
1420
  this.setSource({ output: { volume, muted: false } });
1224
- this.osd.push(`音量: ${volume}%`);
1421
+ this.osd.push(this.#hostText("Volume: {}%", [volume]));
1225
1422
  return;
1226
1423
  }
1227
1424
  case "mute":
1228
1425
  this.setSource({ output: { ...s.output, muted: !s.output.muted } });
1229
- this.osd.push(s.output.muted ? "静音: 关" : "静音: 开");
1426
+ this.osd.push(this.#hostText(s.output.muted ? "Mute: Off" : "Mute: On"));
1230
1427
  return;
1231
1428
  }
1232
1429
  }
@@ -1399,6 +1596,8 @@ export class TestHost {
1399
1596
  pictureInPicture: { ...this.mainWindow.pictureInPicture },
1400
1597
  appearance: this.appearance,
1401
1598
  fileTypes: this.fileTypes(),
1599
+ language: this.language,
1600
+ locale: this.locale,
1402
1601
  };
1403
1602
  }
1404
1603
  /**
@@ -1586,10 +1785,14 @@ export class TestHost {
1586
1785
  const key = this.#bindingKey(id);
1587
1786
  const scope = declaration.focus ? "panel" : "global";
1588
1787
  const current = this.bindings.overlay.get(key) ?? declaration.bindings;
1788
+ const shown = this.commands.get(id) ?? this.coreCommands.get(id);
1589
1789
  return {
1590
1790
  ...this.#split(key),
1591
- title: declaration.title,
1592
- 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,
1593
1796
  scope: scope,
1594
1797
  defaults: declaration.bindings,
1595
1798
  current,
@@ -1898,8 +2101,10 @@ export class TestHost {
1898
2101
  for (const other of this.otherResolvers) {
1899
2102
  const what = claims(other);
1900
2103
  if (what) {
1901
- const target = what === "host" ? "这个网站" : `文件类型 .${what}`;
1902
- 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]));
1903
2108
  }
1904
2109
  }
1905
2110
  this.resolvers.set(id, p);
@@ -2026,8 +2231,12 @@ export class TestHost {
2026
2231
  this.commands.size + this.coreCommands.size >= COMMAND_LIMITS.total)
2027
2232
  throw new HostError("limit", "command registry limit reached");
2028
2233
  const host = this;
2234
+ const declaration = p.declaration;
2029
2235
  this.commands.set(id, {
2030
- declaration: p.declaration,
2236
+ declaration,
2237
+ get display() {
2238
+ return host.#display(declaration.path ?? [], declaration.title);
2239
+ },
2031
2240
  state: p.state,
2032
2241
  revision: Number(p.revision),
2033
2242
  async run() {
@@ -2102,10 +2311,15 @@ export class TestHost {
2102
2311
  ? this.layout.bottom?.panels?.[0]?.plugin
2103
2312
  : undefined;
2104
2313
  if (holder !== undefined || (p.slot === "bottom" && this.layout.bottom))
2105
- 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
+ ]));
2106
2318
  if (p.slot === "sidebar" &&
2107
2319
  (this.layout.sidebar?.panels?.length ?? 0) >= MAX_SIDEBAR_TABS)
2108
- throw taken(`侧栏最多 ${MAX_SIDEBAR_TABS} 个页签`);
2320
+ throw taken(this.#hostText("The sidebar holds at most {} tabs", [
2321
+ MAX_SIDEBAR_TABS,
2322
+ ]));
2109
2323
  this.#seeImages(p.properties);
2110
2324
  const host = this;
2111
2325
  this.panel = {
@@ -2131,7 +2345,9 @@ export class TestHost {
2131
2345
  this.#addPanel(slot, {
2132
2346
  owner: 1,
2133
2347
  plugin: this.manifestId,
2134
- 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,
2135
2351
  });
2136
2352
  return { status: "ready", prepareMs: 0, shellGeneration: 1 };
2137
2353
  },
@@ -2184,12 +2400,15 @@ export class TestHost {
2184
2400
  state.detached = p.detached;
2185
2401
  },
2186
2402
  "osd.show": (p) => {
2187
- const text = String(p.text);
2188
- 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)
2189
2405
  throw new HostError("invalid", "osd text needs 1..200 characters");
2190
2406
  this.osd.push(text);
2191
2407
  },
2192
- "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)),
2193
2412
  "playback.stats": () => this.stats,
2194
2413
  "audio.addTrack": (p) => {
2195
2414
  this.#audioCondition(p.expected);
@@ -2292,7 +2511,7 @@ export class TestHost {
2292
2511
  const reason = p.unit === "keyframe"
2293
2512
  ? s.phase === "ready" && s.capabilities.seek && s.hasVideo !== false
2294
2513
  ? null
2295
- : "当前来源不支持此操作"
2514
+ : this.#hostText("Not supported by the current source")
2296
2515
  : this.#frameCheck(p.direction === "forward");
2297
2516
  if (reason)
2298
2517
  throw new HostError("unsupported", reason);
@@ -2339,6 +2558,14 @@ export class TestHost {
2339
2558
  this.dropProviders.splice(index, 1);
2340
2559
  },
2341
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
+ },
2342
2569
  "appearance.set": (p) => {
2343
2570
  // Like the player: an id it does not have selects the default (the
2344
2571
  // SDK's own `set` refuses one before it gets here).
@@ -2356,7 +2583,7 @@ export class TestHost {
2356
2583
  const host = this;
2357
2584
  this.settings.pages.set(id, {
2358
2585
  category: String(p.category),
2359
- title: String(p.title),
2586
+ title: this.translate(String(p.title)),
2360
2587
  order: Number(p.order ?? 0),
2361
2588
  file: String(p.file),
2362
2589
  component: String(p.component),
@@ -2468,6 +2695,17 @@ export class TestHost {
2468
2695
  "ui.interaction": (p) => void (this.mounted().locks = p),
2469
2696
  "dialogs.confirm": (p) => {
2470
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
+ });
2471
2709
  const answer = this.dialogAnswers.shift();
2472
2710
  if (answer === undefined)
2473
2711
  throw new Error("test host: no dialog answer queued");
@@ -2478,7 +2716,7 @@ export class TestHost {
2478
2716
  "window.state": () => this.#windowFacts(),
2479
2717
  "window.setVideoArea": (p) => {
2480
2718
  if (this.mainWindow.fullscreen)
2481
- throw new HostError("unavailable", "全屏时不可用");
2719
+ throw new HostError("unavailable", this.#hostText("Not available in fullscreen"));
2482
2720
  const width = Math.max(318, Number(p.width));
2483
2721
  const height = Math.max(120, Number(p.height));
2484
2722
  const clamped = width !== p.width || height !== p.height;
@@ -2498,10 +2736,10 @@ export class TestHost {
2498
2736
  if (p.on === pip.active)
2499
2737
  return;
2500
2738
  if (p.on && !pip.supported)
2501
- throw new HostError("unsupported", "此系统不支持画中画");
2739
+ throw new HostError("unsupported", this.#hostText("Picture-in-picture is not supported on this system"));
2502
2740
  const video = this.source.phase !== "empty" && this.source.hasVideo !== false;
2503
2741
  if (p.on && !video)
2504
- throw new HostError("unavailable", "没有视频");
2742
+ throw new HostError("unavailable", this.#hostText("No video"));
2505
2743
  this.setMainWindow({
2506
2744
  // The player leaves fullscreen before the window opens.
2507
2745
  fullscreen: p.on ? false : this.mainWindow.fullscreen,
@@ -2877,7 +3115,8 @@ export class TestHost {
2877
3115
  };
2878
3116
  this.subtitles.tracks.push({
2879
3117
  target,
2880
- title: title ?? (format === "ass" ? "外部 ASS" : "外部 SRT"),
3118
+ title: title ??
3119
+ this.#hostText(format === "ass" ? "External ASS" : "External SRT"),
2881
3120
  language: null,
2882
3121
  styleSupported: true,
2883
3122
  styleReason: null,
@@ -3157,6 +3396,8 @@ export class TestHost {
3157
3396
  */
3158
3397
  export async function startPlugin(activate, options = {}) {
3159
3398
  const host = options.host ?? new TestHost();
3399
+ if (options.locale !== undefined)
3400
+ host.locale = options.locale;
3160
3401
  const directory = options.directory instanceof URL
3161
3402
  ? fileURLToPath(options.directory)
3162
3403
  : (options.directory ?? process.cwd());
@@ -3169,6 +3410,7 @@ export async function startPlugin(activate, options = {}) {
3169
3410
  manifest = { id: read.id, capabilities: read.capabilities };
3170
3411
  }
3171
3412
  host.manifestId = manifest.id;
3413
+ host.loadCatalog(directory);
3172
3414
  const data = options.dataDirectory ?? host.dataDirectory;
3173
3415
  mkdirSync(data, { recursive: true });
3174
3416
  const instance = await createPlugin(host, manifest, directory, data, host.app);