@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.
package/dist/i18n.js ADDED
@@ -0,0 +1,673 @@
1
+ /*
2
+ * Translation catalogs, the same way the player reads them (the player's
3
+ * `src/host/src/i18n/`): gettext `.po` files, the compiled
4
+ * `locales/<lang>.json` a package carries, template formatting and the
5
+ * Slint `@tr` scanner. Used by `vestra-plugin i18n`, `vestra-plugin build`
6
+ * and {@link TestHost}; not part of the plugin API (plugins write no
7
+ * translation code: see docs/how-to/i18n.md).
8
+ * Internal: the player's runner and tests reach it through
9
+ * `@vestra-player/sdk/runtime`, `LOCALES` through `./testing`.
10
+ */
11
+ /**
12
+ * The player's interface languages: English (the source language) first,
13
+ * then the translations, as the player's own table (its tests check that
14
+ * they agree).
15
+ */
16
+ export const LOCALES = ["en", "zh-Hans", "ja", "ko", "de", "fr", "es"];
17
+ /** The catalog key: `msgid`, or `msgctxt` U+0004 `msgid`. */
18
+ export const keyOf = (context, id) => context === undefined ? id : `${context}\u0004${id}`;
19
+ const translated = (entry) => !entry.fuzzy && !entry.obsolete && (entry.strings[0] ?? "") !== "";
20
+ /** Parse a `.po` (or `.pot`) file. Throws with the line number on errors. */
21
+ export function parsePo(text) {
22
+ const entries = [];
23
+ let header = "";
24
+ const blank = () => ({
25
+ id: "",
26
+ strings: [],
27
+ fuzzy: false,
28
+ notes: [],
29
+ references: [],
30
+ flags: [],
31
+ obsolete: false,
32
+ });
33
+ let entry = blank();
34
+ let started = false;
35
+ let field = "none";
36
+ // Comments seen before the entry's first keyword.
37
+ let pending = blank();
38
+ const finish = () => {
39
+ if (started) {
40
+ if (entry.id === "" && entry.context === undefined)
41
+ header = entry.strings[0] ?? "";
42
+ else
43
+ entries.push(entry);
44
+ }
45
+ entry = blank();
46
+ started = false;
47
+ field = "none";
48
+ };
49
+ const lines = text.split(/\r?\n/);
50
+ for (let index = 0; index < lines.length; index++) {
51
+ let line = lines[index].trim();
52
+ const at = (message) => new Error(`line ${index + 1}: ${message}`);
53
+ if (line === "") {
54
+ finish();
55
+ continue;
56
+ }
57
+ let obsolete = false;
58
+ if (line.startsWith("#~")) {
59
+ obsolete = true;
60
+ line = line.slice(2).trim();
61
+ if (line === "")
62
+ continue;
63
+ }
64
+ else if (line.startsWith("#")) {
65
+ if (started)
66
+ finish();
67
+ const body = line.slice(2).trim();
68
+ if (line.startsWith("#."))
69
+ pending.notes.push(body);
70
+ else if (line.startsWith("#:"))
71
+ pending.references.push(...body.split(/\s+/).filter(Boolean));
72
+ else if (line.startsWith("#,"))
73
+ for (const flag of body.split(",").map((f) => f.trim()))
74
+ if (flag === "fuzzy")
75
+ pending.fuzzy = true;
76
+ else if (flag)
77
+ pending.flags.push(flag);
78
+ continue;
79
+ }
80
+ let keyword = "";
81
+ let rest = line;
82
+ if (!line.startsWith('"')) {
83
+ const split = line.search(/\s/);
84
+ if (split < 0)
85
+ throw at("a keyword without a string");
86
+ keyword = line.slice(0, split);
87
+ rest = line.slice(split).trim();
88
+ }
89
+ let next;
90
+ if (keyword === "") {
91
+ if (field === "none")
92
+ throw at("a string without a keyword");
93
+ next = field;
94
+ }
95
+ else if (keyword === "msgctxt")
96
+ next = "context";
97
+ else if (keyword === "msgid")
98
+ next = "id";
99
+ else if (keyword === "msgid_plural")
100
+ next = "plural";
101
+ else if (keyword === "msgstr")
102
+ next = 0;
103
+ else {
104
+ const match = /^msgstr\[(\d+)\]$/.exec(keyword);
105
+ if (!match || Number(match[1]) > 16)
106
+ throw at(`unknown keyword ${keyword}`);
107
+ next = Number(match[1]);
108
+ }
109
+ if (keyword !== "") {
110
+ const opens = next === "context" || (next === "id" && field !== "context");
111
+ if (opens && started)
112
+ finish();
113
+ if (!started) {
114
+ if (!opens)
115
+ throw at("an entry starts with msgctxt or msgid");
116
+ started = true;
117
+ entry = { ...pending, obsolete };
118
+ pending = blank();
119
+ }
120
+ }
121
+ const value = unquote(rest, at);
122
+ if (next === "context")
123
+ entry.context = (entry.context ?? "") + value;
124
+ else if (next === "id")
125
+ entry.id += value;
126
+ else if (next === "plural")
127
+ entry.plural = (entry.plural ?? "") + value;
128
+ else if (typeof next === "number") {
129
+ while (entry.strings.length <= next)
130
+ entry.strings.push("");
131
+ entry.strings[next] += value;
132
+ }
133
+ field = next;
134
+ }
135
+ finish();
136
+ return { header, entries };
137
+ }
138
+ function unquote(text, at) {
139
+ if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"'))
140
+ throw at("expected a quoted string");
141
+ let out = "";
142
+ const inner = text.slice(1, -1);
143
+ for (let i = 0; i < inner.length; i++) {
144
+ const c = inner[i];
145
+ if (c !== "\\") {
146
+ out += c;
147
+ continue;
148
+ }
149
+ const n = inner[++i];
150
+ if (n === "n")
151
+ out += "\n";
152
+ else if (n === "t")
153
+ out += "\t";
154
+ else if (n === "r")
155
+ out += "\r";
156
+ else if (n === '"')
157
+ out += '"';
158
+ else if (n === "\\")
159
+ out += "\\";
160
+ else
161
+ throw at(`unknown escape \\${n ?? ""}`);
162
+ }
163
+ return out;
164
+ }
165
+ const poString = (text) => `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\t", "\\t").replaceAll("\r", "\\r").replaceAll("\n", "\\n")}"`;
166
+ /** A `.po` string field: one line, or `""` then one line per `\n`. */
167
+ function field(keyword, text) {
168
+ const parts = text.split(/(?<=\n)/);
169
+ if (parts.length <= 1)
170
+ return `${keyword} ${poString(text)}`;
171
+ return [`${keyword} ""`, ...parts.map(poString)].join("\n");
172
+ }
173
+ /** Write a `.po` file: the header, then the entries in the given order. */
174
+ export function writePo(header, entries) {
175
+ const blocks = [`msgid ""\n${field("msgstr", header)}`];
176
+ for (const entry of entries) {
177
+ const prefix = entry.obsolete ? "#~ " : "";
178
+ const lines = [];
179
+ for (const note of entry.notes)
180
+ lines.push(`#. ${note}`);
181
+ if (entry.references.length && !entry.obsolete)
182
+ lines.push(`#: ${entry.references.join(" ")}`);
183
+ const flags = [...(entry.fuzzy ? ["fuzzy"] : []), ...entry.flags];
184
+ if (flags.length)
185
+ lines.push(`#, ${flags.join(", ")}`);
186
+ const body = [];
187
+ if (entry.context !== undefined)
188
+ body.push(field("msgctxt", entry.context));
189
+ body.push(field("msgid", entry.id));
190
+ if (entry.plural !== undefined) {
191
+ body.push(field("msgid_plural", entry.plural));
192
+ const strings = entry.strings.length ? entry.strings : ["", ""];
193
+ strings.forEach((text, n) => body.push(field(`msgstr[${n}]`, text)));
194
+ }
195
+ else
196
+ body.push(field("msgstr", entry.strings[0] ?? ""));
197
+ for (const text of body)
198
+ for (const line of text.split("\n"))
199
+ lines.push(prefix + line);
200
+ blocks.push(lines.join("\n"));
201
+ }
202
+ return `${blocks.join("\n\n")}\n`;
203
+ }
204
+ /**
205
+ * The compiled catalog of one language (`locales/<lang>.json`): translated
206
+ * entries only (not fuzzy, not obsolete, not empty), keys sorted. Plurals
207
+ * have two forms, as the player shows them: `msgstr[0]` for `n == 1` and
208
+ * `msgstr[1]` (else `msgstr[0]`) for every other `n`.
209
+ */
210
+ export function compileCatalog(language, entries) {
211
+ const messages = {};
212
+ for (const entry of [...entries].sort((a, b) => keyOf(a.context, a.id) < keyOf(b.context, b.id) ? -1 : 1)) {
213
+ if (!translated(entry))
214
+ continue;
215
+ const key = keyOf(entry.context, entry.id);
216
+ if (key in messages)
217
+ continue;
218
+ const one = entry.strings[0];
219
+ messages[key] =
220
+ entry.plural === undefined ? one : [one, entry.strings[1] || one];
221
+ }
222
+ return { language, messages };
223
+ }
224
+ /** Lookups in one language's messages, as the player does them. */
225
+ export class Catalog {
226
+ messages;
227
+ constructor(messages = {}) {
228
+ this.messages = new Map(Object.entries(messages));
229
+ }
230
+ /** From parsed `.po` entries. */
231
+ static fromEntries(entries) {
232
+ return new Catalog(compileCatalog("", entries).messages);
233
+ }
234
+ /** The translation of `id` (the one form of a plural), or `undefined`. */
235
+ lookup(id, context) {
236
+ const message = this.messages.get(keyOf(context, id));
237
+ return Array.isArray(message) ? message[0] : message;
238
+ }
239
+ /** The translation of `id`, or `id` itself. */
240
+ text(id) {
241
+ return this.lookup(id) ?? id;
242
+ }
243
+ /**
244
+ * A plugin sentence (`osd.show(template, args)`, dialogs): translated,
245
+ * then formatted with `args` when given; without `args` the text shows
246
+ * as it is, braces included.
247
+ */
248
+ template(text, args) {
249
+ const template = this.text(text);
250
+ return args === undefined || args === null
251
+ ? template
252
+ : format(template, args);
253
+ }
254
+ /** What an `@tr` call becomes, `undefined` when untranslated. */
255
+ slint(call) {
256
+ const message = this.messages.get(keyOf(call.context, call.id));
257
+ if (message === undefined)
258
+ return undefined;
259
+ if (Array.isArray(message) !== (call.plural !== undefined))
260
+ return undefined;
261
+ return Array.isArray(message)
262
+ ? [message[0], message[1]]
263
+ : [message, undefined];
264
+ }
265
+ }
266
+ const argument = (value) => typeof value === "number"
267
+ ? Number.isInteger(value)
268
+ ? value.toFixed(0)
269
+ : String(value)
270
+ : (value ?? "");
271
+ /**
272
+ * Format a template the way Slint's `@tr` and the player do: `{}` takes
273
+ * the next argument, `{0}` the first, `{name}` a field of an object; `{{`
274
+ * and `}}` are braces. A placeholder without an argument stays as written.
275
+ */
276
+ export function format(template, args) {
277
+ let out = "";
278
+ let next = 0;
279
+ let rest = template;
280
+ for (;;) {
281
+ const at = rest.search(/[{}]/);
282
+ if (at < 0)
283
+ break;
284
+ out += rest.slice(0, at);
285
+ const tail = rest.slice(at);
286
+ if (tail.startsWith("{{") || tail.startsWith("}}")) {
287
+ out += tail[0];
288
+ rest = tail.slice(2);
289
+ continue;
290
+ }
291
+ if (tail.startsWith("}")) {
292
+ out += "}";
293
+ rest = tail.slice(1);
294
+ continue;
295
+ }
296
+ const close = tail.indexOf("}");
297
+ if (close < 0) {
298
+ out += tail;
299
+ rest = "";
300
+ break;
301
+ }
302
+ const name = tail.slice(1, close);
303
+ let value;
304
+ const list = Array.isArray(args)
305
+ ? args
306
+ : null;
307
+ if (name === "")
308
+ value = list?.[next++];
309
+ else if (/^\d+$/.test(name))
310
+ value = list?.[Number(name)];
311
+ else if (args && !list)
312
+ value = args[name];
313
+ out += value === undefined ? tail.slice(0, close + 1) : argument(value);
314
+ rest = tail.slice(close + 1);
315
+ }
316
+ return out + rest;
317
+ }
318
+ /**
319
+ * The placeholders of a template, sorted: `""` per `{}`, else the name or
320
+ * number (`{n}` of a Slint plural is `"n"`). Translations must keep them.
321
+ */
322
+ export function placeholders(text) {
323
+ const found = [];
324
+ const plain = text.replaceAll("{{", "").replaceAll("}}", "");
325
+ for (const match of plain.matchAll(/\{([^{}]*)\}/g))
326
+ found.push(match[1]);
327
+ return found.sort();
328
+ }
329
+ const lineEnd = (s, i) => {
330
+ const found = s.slice(i).search(/[\n\r]/);
331
+ return found < 0 ? s.length : i + found;
332
+ };
333
+ function commentEnd(s, from) {
334
+ let depth = 0;
335
+ let i = from + 2;
336
+ while (i + 1 < s.length) {
337
+ if (s[i] === "/" && s[i + 1] === "*") {
338
+ depth++;
339
+ i += 2;
340
+ }
341
+ else if (s[i] === "*" && s[i + 1] === "/") {
342
+ if (depth === 0)
343
+ return i + 2;
344
+ depth--;
345
+ i += 2;
346
+ }
347
+ else
348
+ i++;
349
+ }
350
+ return s.length;
351
+ }
352
+ function stringEnd(s, from, templates) {
353
+ let i = from;
354
+ while (i < s.length) {
355
+ if (s[i] === '"')
356
+ return i + 1;
357
+ if (s[i] === "\\" && s[i + 1] === "{") {
358
+ templates.push(0);
359
+ return i + 2;
360
+ }
361
+ i += s[i] === "\\" ? 2 : 1;
362
+ }
363
+ return s.length;
364
+ }
365
+ function skip(s, i) {
366
+ for (;;) {
367
+ if (/\s/.test(s[i] ?? ""))
368
+ i++;
369
+ else if (s[i] === "/" && s[i + 1] === "/")
370
+ i = lineEnd(s, i);
371
+ else if (s[i] === "/" && s[i + 1] === "*")
372
+ i = commentEnd(s, i);
373
+ else
374
+ return i;
375
+ }
376
+ }
377
+ /** A plain string literal at `i`: its span and value (no interpolation). */
378
+ function literal(s, i) {
379
+ if (s[i] !== '"')
380
+ return null;
381
+ let j = i + 1;
382
+ let value = "";
383
+ for (;;) {
384
+ const c = s[j];
385
+ if (c === undefined)
386
+ return null;
387
+ if (c === '"')
388
+ return [[i, j + 1], value];
389
+ if (c !== "\\") {
390
+ value += c;
391
+ j++;
392
+ continue;
393
+ }
394
+ const n = s[j + 1];
395
+ if (n === '"' || n === "\\")
396
+ value += n;
397
+ else if (n === "n")
398
+ value += "\n";
399
+ else if (n === "u") {
400
+ if (s[j + 2] !== "{")
401
+ return null;
402
+ const close = s.indexOf("}", j + 3);
403
+ if (close < 0)
404
+ return null;
405
+ const code = Number.parseInt(s.slice(j + 3, close), 16);
406
+ if (!/^[0-9a-fA-F]+$/.test(s.slice(j + 3, close)) || code > 0x10ffff)
407
+ return null;
408
+ value += String.fromCodePoint(code);
409
+ j = close + 1;
410
+ continue;
411
+ }
412
+ else
413
+ return null;
414
+ j += 2;
415
+ }
416
+ }
417
+ function callAt(s, at) {
418
+ let i = skip(s, at + 1);
419
+ if (!s.startsWith("tr", i))
420
+ return null;
421
+ i += 2;
422
+ if (/[A-Za-z0-9_-]/.test(s[i] ?? ""))
423
+ return null;
424
+ i = skip(s, i);
425
+ if (s[i] !== "(")
426
+ return null;
427
+ let found = literal(s, skip(s, i + 1));
428
+ if (!found)
429
+ return null;
430
+ let [span, text] = found;
431
+ let context;
432
+ let end = skip(s, span[1]);
433
+ if (s.startsWith("=>", end)) {
434
+ context = text;
435
+ found = literal(s, skip(s, end + 2));
436
+ if (!found)
437
+ return null;
438
+ [span, text] = found;
439
+ end = skip(s, span[1]);
440
+ }
441
+ let resume = span[1];
442
+ const call = {
443
+ id: text,
444
+ line: s.slice(0, at).split("\n").length,
445
+ idSpan: span,
446
+ };
447
+ if (context !== undefined)
448
+ call.context = context;
449
+ if (s[end] === "|") {
450
+ const other = literal(s, skip(s, end + 1));
451
+ if (!other)
452
+ return null;
453
+ call.plural = other[1];
454
+ call.pluralSpan = other[0];
455
+ resume = other[0][1];
456
+ }
457
+ return [call, resume];
458
+ }
459
+ /**
460
+ * Every `@tr` call of a Slint source, in order; the same scanner as the
461
+ * player's (both run the shared cases of the player's
462
+ * `src/host/tests/fixtures/slint-tr.json`). Comments, strings and string
463
+ * interpolation are skipped; a malformed call is left out.
464
+ */
465
+ export function scanSlint(source) {
466
+ const calls = [];
467
+ const templates = [];
468
+ let i = 0;
469
+ while (i < source.length) {
470
+ const c = source[i];
471
+ if (c === "/" && source[i + 1] === "/")
472
+ i = lineEnd(source, i);
473
+ else if (c === "/" && source[i + 1] === "*")
474
+ i = commentEnd(source, i);
475
+ else if (c === '"')
476
+ i = stringEnd(source, i + 1, templates);
477
+ else if (c === "{" && templates.length) {
478
+ templates[templates.length - 1]++;
479
+ i++;
480
+ }
481
+ else if (c === "}" && templates.length) {
482
+ if (templates[templates.length - 1] === 0) {
483
+ templates.pop();
484
+ i = stringEnd(source, i + 1, templates);
485
+ }
486
+ else {
487
+ templates[templates.length - 1]--;
488
+ i++;
489
+ }
490
+ }
491
+ else if (c === "@") {
492
+ const found = callAt(source, i);
493
+ if (found) {
494
+ calls.push(found[0]);
495
+ i = found[1];
496
+ }
497
+ else
498
+ i++;
499
+ }
500
+ else
501
+ i++;
502
+ }
503
+ return calls;
504
+ }
505
+ /** A Slint string literal for `text`. */
506
+ export function slintQuote(text) {
507
+ let out = '"';
508
+ for (const c of text) {
509
+ if (c === '"')
510
+ out += '\\"';
511
+ else if (c === "\\")
512
+ out += "\\\\";
513
+ else if (c === "\n")
514
+ out += "\\n";
515
+ else if (/\p{Cc}/u.test(c))
516
+ out += `\\u{${c.codePointAt(0).toString(16)}}`;
517
+ else
518
+ out += c;
519
+ }
520
+ return `${out}"`;
521
+ }
522
+ /** `source` with its `@tr` literals translated (what the player compiles). */
523
+ export function rewriteSlint(source, catalog) {
524
+ let out = "";
525
+ let copied = 0;
526
+ for (const call of scanSlint(source)) {
527
+ const translation = catalog.slint(call);
528
+ if (!translation)
529
+ continue;
530
+ out += source.slice(copied, call.idSpan[0]) + slintQuote(translation[0]);
531
+ copied = call.idSpan[1];
532
+ if (call.pluralSpan && translation[1] !== undefined) {
533
+ out +=
534
+ source.slice(copied, call.pluralSpan[0]) + slintQuote(translation[1]);
535
+ copied = call.pluralSpan[1];
536
+ }
537
+ }
538
+ return out + source.slice(copied);
539
+ }
540
+ /**
541
+ * The supported locale for a BCP 47 tag, as the player maps the system's
542
+ * languages: Simplified Chinese is `zh-Hans`; Traditional Chinese (`zh-Hant`,
543
+ * or Taiwan, Hong Kong, Macao) is English; other languages by their primary
544
+ * subtag; `undefined` for a language the player does not have.
545
+ */
546
+ export function matchLocale(tag) {
547
+ const parts = tag
548
+ .replaceAll("_", "-")
549
+ .toLowerCase()
550
+ .split(/[.@]/)[0]
551
+ .split("-");
552
+ const [language, ...rest] = parts;
553
+ if (language === "zh") {
554
+ const script = rest.find((part) => part.length === 4);
555
+ if (script)
556
+ return script === "hans" ? "zh-Hans" : "en";
557
+ return rest.some((r) => ["tw", "hk", "mo"].includes(r)) ? "en" : "zh-Hans";
558
+ }
559
+ return LOCALES.find((locale) => !locale.includes("-") && locale === language);
560
+ }
561
+ // ---------------------------------------------------------------- menus
562
+ /**
563
+ * The player's built-in top-level menus: id → key (the English text paths
564
+ * merge by), as the player's `MENU_ANCHORS`.
565
+ */
566
+ export const MENU_KEYS = {
567
+ open: "Open",
568
+ playback: "Playback",
569
+ subtitles: "Subtitles",
570
+ video: "Video",
571
+ audio: "Audio",
572
+ screen: "Screen",
573
+ settings: "Settings",
574
+ };
575
+ /** The player's old Chinese titles of its own menus: parent, title, key. */
576
+ const LEGACY_MENUS = [
577
+ [[], "打开", "Open"],
578
+ [[], "播放", "Playback"],
579
+ [[], "字幕", "Subtitles"],
580
+ [[], "视频", "Video"],
581
+ [[], "声音", "Audio"],
582
+ [[], "屏幕", "Screen"],
583
+ [[], "设置", "Settings"],
584
+ [["Playback"], "速度", "Speed"],
585
+ [["Playback"], "跳转", "Seek"],
586
+ // Shared with a migrated built-in plugin (until all are migrated).
587
+ [["Settings"], "外观", "Appearance"],
588
+ ];
589
+ /** The player's own submenus, by key path (their labels are the player's). */
590
+ export const HOST_GROUPS = [
591
+ "Open",
592
+ "Playback",
593
+ "Subtitles",
594
+ "Video",
595
+ "Audio",
596
+ "Screen",
597
+ "Settings",
598
+ "Playback/Speed",
599
+ "Playback/Seek",
600
+ ];
601
+ /**
602
+ * The keys a path merges by, as the player normalizes it: `@id` becomes the
603
+ * built-in menu's key, the player's old Chinese titles of its own submenus
604
+ * their keys.
605
+ */
606
+ export function menuKeys(path) {
607
+ const keys = [...path];
608
+ const id = keys[0]?.startsWith("@") ? keys[0].slice(1) : null;
609
+ if (id !== null && MENU_KEYS[id])
610
+ keys[0] = MENU_KEYS[id];
611
+ for (const [parent, old, key] of LEGACY_MENUS) {
612
+ const depth = parent.length;
613
+ if (keys.length > depth + 1 &&
614
+ parent.every((segment, i) => keys[i] === segment) &&
615
+ keys[depth] === old)
616
+ keys[depth] = key;
617
+ }
618
+ return keys;
619
+ }
620
+ /**
621
+ * The player's own texts that the test host repeats (core actions, its
622
+ * refusals, its menus), English → the player's Simplified Chinese
623
+ * (`src/host/locales/zh-Hans.po` in the player; its tests check that they
624
+ * agree).
625
+ */
626
+ export const HOST_ZH_HANS = {
627
+ Open: "打开",
628
+ Playback: "播放",
629
+ Subtitles: "字幕",
630
+ Video: "视频",
631
+ Audio: "声音",
632
+ Screen: "屏幕",
633
+ Settings: "设置",
634
+ Speed: "速度",
635
+ Seek: "跳转",
636
+ Close: "关闭",
637
+ Cancel: "取消",
638
+ "Play/Pause": "播放/暂停",
639
+ Previous: "上一个",
640
+ Next: "下一个",
641
+ "Previous Frame": "上一帧",
642
+ "Next Frame": "下一帧",
643
+ "Back {} s": "后退{}秒",
644
+ "Forward {} s": "前进{}秒",
645
+ Normal: "正常",
646
+ "Slower 0.1": "减速 0.1",
647
+ "Faster 0.1": "加速 0.1",
648
+ "Volume +": "音量 +",
649
+ "Volume −": "音量 −",
650
+ Mute: "静音",
651
+ "Not supported by the current source": "当前来源不支持此操作",
652
+ "The current source cannot step back a frame": "当前来源不支持逐帧后退",
653
+ "No video": "没有视频",
654
+ "No previous item": "没有上一个",
655
+ "No next item": "没有下一个",
656
+ Paused: "已暂停",
657
+ Playing: "播放",
658
+ "Back {} s: {} / {}": "后退 {}秒: {} / {}",
659
+ "Forward {} s: {} / {}": "前进 {}秒: {} / {}",
660
+ "Speed: {}x": "播放速度: {}x",
661
+ "Volume: {}%": "音量: {}%",
662
+ "Mute: On": "静音: 开",
663
+ "Mute: Off": "静音: 关",
664
+ "bottom bar": "底栏",
665
+ sidebar: "侧栏",
666
+ "video backdrop": "视频背景",
667
+ "The {} is used by {}": "{}已被 {} 使用",
668
+ "The sidebar holds at most {} tabs": "侧栏最多 {} 个页签",
669
+ "{} is resolved by {}": "{} 已由 {} 解析",
670
+ "File type .{}": "文件类型 .{}",
671
+ "Not available in fullscreen": "全屏时不可用",
672
+ "Picture-in-picture is not supported on this system": "此系统不支持画中画",
673
+ };
@@ -13,5 +13,6 @@ export { HostError } from "./wire.ts";
13
13
  export type { Json, Transport } from "./wire.ts";
14
14
  export { API_VERSION, CAPABILITIES } from "./capabilities.ts";
15
15
  export { COMMAND_LIMITS, MENU_IDS } from "./commands.ts";
16
+ export { Catalog, HOST_ZH_HANS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, scanSlint, } from "./i18n.ts";
16
17
  export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.ts";
17
18
  export { MARKDOWN_LIMITS } from "./plugin/markdown.ts";
@@ -11,5 +11,6 @@ export { createPlugin } from "./plugin/index.js";
11
11
  export { HostError } from "./wire.js";
12
12
  export { API_VERSION, CAPABILITIES } from "./capabilities.js";
13
13
  export { COMMAND_LIMITS, MENU_IDS } from "./commands.js";
14
+ export { Catalog, HOST_ZH_HANS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, scanSlint, } from "./i18n.js";
14
15
  export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.js";
15
16
  export { MARKDOWN_LIMITS } from "./plugin/markdown.js";