@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.
- package/CHANGELOG.md +39 -0
- package/README.md +4 -1
- package/bin/vestra-plugin.mjs +170 -7
- package/dist/commands.d.ts +7 -4
- package/dist/i18n-tools.d.ts +67 -0
- package/dist/i18n-tools.js +787 -0
- package/dist/i18n.d.ts +222 -0
- package/dist/i18n.js +972 -0
- package/dist/player-runtime.d.ts +2 -0
- package/dist/player-runtime.js +2 -0
- package/dist/plugin/appearance.js +27 -1
- package/dist/plugin/bindings.js +1 -0
- package/dist/plugin/commands.js +24 -4
- package/dist/plugin/index.js +9 -1
- package/dist/plugin/language.d.ts +8 -0
- package/dist/plugin/language.js +79 -0
- package/dist/plugin/osd.d.ts +11 -0
- package/dist/plugin/osd.js +64 -9
- package/dist/plugin/packages.js +10 -0
- package/dist/plugin/runtime.d.ts +3 -1
- package/dist/plugin/runtime.js +6 -0
- package/dist/plugin/scope.d.ts +1 -0
- package/dist/plugin/scope.js +2 -0
- package/dist/plugin/subtitles.js +9 -1
- package/dist/plugin/testing.d.ts +184 -5
- package/dist/plugin/testing.js +459 -67
- package/dist/plugin/timeline.js +3 -1
- package/dist/plugin/types.d.ts +523 -54
- package/dist/plugin/ui.js +29 -10
- package/dist/plugin/values.d.ts +55 -0
- package/dist/plugin/values.js +117 -0
- package/dist/plugin/window.d.ts +2 -0
- package/dist/plugin/window.js +4 -0
- package/dist/source-resolvers.d.ts +0 -1
- package/dist/source-resolvers.js +9 -1
- package/dist/subtitles.d.ts +3 -1
- package/dist/wire.d.ts +38 -6
- package/dist/wire.js +13 -2
- package/llms-full.txt +646 -58
- package/llms.txt +44 -40
- package/package.json +1 -1
- package/slint/vestra/markdown.slint +5 -5
- package/slint/vestra/widgets.slint +4 -4
package/dist/i18n.js
ADDED
|
@@ -0,0 +1,972 @@
|
|
|
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
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
/**
|
|
14
|
+
* The player's interface languages: English (the source language) first,
|
|
15
|
+
* then the translations, as the player's own table (its tests check that
|
|
16
|
+
* they agree).
|
|
17
|
+
*/
|
|
18
|
+
export const LOCALES = ["en", "zh-Hans", "ja", "ko", "de", "fr", "es"];
|
|
19
|
+
/** The catalog key: `msgid`, or `msgctxt` U+0004 `msgid`. */
|
|
20
|
+
export const keyOf = (context, id) => context === undefined ? id : `${context}\u0004${id}`;
|
|
21
|
+
const translated = (entry) => !entry.fuzzy && !entry.obsolete && (entry.strings[0] ?? "") !== "";
|
|
22
|
+
/** Parse a `.po` (or `.pot`) file. Throws with the line number on errors. */
|
|
23
|
+
export function parsePo(text) {
|
|
24
|
+
const entries = [];
|
|
25
|
+
let header = "";
|
|
26
|
+
const blank = () => ({
|
|
27
|
+
id: "",
|
|
28
|
+
strings: [],
|
|
29
|
+
fuzzy: false,
|
|
30
|
+
notes: [],
|
|
31
|
+
references: [],
|
|
32
|
+
flags: [],
|
|
33
|
+
obsolete: false,
|
|
34
|
+
});
|
|
35
|
+
let entry = blank();
|
|
36
|
+
let started = false;
|
|
37
|
+
let field = "none";
|
|
38
|
+
// Comments seen before the entry's first keyword.
|
|
39
|
+
let pending = blank();
|
|
40
|
+
const finish = () => {
|
|
41
|
+
if (started) {
|
|
42
|
+
if (entry.id === "" && entry.context === undefined)
|
|
43
|
+
header = entry.strings[0] ?? "";
|
|
44
|
+
else
|
|
45
|
+
entries.push(entry);
|
|
46
|
+
}
|
|
47
|
+
entry = blank();
|
|
48
|
+
started = false;
|
|
49
|
+
field = "none";
|
|
50
|
+
};
|
|
51
|
+
const lines = text.split(/\r?\n/);
|
|
52
|
+
for (let index = 0; index < lines.length; index++) {
|
|
53
|
+
let line = lines[index].trim();
|
|
54
|
+
const at = (message) => new Error(`line ${index + 1}: ${message}`);
|
|
55
|
+
if (line === "") {
|
|
56
|
+
finish();
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
let obsolete = false;
|
|
60
|
+
if (line.startsWith("#~")) {
|
|
61
|
+
obsolete = true;
|
|
62
|
+
line = line.slice(2).trim();
|
|
63
|
+
if (line === "")
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
else if (line.startsWith("#")) {
|
|
67
|
+
if (started)
|
|
68
|
+
finish();
|
|
69
|
+
const body = line.slice(2).trim();
|
|
70
|
+
if (line.startsWith("#."))
|
|
71
|
+
pending.notes.push(body);
|
|
72
|
+
else if (line.startsWith("#:"))
|
|
73
|
+
pending.references.push(...body.split(/\s+/).filter(Boolean));
|
|
74
|
+
else if (line.startsWith("#,"))
|
|
75
|
+
for (const flag of body.split(",").map((f) => f.trim()))
|
|
76
|
+
if (flag === "fuzzy")
|
|
77
|
+
pending.fuzzy = true;
|
|
78
|
+
else if (flag)
|
|
79
|
+
pending.flags.push(flag);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
let keyword = "";
|
|
83
|
+
let rest = line;
|
|
84
|
+
if (!line.startsWith('"')) {
|
|
85
|
+
const split = line.search(/\s/);
|
|
86
|
+
if (split < 0)
|
|
87
|
+
throw at("a keyword without a string");
|
|
88
|
+
keyword = line.slice(0, split);
|
|
89
|
+
rest = line.slice(split).trim();
|
|
90
|
+
}
|
|
91
|
+
let next;
|
|
92
|
+
if (keyword === "") {
|
|
93
|
+
if (field === "none")
|
|
94
|
+
throw at("a string without a keyword");
|
|
95
|
+
next = field;
|
|
96
|
+
}
|
|
97
|
+
else if (keyword === "msgctxt")
|
|
98
|
+
next = "context";
|
|
99
|
+
else if (keyword === "msgid")
|
|
100
|
+
next = "id";
|
|
101
|
+
else if (keyword === "msgid_plural")
|
|
102
|
+
next = "plural";
|
|
103
|
+
else if (keyword === "msgstr")
|
|
104
|
+
next = 0;
|
|
105
|
+
else {
|
|
106
|
+
const match = /^msgstr\[(\d+)\]$/.exec(keyword);
|
|
107
|
+
if (!match || Number(match[1]) > 16)
|
|
108
|
+
throw at(`unknown keyword ${keyword}`);
|
|
109
|
+
next = Number(match[1]);
|
|
110
|
+
}
|
|
111
|
+
if (keyword !== "") {
|
|
112
|
+
const opens = next === "context" || (next === "id" && field !== "context");
|
|
113
|
+
if (opens && started)
|
|
114
|
+
finish();
|
|
115
|
+
if (!started) {
|
|
116
|
+
if (!opens)
|
|
117
|
+
throw at("an entry starts with msgctxt or msgid");
|
|
118
|
+
started = true;
|
|
119
|
+
entry = { ...pending, obsolete };
|
|
120
|
+
pending = blank();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const value = unquote(rest, at);
|
|
124
|
+
if (next === "context")
|
|
125
|
+
entry.context = (entry.context ?? "") + value;
|
|
126
|
+
else if (next === "id")
|
|
127
|
+
entry.id += value;
|
|
128
|
+
else if (next === "plural")
|
|
129
|
+
entry.plural = (entry.plural ?? "") + value;
|
|
130
|
+
else if (typeof next === "number") {
|
|
131
|
+
while (entry.strings.length <= next)
|
|
132
|
+
entry.strings.push("");
|
|
133
|
+
entry.strings[next] += value;
|
|
134
|
+
}
|
|
135
|
+
field = next;
|
|
136
|
+
}
|
|
137
|
+
finish();
|
|
138
|
+
return { header, entries };
|
|
139
|
+
}
|
|
140
|
+
function unquote(text, at) {
|
|
141
|
+
if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"'))
|
|
142
|
+
throw at("expected a quoted string");
|
|
143
|
+
let out = "";
|
|
144
|
+
const inner = text.slice(1, -1);
|
|
145
|
+
for (let i = 0; i < inner.length; i++) {
|
|
146
|
+
const c = inner[i];
|
|
147
|
+
if (c !== "\\") {
|
|
148
|
+
out += c;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const n = inner[++i];
|
|
152
|
+
if (n === "n")
|
|
153
|
+
out += "\n";
|
|
154
|
+
else if (n === "t")
|
|
155
|
+
out += "\t";
|
|
156
|
+
else if (n === "r")
|
|
157
|
+
out += "\r";
|
|
158
|
+
else if (n === '"')
|
|
159
|
+
out += '"';
|
|
160
|
+
else if (n === "\\")
|
|
161
|
+
out += "\\";
|
|
162
|
+
else
|
|
163
|
+
throw at(`unknown escape \\${n ?? ""}`);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
const poString = (text) => `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\t", "\\t").replaceAll("\r", "\\r").replaceAll("\n", "\\n")}"`;
|
|
168
|
+
/** A `.po` string field: one line, or `""` then one line per `\n`. */
|
|
169
|
+
function field(keyword, text) {
|
|
170
|
+
const parts = text.split(/(?<=\n)/);
|
|
171
|
+
if (parts.length <= 1)
|
|
172
|
+
return `${keyword} ${poString(text)}`;
|
|
173
|
+
return [`${keyword} ""`, ...parts.map(poString)].join("\n");
|
|
174
|
+
}
|
|
175
|
+
/** Write a `.po` file: the header, then the entries in the given order. */
|
|
176
|
+
export function writePo(header, entries) {
|
|
177
|
+
const blocks = [`msgid ""\n${field("msgstr", header)}`];
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
const prefix = entry.obsolete ? "#~ " : "";
|
|
180
|
+
const lines = [];
|
|
181
|
+
for (const note of entry.notes)
|
|
182
|
+
lines.push(`#. ${note}`);
|
|
183
|
+
if (entry.references.length && !entry.obsolete)
|
|
184
|
+
lines.push(`#: ${entry.references.join(" ")}`);
|
|
185
|
+
const flags = [...(entry.fuzzy ? ["fuzzy"] : []), ...entry.flags];
|
|
186
|
+
if (flags.length)
|
|
187
|
+
lines.push(`#, ${flags.join(", ")}`);
|
|
188
|
+
const body = [];
|
|
189
|
+
if (entry.context !== undefined)
|
|
190
|
+
body.push(field("msgctxt", entry.context));
|
|
191
|
+
body.push(field("msgid", entry.id));
|
|
192
|
+
if (entry.plural !== undefined) {
|
|
193
|
+
body.push(field("msgid_plural", entry.plural));
|
|
194
|
+
const strings = entry.strings.length ? entry.strings : ["", ""];
|
|
195
|
+
strings.forEach((text, n) => body.push(field(`msgstr[${n}]`, text)));
|
|
196
|
+
}
|
|
197
|
+
else
|
|
198
|
+
body.push(field("msgstr", entry.strings[0] ?? ""));
|
|
199
|
+
for (const text of body)
|
|
200
|
+
for (const line of text.split("\n"))
|
|
201
|
+
lines.push(prefix + line);
|
|
202
|
+
blocks.push(lines.join("\n"));
|
|
203
|
+
}
|
|
204
|
+
return `${blocks.join("\n\n")}\n`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The compiled catalog of one language (`locales/<lang>.json`): translated
|
|
208
|
+
* entries only (not fuzzy, not obsolete, not empty), keys sorted. Plurals
|
|
209
|
+
* have two forms, as the player shows them: `msgstr[0]` for `n == 1` and
|
|
210
|
+
* `msgstr[1]` (else `msgstr[0]`) for every other `n`.
|
|
211
|
+
*/
|
|
212
|
+
export function compileCatalog(language, entries) {
|
|
213
|
+
const messages = {};
|
|
214
|
+
for (const entry of [...entries].sort((a, b) => keyOf(a.context, a.id) < keyOf(b.context, b.id) ? -1 : 1)) {
|
|
215
|
+
if (!translated(entry))
|
|
216
|
+
continue;
|
|
217
|
+
const key = keyOf(entry.context, entry.id);
|
|
218
|
+
if (key in messages)
|
|
219
|
+
continue;
|
|
220
|
+
const one = entry.strings[0];
|
|
221
|
+
messages[key] =
|
|
222
|
+
entry.plural === undefined ? one : [one, entry.strings[1] || one];
|
|
223
|
+
}
|
|
224
|
+
return { language, messages };
|
|
225
|
+
}
|
|
226
|
+
/** Lookups in one language's messages, as the player does them. */
|
|
227
|
+
export class Catalog {
|
|
228
|
+
messages;
|
|
229
|
+
constructor(messages = {}) {
|
|
230
|
+
this.messages = new Map(Object.entries(messages));
|
|
231
|
+
}
|
|
232
|
+
/** From parsed `.po` entries. */
|
|
233
|
+
static fromEntries(entries) {
|
|
234
|
+
return new Catalog(compileCatalog("", entries).messages);
|
|
235
|
+
}
|
|
236
|
+
/** The translation of `id` (the one form of a plural), or `undefined`. */
|
|
237
|
+
lookup(id, context) {
|
|
238
|
+
const message = this.messages.get(keyOf(context, id));
|
|
239
|
+
return Array.isArray(message) ? message[0] : message;
|
|
240
|
+
}
|
|
241
|
+
/** The translation of `id`, or `id` itself. */
|
|
242
|
+
text(id) {
|
|
243
|
+
return this.lookup(id) ?? id;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* A plugin sentence (`osd.show(template, args)`, dialogs): translated,
|
|
247
|
+
* then formatted with `args` when given; without `args` the text shows
|
|
248
|
+
* as it is, braces included.
|
|
249
|
+
*/
|
|
250
|
+
template(text, args) {
|
|
251
|
+
const template = this.text(text);
|
|
252
|
+
return args === undefined || args === null
|
|
253
|
+
? template
|
|
254
|
+
: format(template, args);
|
|
255
|
+
}
|
|
256
|
+
/** What an `@tr` call becomes, `undefined` when untranslated. */
|
|
257
|
+
slint(call) {
|
|
258
|
+
const message = this.messages.get(keyOf(call.context, call.id));
|
|
259
|
+
if (message === undefined)
|
|
260
|
+
return undefined;
|
|
261
|
+
if (Array.isArray(message) !== (call.plural !== undefined))
|
|
262
|
+
return undefined;
|
|
263
|
+
return Array.isArray(message)
|
|
264
|
+
? [message[0], message[1]]
|
|
265
|
+
: [message, undefined];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const argument = (value) => typeof value === "number"
|
|
269
|
+
? Number.isInteger(value)
|
|
270
|
+
? value.toFixed(0)
|
|
271
|
+
: String(value)
|
|
272
|
+
: (value ?? "");
|
|
273
|
+
/**
|
|
274
|
+
* Format a template the way Slint's `@tr` and the player do: `{}` takes
|
|
275
|
+
* the next argument, `{0}` the first, `{name}` a field of an object; `{{`
|
|
276
|
+
* and `}}` are braces. A placeholder without an argument stays as written.
|
|
277
|
+
*/
|
|
278
|
+
export function format(template, args) {
|
|
279
|
+
let out = "";
|
|
280
|
+
let next = 0;
|
|
281
|
+
let rest = template;
|
|
282
|
+
for (;;) {
|
|
283
|
+
const at = rest.search(/[{}]/);
|
|
284
|
+
if (at < 0)
|
|
285
|
+
break;
|
|
286
|
+
out += rest.slice(0, at);
|
|
287
|
+
const tail = rest.slice(at);
|
|
288
|
+
if (tail.startsWith("{{") || tail.startsWith("}}")) {
|
|
289
|
+
out += tail[0];
|
|
290
|
+
rest = tail.slice(2);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (tail.startsWith("}")) {
|
|
294
|
+
out += "}";
|
|
295
|
+
rest = tail.slice(1);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const close = tail.indexOf("}");
|
|
299
|
+
if (close < 0) {
|
|
300
|
+
out += tail;
|
|
301
|
+
rest = "";
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
const name = tail.slice(1, close);
|
|
305
|
+
let value;
|
|
306
|
+
const list = Array.isArray(args)
|
|
307
|
+
? args
|
|
308
|
+
: null;
|
|
309
|
+
if (name === "")
|
|
310
|
+
value = list?.[next++];
|
|
311
|
+
else if (/^\d+$/.test(name))
|
|
312
|
+
value = list?.[Number(name)];
|
|
313
|
+
else if (args && !list)
|
|
314
|
+
value = args[name];
|
|
315
|
+
out += value === undefined ? tail.slice(0, close + 1) : argument(value);
|
|
316
|
+
rest = tail.slice(close + 1);
|
|
317
|
+
}
|
|
318
|
+
return out + rest;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* The placeholders of a template, sorted: `""` per `{}`, else the name or
|
|
322
|
+
* number (`{n}` of a Slint plural is `"n"`). Translations must keep them.
|
|
323
|
+
*/
|
|
324
|
+
export function placeholders(text) {
|
|
325
|
+
const found = [];
|
|
326
|
+
const plain = text.replaceAll("{{", "").replaceAll("}}", "");
|
|
327
|
+
for (const match of plain.matchAll(/\{([^{}]*)\}/g))
|
|
328
|
+
found.push(match[1]);
|
|
329
|
+
return found.sort();
|
|
330
|
+
}
|
|
331
|
+
// ---------------------------------------------------------------- texts
|
|
332
|
+
/**
|
|
333
|
+
* The ways a `TextList` is joined (`ListStyle`), as the player
|
|
334
|
+
* joins them in each interface language (`LIST_SEPARATORS`).
|
|
335
|
+
*/
|
|
336
|
+
export const LIST_STYLES = [
|
|
337
|
+
"list",
|
|
338
|
+
"clauses",
|
|
339
|
+
"semicolons",
|
|
340
|
+
"sentences",
|
|
341
|
+
"lines",
|
|
342
|
+
];
|
|
343
|
+
/**
|
|
344
|
+
* The separator of each list style per interface language, as the player's
|
|
345
|
+
* table (`i18n::text::SEPARATORS`; its tests check that they agree).
|
|
346
|
+
*/
|
|
347
|
+
export const LIST_SEPARATORS = {
|
|
348
|
+
en: {
|
|
349
|
+
list: ", ",
|
|
350
|
+
clauses: ", ",
|
|
351
|
+
semicolons: "; ",
|
|
352
|
+
sentences: ". ",
|
|
353
|
+
lines: "\n",
|
|
354
|
+
},
|
|
355
|
+
"zh-Hans": {
|
|
356
|
+
list: "、",
|
|
357
|
+
clauses: ",",
|
|
358
|
+
semicolons: ";",
|
|
359
|
+
sentences: "。",
|
|
360
|
+
lines: "\n",
|
|
361
|
+
},
|
|
362
|
+
ja: {
|
|
363
|
+
list: "、",
|
|
364
|
+
clauses: "、",
|
|
365
|
+
semicolons: ";",
|
|
366
|
+
sentences: "。",
|
|
367
|
+
lines: "\n",
|
|
368
|
+
},
|
|
369
|
+
ko: {
|
|
370
|
+
list: ", ",
|
|
371
|
+
clauses: ", ",
|
|
372
|
+
semicolons: "; ",
|
|
373
|
+
sentences: ". ",
|
|
374
|
+
lines: "\n",
|
|
375
|
+
},
|
|
376
|
+
de: {
|
|
377
|
+
list: ", ",
|
|
378
|
+
clauses: ", ",
|
|
379
|
+
semicolons: "; ",
|
|
380
|
+
sentences: ". ",
|
|
381
|
+
lines: "\n",
|
|
382
|
+
},
|
|
383
|
+
fr: {
|
|
384
|
+
list: ", ",
|
|
385
|
+
clauses: ", ",
|
|
386
|
+
semicolons: " ; ",
|
|
387
|
+
sentences: ". ",
|
|
388
|
+
lines: "\n",
|
|
389
|
+
},
|
|
390
|
+
es: {
|
|
391
|
+
list: ", ",
|
|
392
|
+
clauses: ", ",
|
|
393
|
+
semicolons: "; ",
|
|
394
|
+
sentences: ". ",
|
|
395
|
+
lines: "\n",
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
/**
|
|
399
|
+
* The player's limits on one text value (a
|
|
400
|
+
* {@link @vestra-player/sdk!Text | Text} or
|
|
401
|
+
* {@link @vestra-player/sdk!TextList | TextList} with everything nested in it), as the player's
|
|
402
|
+
* `i18n::text` (its tests check that they agree).
|
|
403
|
+
*/
|
|
404
|
+
export const TEXT_LIMITS = {
|
|
405
|
+
/** Nesting depth of texts and lists; the value itself is depth 0 (8). */
|
|
406
|
+
depth: 8,
|
|
407
|
+
/** Texts, lists and arguments in all, the value itself included (1024). */
|
|
408
|
+
nodes: 1024,
|
|
409
|
+
/** Arguments of one text (16). */
|
|
410
|
+
args: 16,
|
|
411
|
+
/** Items of one list (256). */
|
|
412
|
+
items: 256,
|
|
413
|
+
/** UTF-8 bytes of a source text or a string argument (4096). */
|
|
414
|
+
stringBytes: 4096,
|
|
415
|
+
};
|
|
416
|
+
const plainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
417
|
+
/** A text or list value (by its marker key). */
|
|
418
|
+
export function isTextValue(value) {
|
|
419
|
+
return (plainObject(value) && ("$vestraText" in value || "$vestraList" in value));
|
|
420
|
+
}
|
|
421
|
+
const utf8 = (text) => new TextEncoder().encode(text).length;
|
|
422
|
+
/**
|
|
423
|
+
* Why the player would refuse a text value or template arguments, or
|
|
424
|
+
* `null`. `args` mode checks template arguments (an array or object of
|
|
425
|
+
* arguments) instead of one value. The same rules and messages as the
|
|
426
|
+
* player's `i18n::text::check`.
|
|
427
|
+
*/
|
|
428
|
+
export function textProblem(value, mode = "value") {
|
|
429
|
+
let nodes = 0;
|
|
430
|
+
const L = TEXT_LIMITS;
|
|
431
|
+
const args = (list, depth) => {
|
|
432
|
+
const values = Array.isArray(list)
|
|
433
|
+
? list
|
|
434
|
+
: plainObject(list)
|
|
435
|
+
? Object.values(list)
|
|
436
|
+
: null;
|
|
437
|
+
if (!values)
|
|
438
|
+
return "args is an array or an object";
|
|
439
|
+
if (values.length > L.args)
|
|
440
|
+
return `args holds at most ${L.args} values`;
|
|
441
|
+
for (const item of values) {
|
|
442
|
+
const problem = arg(item, depth);
|
|
443
|
+
if (problem)
|
|
444
|
+
return problem;
|
|
445
|
+
}
|
|
446
|
+
return null;
|
|
447
|
+
};
|
|
448
|
+
const arg = (item, depth) => {
|
|
449
|
+
if (typeof item === "number")
|
|
450
|
+
return Number.isFinite(item) ? count() : "a number argument is finite";
|
|
451
|
+
if (typeof item === "string")
|
|
452
|
+
return utf8(item) > L.stringBytes
|
|
453
|
+
? `a string argument is at most ${L.stringBytes} bytes`
|
|
454
|
+
: count();
|
|
455
|
+
if (isTextValue(item))
|
|
456
|
+
return text(item, depth + 1);
|
|
457
|
+
return "args are strings, numbers, texts and lists";
|
|
458
|
+
};
|
|
459
|
+
const count = () => ++nodes > L.nodes ? `a text holds at most ${L.nodes} values` : null;
|
|
460
|
+
const text = (item, depth) => {
|
|
461
|
+
if (depth > L.depth)
|
|
462
|
+
return `texts nest at most ${L.depth} deep`;
|
|
463
|
+
if (!plainObject(item))
|
|
464
|
+
return "a text is a $vestraText or $vestraList object";
|
|
465
|
+
const over = count();
|
|
466
|
+
if (over)
|
|
467
|
+
return over;
|
|
468
|
+
if ("$vestraText" in item) {
|
|
469
|
+
const keys = Object.keys(item);
|
|
470
|
+
if (keys.some((key) => key !== "$vestraText" && key !== "args"))
|
|
471
|
+
return "a text has only $vestraText and args";
|
|
472
|
+
const source = item.$vestraText;
|
|
473
|
+
if (typeof source !== "string" || source === "")
|
|
474
|
+
return "a text's source is a non-empty string";
|
|
475
|
+
if (utf8(source) > L.stringBytes)
|
|
476
|
+
return `a text's source is at most ${L.stringBytes} bytes`;
|
|
477
|
+
return item.args === undefined ? null : args(item.args, depth);
|
|
478
|
+
}
|
|
479
|
+
const keys = Object.keys(item);
|
|
480
|
+
if (keys.some((key) => key !== "$vestraList" && key !== "style"))
|
|
481
|
+
return "a list has only $vestraList and style";
|
|
482
|
+
const items = item.$vestraList;
|
|
483
|
+
if (!Array.isArray(items))
|
|
484
|
+
return "a list's items are an array";
|
|
485
|
+
if (items.length > L.items)
|
|
486
|
+
return `a list holds at most ${L.items} items`;
|
|
487
|
+
if (item.style !== undefined &&
|
|
488
|
+
!LIST_STYLES.includes(item.style))
|
|
489
|
+
return `a list's style is one of ${LIST_STYLES.join(", ")}`;
|
|
490
|
+
for (const entry of items) {
|
|
491
|
+
const problem = arg(entry, depth);
|
|
492
|
+
if (problem)
|
|
493
|
+
return problem;
|
|
494
|
+
}
|
|
495
|
+
return null;
|
|
496
|
+
};
|
|
497
|
+
return mode === "args" ? args(value, 0) : text(value, 0);
|
|
498
|
+
}
|
|
499
|
+
function renderArg(arg, r) {
|
|
500
|
+
return isTextValue(arg) ? renderValue(arg, r) : arg;
|
|
501
|
+
}
|
|
502
|
+
function renderValue(value, r) {
|
|
503
|
+
if ("$vestraText" in value) {
|
|
504
|
+
const template = r.catalog.text(value.$vestraText);
|
|
505
|
+
return format(template, renderArgs(value.args, r) ?? []);
|
|
506
|
+
}
|
|
507
|
+
const locale = LOCALES.includes(r.locale)
|
|
508
|
+
? r.locale
|
|
509
|
+
: "en";
|
|
510
|
+
const separator = LIST_SEPARATORS[locale][value.style ?? "list"];
|
|
511
|
+
return value.$vestraList
|
|
512
|
+
.map((item) => String(argumentText(renderArg(item, r))))
|
|
513
|
+
.join(separator);
|
|
514
|
+
}
|
|
515
|
+
const argumentText = (value) => typeof value === "number" ? format("{}", [value]) : value;
|
|
516
|
+
function renderArgs(args, r) {
|
|
517
|
+
if (args === undefined || args === null)
|
|
518
|
+
return undefined;
|
|
519
|
+
if (Array.isArray(args))
|
|
520
|
+
return args.map((arg) => renderArg(arg, r));
|
|
521
|
+
return Object.fromEntries(Object.entries(args).map(([name, arg]) => [name, renderArg(arg, r)]));
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* A text value as the player shows it: the source translated with the
|
|
525
|
+
* plugin's catalog and formatted with its (rendered) arguments; a list's
|
|
526
|
+
* items joined with the locale's separator.
|
|
527
|
+
*/
|
|
528
|
+
export function renderText(value, catalog, locale) {
|
|
529
|
+
return renderValue(value, { catalog, locale });
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Template arguments with their texts rendered (for `format`): what the
|
|
533
|
+
* player fills a template with.
|
|
534
|
+
*/
|
|
535
|
+
export function renderTemplateArgs(args, catalog, locale) {
|
|
536
|
+
return renderArgs(args ?? undefined, { catalog, locale });
|
|
537
|
+
}
|
|
538
|
+
/** The English rendering of a text value: the key a menu merges by. */
|
|
539
|
+
export function textKey(value) {
|
|
540
|
+
return renderValue(value, { catalog: new Catalog(), locale: "en" });
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* A property value with every text value in it replaced by its shown
|
|
544
|
+
* string, as the player does before it hands the value to Slint. Returns
|
|
545
|
+
* a problem (`textProblem`) instead when a text value is malformed.
|
|
546
|
+
*/
|
|
547
|
+
export function resolveTexts(value, catalog, locale) {
|
|
548
|
+
let problem = null;
|
|
549
|
+
const walk = (item) => {
|
|
550
|
+
if (problem)
|
|
551
|
+
return item;
|
|
552
|
+
if (isTextValue(item)) {
|
|
553
|
+
problem = textProblem(item);
|
|
554
|
+
return problem ? item : renderText(item, catalog, locale);
|
|
555
|
+
}
|
|
556
|
+
if (Array.isArray(item))
|
|
557
|
+
return item.map(walk);
|
|
558
|
+
if (plainObject(item)) {
|
|
559
|
+
// A runtime image or Markdown document stays as it is.
|
|
560
|
+
if ("$vestraImage" in item || "$vestraMarkdown" in item)
|
|
561
|
+
return item;
|
|
562
|
+
return Object.fromEntries(Object.entries(item).map(([key, entry]) => [key, walk(entry)]));
|
|
563
|
+
}
|
|
564
|
+
return item;
|
|
565
|
+
};
|
|
566
|
+
const out = walk(value);
|
|
567
|
+
return problem ? { problem } : { value: out };
|
|
568
|
+
}
|
|
569
|
+
/** Whether a value holds a text value anywhere (a cheap pre-check). */
|
|
570
|
+
export function hasTexts(value) {
|
|
571
|
+
if (isTextValue(value))
|
|
572
|
+
return true;
|
|
573
|
+
if (Array.isArray(value))
|
|
574
|
+
return value.some(hasTexts);
|
|
575
|
+
if (plainObject(value))
|
|
576
|
+
return Object.values(value).some(hasTexts);
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
const lineEnd = (s, i) => {
|
|
580
|
+
const found = s.slice(i).search(/[\n\r]/);
|
|
581
|
+
return found < 0 ? s.length : i + found;
|
|
582
|
+
};
|
|
583
|
+
function commentEnd(s, from) {
|
|
584
|
+
let depth = 0;
|
|
585
|
+
let i = from + 2;
|
|
586
|
+
while (i + 1 < s.length) {
|
|
587
|
+
if (s[i] === "/" && s[i + 1] === "*") {
|
|
588
|
+
depth++;
|
|
589
|
+
i += 2;
|
|
590
|
+
}
|
|
591
|
+
else if (s[i] === "*" && s[i + 1] === "/") {
|
|
592
|
+
if (depth === 0)
|
|
593
|
+
return i + 2;
|
|
594
|
+
depth--;
|
|
595
|
+
i += 2;
|
|
596
|
+
}
|
|
597
|
+
else
|
|
598
|
+
i++;
|
|
599
|
+
}
|
|
600
|
+
return s.length;
|
|
601
|
+
}
|
|
602
|
+
function stringEnd(s, from, templates) {
|
|
603
|
+
let i = from;
|
|
604
|
+
while (i < s.length) {
|
|
605
|
+
if (s[i] === '"')
|
|
606
|
+
return i + 1;
|
|
607
|
+
if (s[i] === "\\" && s[i + 1] === "{") {
|
|
608
|
+
templates.push(0);
|
|
609
|
+
return i + 2;
|
|
610
|
+
}
|
|
611
|
+
i += s[i] === "\\" ? 2 : 1;
|
|
612
|
+
}
|
|
613
|
+
return s.length;
|
|
614
|
+
}
|
|
615
|
+
function skip(s, i) {
|
|
616
|
+
for (;;) {
|
|
617
|
+
if (/\s/.test(s[i] ?? ""))
|
|
618
|
+
i++;
|
|
619
|
+
else if (s[i] === "/" && s[i + 1] === "/")
|
|
620
|
+
i = lineEnd(s, i);
|
|
621
|
+
else if (s[i] === "/" && s[i + 1] === "*")
|
|
622
|
+
i = commentEnd(s, i);
|
|
623
|
+
else
|
|
624
|
+
return i;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/** A plain string literal at `i`: its span and value (no interpolation). */
|
|
628
|
+
function literal(s, i) {
|
|
629
|
+
if (s[i] !== '"')
|
|
630
|
+
return null;
|
|
631
|
+
let j = i + 1;
|
|
632
|
+
let value = "";
|
|
633
|
+
for (;;) {
|
|
634
|
+
const c = s[j];
|
|
635
|
+
if (c === undefined)
|
|
636
|
+
return null;
|
|
637
|
+
if (c === '"')
|
|
638
|
+
return [[i, j + 1], value];
|
|
639
|
+
if (c !== "\\") {
|
|
640
|
+
value += c;
|
|
641
|
+
j++;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const n = s[j + 1];
|
|
645
|
+
if (n === '"' || n === "\\")
|
|
646
|
+
value += n;
|
|
647
|
+
else if (n === "n")
|
|
648
|
+
value += "\n";
|
|
649
|
+
else if (n === "u") {
|
|
650
|
+
if (s[j + 2] !== "{")
|
|
651
|
+
return null;
|
|
652
|
+
const close = s.indexOf("}", j + 3);
|
|
653
|
+
if (close < 0)
|
|
654
|
+
return null;
|
|
655
|
+
const code = Number.parseInt(s.slice(j + 3, close), 16);
|
|
656
|
+
if (!/^[0-9a-fA-F]+$/.test(s.slice(j + 3, close)) || code > 0x10ffff)
|
|
657
|
+
return null;
|
|
658
|
+
value += String.fromCodePoint(code);
|
|
659
|
+
j = close + 1;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
else
|
|
663
|
+
return null;
|
|
664
|
+
j += 2;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
function callAt(s, at) {
|
|
668
|
+
let i = skip(s, at + 1);
|
|
669
|
+
if (!s.startsWith("tr", i))
|
|
670
|
+
return null;
|
|
671
|
+
i += 2;
|
|
672
|
+
if (/[A-Za-z0-9_-]/.test(s[i] ?? ""))
|
|
673
|
+
return null;
|
|
674
|
+
i = skip(s, i);
|
|
675
|
+
if (s[i] !== "(")
|
|
676
|
+
return null;
|
|
677
|
+
let found = literal(s, skip(s, i + 1));
|
|
678
|
+
if (!found)
|
|
679
|
+
return null;
|
|
680
|
+
let [span, text] = found;
|
|
681
|
+
let context;
|
|
682
|
+
let end = skip(s, span[1]);
|
|
683
|
+
if (s.startsWith("=>", end)) {
|
|
684
|
+
context = text;
|
|
685
|
+
found = literal(s, skip(s, end + 2));
|
|
686
|
+
if (!found)
|
|
687
|
+
return null;
|
|
688
|
+
[span, text] = found;
|
|
689
|
+
end = skip(s, span[1]);
|
|
690
|
+
}
|
|
691
|
+
let resume = span[1];
|
|
692
|
+
const call = {
|
|
693
|
+
id: text,
|
|
694
|
+
line: s.slice(0, at).split("\n").length,
|
|
695
|
+
idSpan: span,
|
|
696
|
+
};
|
|
697
|
+
if (context !== undefined)
|
|
698
|
+
call.context = context;
|
|
699
|
+
if (s[end] === "|") {
|
|
700
|
+
const other = literal(s, skip(s, end + 1));
|
|
701
|
+
if (!other)
|
|
702
|
+
return null;
|
|
703
|
+
call.plural = other[1];
|
|
704
|
+
call.pluralSpan = other[0];
|
|
705
|
+
resume = other[0][1];
|
|
706
|
+
}
|
|
707
|
+
return [call, resume];
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Every `@tr` call of a Slint source, in order; the same scanner as the
|
|
711
|
+
* player's (both run the shared cases of the player's
|
|
712
|
+
* `src/host/tests/fixtures/slint-tr.json`). Comments, strings and string
|
|
713
|
+
* interpolation are skipped; a malformed call is left out.
|
|
714
|
+
*/
|
|
715
|
+
export function scanSlint(source) {
|
|
716
|
+
const calls = [];
|
|
717
|
+
const templates = [];
|
|
718
|
+
let i = 0;
|
|
719
|
+
while (i < source.length) {
|
|
720
|
+
const c = source[i];
|
|
721
|
+
if (c === "/" && source[i + 1] === "/")
|
|
722
|
+
i = lineEnd(source, i);
|
|
723
|
+
else if (c === "/" && source[i + 1] === "*")
|
|
724
|
+
i = commentEnd(source, i);
|
|
725
|
+
else if (c === '"')
|
|
726
|
+
i = stringEnd(source, i + 1, templates);
|
|
727
|
+
else if (c === "{" && templates.length) {
|
|
728
|
+
templates[templates.length - 1]++;
|
|
729
|
+
i++;
|
|
730
|
+
}
|
|
731
|
+
else if (c === "}" && templates.length) {
|
|
732
|
+
if (templates[templates.length - 1] === 0) {
|
|
733
|
+
templates.pop();
|
|
734
|
+
i = stringEnd(source, i + 1, templates);
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
templates[templates.length - 1]--;
|
|
738
|
+
i++;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
else if (c === "@") {
|
|
742
|
+
const found = callAt(source, i);
|
|
743
|
+
if (found) {
|
|
744
|
+
calls.push(found[0]);
|
|
745
|
+
i = found[1];
|
|
746
|
+
}
|
|
747
|
+
else
|
|
748
|
+
i++;
|
|
749
|
+
}
|
|
750
|
+
else
|
|
751
|
+
i++;
|
|
752
|
+
}
|
|
753
|
+
return calls;
|
|
754
|
+
}
|
|
755
|
+
/** A Slint string literal for `text`. */
|
|
756
|
+
export function slintQuote(text) {
|
|
757
|
+
let out = '"';
|
|
758
|
+
for (const c of text) {
|
|
759
|
+
if (c === '"')
|
|
760
|
+
out += '\\"';
|
|
761
|
+
else if (c === "\\")
|
|
762
|
+
out += "\\\\";
|
|
763
|
+
else if (c === "\n")
|
|
764
|
+
out += "\\n";
|
|
765
|
+
else if (/\p{Cc}/u.test(c))
|
|
766
|
+
out += `\\u{${c.codePointAt(0).toString(16)}}`;
|
|
767
|
+
else
|
|
768
|
+
out += c;
|
|
769
|
+
}
|
|
770
|
+
return `${out}"`;
|
|
771
|
+
}
|
|
772
|
+
/** `source` with its `@tr` literals translated (what the player compiles). */
|
|
773
|
+
export function rewriteSlint(source, catalog) {
|
|
774
|
+
let out = "";
|
|
775
|
+
let copied = 0;
|
|
776
|
+
for (const call of scanSlint(source)) {
|
|
777
|
+
const translation = catalog.slint(call);
|
|
778
|
+
if (!translation)
|
|
779
|
+
continue;
|
|
780
|
+
out += source.slice(copied, call.idSpan[0]) + slintQuote(translation[0]);
|
|
781
|
+
copied = call.idSpan[1];
|
|
782
|
+
if (call.pluralSpan && translation[1] !== undefined) {
|
|
783
|
+
out +=
|
|
784
|
+
source.slice(copied, call.pluralSpan[0]) + slintQuote(translation[1]);
|
|
785
|
+
copied = call.pluralSpan[1];
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return out + source.slice(copied);
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* The supported locale for a BCP 47 tag, as the player maps the system's
|
|
792
|
+
* languages: Simplified Chinese is `zh-Hans`; Traditional Chinese (`zh-Hant`,
|
|
793
|
+
* or Taiwan, Hong Kong, Macao) is English; other languages by their primary
|
|
794
|
+
* subtag; `undefined` for a language the player does not have.
|
|
795
|
+
*/
|
|
796
|
+
export function matchLocale(tag) {
|
|
797
|
+
const parts = tag
|
|
798
|
+
.replaceAll("_", "-")
|
|
799
|
+
.toLowerCase()
|
|
800
|
+
.split(/[.@]/)[0]
|
|
801
|
+
.split("-");
|
|
802
|
+
const [language, ...rest] = parts;
|
|
803
|
+
if (language === "zh") {
|
|
804
|
+
const script = rest.find((part) => part.length === 4);
|
|
805
|
+
if (script)
|
|
806
|
+
return script === "hans" ? "zh-Hans" : "en";
|
|
807
|
+
return rest.some((r) => ["tw", "hk", "mo"].includes(r)) ? "en" : "zh-Hans";
|
|
808
|
+
}
|
|
809
|
+
return LOCALES.find((locale) => !locale.includes("-") && locale === language);
|
|
810
|
+
}
|
|
811
|
+
// ---------------------------------------------------------------- menus
|
|
812
|
+
/**
|
|
813
|
+
* The player's built-in top-level menus: id → key (the English text paths
|
|
814
|
+
* merge by), as the player's `MENU_ANCHORS`.
|
|
815
|
+
*/
|
|
816
|
+
export const MENU_KEYS = {
|
|
817
|
+
open: "Open",
|
|
818
|
+
playback: "Playback",
|
|
819
|
+
subtitles: "Subtitles",
|
|
820
|
+
video: "Video",
|
|
821
|
+
audio: "Audio",
|
|
822
|
+
screen: "Screen",
|
|
823
|
+
settings: "Settings",
|
|
824
|
+
};
|
|
825
|
+
/** The player's old Chinese titles of its own menus: parent, title, key. */
|
|
826
|
+
const LEGACY_MENUS = [
|
|
827
|
+
[[], "打开", "Open"],
|
|
828
|
+
[[], "播放", "Playback"],
|
|
829
|
+
[[], "字幕", "Subtitles"],
|
|
830
|
+
[[], "视频", "Video"],
|
|
831
|
+
[[], "声音", "Audio"],
|
|
832
|
+
[[], "屏幕", "Screen"],
|
|
833
|
+
[[], "设置", "Settings"],
|
|
834
|
+
[["Playback"], "速度", "Speed"],
|
|
835
|
+
[["Playback"], "跳转", "Seek"],
|
|
836
|
+
// Shared with a migrated built-in plugin (until all are migrated).
|
|
837
|
+
[["Settings"], "外观", "Appearance"],
|
|
838
|
+
];
|
|
839
|
+
/** The player's own submenus, by key path (their labels are the player's). */
|
|
840
|
+
export const HOST_GROUPS = [
|
|
841
|
+
"Open",
|
|
842
|
+
"Playback",
|
|
843
|
+
"Subtitles",
|
|
844
|
+
"Video",
|
|
845
|
+
"Audio",
|
|
846
|
+
"Screen",
|
|
847
|
+
"Settings",
|
|
848
|
+
"Playback/Speed",
|
|
849
|
+
"Playback/Seek",
|
|
850
|
+
];
|
|
851
|
+
/**
|
|
852
|
+
* The keys a path merges by, as the player normalizes it: `@id` becomes the
|
|
853
|
+
* built-in menu's key, the player's old Chinese titles of its own submenus
|
|
854
|
+
* their keys.
|
|
855
|
+
*/
|
|
856
|
+
export function menuKeys(path) {
|
|
857
|
+
const keys = [...path];
|
|
858
|
+
const id = keys[0]?.startsWith("@") ? keys[0].slice(1) : null;
|
|
859
|
+
if (id !== null && MENU_KEYS[id])
|
|
860
|
+
keys[0] = MENU_KEYS[id];
|
|
861
|
+
for (const [parent, old, key] of LEGACY_MENUS) {
|
|
862
|
+
const depth = parent.length;
|
|
863
|
+
if (keys.length > depth + 1 &&
|
|
864
|
+
parent.every((segment, i) => keys[i] === segment) &&
|
|
865
|
+
keys[depth] === old)
|
|
866
|
+
keys[depth] = key;
|
|
867
|
+
}
|
|
868
|
+
return keys;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* The player's own texts that the test host repeats (core actions, its
|
|
872
|
+
* refusals, its menus), English → the player's Simplified Chinese
|
|
873
|
+
* (`src/host/locales/zh-Hans.po` in the player; its tests check that they
|
|
874
|
+
* agree).
|
|
875
|
+
*/
|
|
876
|
+
export const HOST_ZH_HANS = {
|
|
877
|
+
Open: "打开",
|
|
878
|
+
Playback: "播放",
|
|
879
|
+
Subtitles: "字幕",
|
|
880
|
+
Video: "视频",
|
|
881
|
+
Audio: "声音",
|
|
882
|
+
Screen: "屏幕",
|
|
883
|
+
Settings: "设置",
|
|
884
|
+
Speed: "速度",
|
|
885
|
+
Seek: "跳转",
|
|
886
|
+
Close: "关闭",
|
|
887
|
+
Cancel: "取消",
|
|
888
|
+
"Play/Pause": "播放/暂停",
|
|
889
|
+
Previous: "上一个",
|
|
890
|
+
Next: "下一个",
|
|
891
|
+
"Previous Frame": "上一帧",
|
|
892
|
+
"Next Frame": "下一帧",
|
|
893
|
+
"Back {} s": "后退{}秒",
|
|
894
|
+
"Forward {} s": "前进{}秒",
|
|
895
|
+
Normal: "正常",
|
|
896
|
+
"Slower 0.1": "减速 0.1",
|
|
897
|
+
"Faster 0.1": "加速 0.1",
|
|
898
|
+
"Volume +": "音量 +",
|
|
899
|
+
"Volume −": "音量 −",
|
|
900
|
+
Mute: "静音",
|
|
901
|
+
"Not supported by the current source": "当前来源不支持此操作",
|
|
902
|
+
"The current source cannot step back a frame": "当前来源不支持逐帧后退",
|
|
903
|
+
"No video": "没有视频",
|
|
904
|
+
"No previous item": "没有上一个",
|
|
905
|
+
"No next item": "没有下一个",
|
|
906
|
+
Paused: "已暂停",
|
|
907
|
+
Playing: "播放",
|
|
908
|
+
"Back {} s: {} / {}": "后退 {}秒: {} / {}",
|
|
909
|
+
"Forward {} s: {} / {}": "前进 {}秒: {} / {}",
|
|
910
|
+
"Speed: {}x": "播放速度: {}x",
|
|
911
|
+
"Volume: {}%": "音量: {}%",
|
|
912
|
+
"Mute: On": "静音: 开",
|
|
913
|
+
"Mute: Off": "静音: 关",
|
|
914
|
+
"bottom bar": "底栏",
|
|
915
|
+
sidebar: "侧栏",
|
|
916
|
+
"video backdrop": "视频背景",
|
|
917
|
+
"The {} is used by {}": "{}已被 {} 使用",
|
|
918
|
+
"The sidebar holds at most {} tabs": "侧栏最多 {} 个页签",
|
|
919
|
+
"{} is resolved by {}": "{} 已由 {} 解析",
|
|
920
|
+
"File type .{}": "文件类型 .{}",
|
|
921
|
+
"Not available in fullscreen": "全屏时不可用",
|
|
922
|
+
"Picture-in-picture is not supported on this system": "此系统不支持画中画",
|
|
923
|
+
};
|
|
924
|
+
// ---------------------------------------------------------------- packages
|
|
925
|
+
/**
|
|
926
|
+
* The catalog of a package directory for `locale`, as the player loads it:
|
|
927
|
+
* the compiled `locales/<lang>.json` of a built package, else the source
|
|
928
|
+
* `locales/<lang>.po`. Empty for English and without a file; a broken file
|
|
929
|
+
* throws.
|
|
930
|
+
*/
|
|
931
|
+
export function loadPackageCatalog(directory, locale) {
|
|
932
|
+
if (locale === "en")
|
|
933
|
+
return new Catalog();
|
|
934
|
+
const base = join(directory, "locales", locale);
|
|
935
|
+
if (existsSync(`${base}.json`)) {
|
|
936
|
+
const compiled = JSON.parse(readFileSync(`${base}.json`, "utf8"));
|
|
937
|
+
return new Catalog(compiled.messages ?? {});
|
|
938
|
+
}
|
|
939
|
+
if (existsSync(`${base}.po`))
|
|
940
|
+
return new Catalog(compileCatalog(locale, parsePo(readFileSync(`${base}.po`, "utf8")).entries).messages);
|
|
941
|
+
return new Catalog();
|
|
942
|
+
}
|
|
943
|
+
// ---------------------------------------------------------------- columns
|
|
944
|
+
const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩\u{1F300}-\u{1F64F}\u{1F900}-\u{1F9FF}\u{20000}-\u{3FFFD}]/u;
|
|
945
|
+
const ZERO = /[\p{Mn}\p{Me}-]/u;
|
|
946
|
+
/** Columns of one character: 2 wide (East Asian), 0 combining, else 1. */
|
|
947
|
+
const columnsOf = (c) => (ZERO.test(c) ? 0 : WIDE.test(c) ? 2 : 1);
|
|
948
|
+
/**
|
|
949
|
+
* Each line of `text` cut to `max` columns, ending with `…` when cut, as
|
|
950
|
+
* the player cuts an OSD panel's lines (`OsdPanelOptions.maxColumns`; the
|
|
951
|
+
* player measures with Unicode's East Asian Width, this is close to it).
|
|
952
|
+
*/
|
|
953
|
+
export function fitColumns(text, max) {
|
|
954
|
+
return text
|
|
955
|
+
.split("\n")
|
|
956
|
+
.map((line) => {
|
|
957
|
+
let used = 0;
|
|
958
|
+
const chars = [...line];
|
|
959
|
+
if (chars.reduce((n, c) => n + columnsOf(c), 0) <= max)
|
|
960
|
+
return line;
|
|
961
|
+
let out = "";
|
|
962
|
+
for (const c of chars) {
|
|
963
|
+
const width = columnsOf(c);
|
|
964
|
+
if (used + width > max - 1)
|
|
965
|
+
break;
|
|
966
|
+
out += c;
|
|
967
|
+
used += width;
|
|
968
|
+
}
|
|
969
|
+
return `${out}…`;
|
|
970
|
+
})
|
|
971
|
+
.join("\n");
|
|
972
|
+
}
|