vidply 1.2.0 → 1.2.3
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/README.md +974 -946
- package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-JYYLK5KB.js} +275 -19
- package/dist/dev/vidply.AudioDescriptionManager-JYYLK5KB.js.map +7 -0
- package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-47RCKQF5.js} +4 -4
- package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-VEJ4HDHO.js} +3 -3
- package/dist/dev/vidply.DASHRenderer-VEJ4HDHO.js.map +7 -0
- package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-C2UVRPZY.js} +5 -5
- package/dist/dev/vidply.FloatingPlayerManager-C2UVRPZY.js.map +7 -0
- package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZVBK2TNQ.js} +4 -4
- package/dist/dev/vidply.HLSRenderer-ZVBK2TNQ.js.map +7 -0
- package/dist/dev/vidply.HTML5Renderer-HLRQUOCU.js +12 -0
- package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-FLUCBW5H.js} +8 -8
- package/dist/dev/vidply.SignLanguageManager-FLUCBW5H.js.map +7 -0
- package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-MGMAJ7NK.js} +3 -3
- package/dist/dev/vidply.SoundCloudRenderer-MGMAJ7NK.js.map +7 -0
- package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-Y2RYXHJW.js} +10 -10
- package/dist/dev/vidply.TranscriptManager-Y2RYXHJW.js.map +7 -0
- package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-RQFQD7OE.js} +3 -3
- package/dist/dev/vidply.VimeoRenderer-RQFQD7OE.js.map +7 -0
- package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-EU2BARZF.js} +3 -3
- package/dist/dev/vidply.YouTubeRenderer-EU2BARZF.js.map +7 -0
- package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-6LYZYI5G.js} +3 -3
- package/dist/dev/vidply.chunk-6LYZYI5G.js.map +7 -0
- package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-A4ESEOQ2.js} +6 -6
- package/dist/dev/vidply.chunk-A4ESEOQ2.js.map +7 -0
- package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-CGYIED2I.js} +2 -2
- package/dist/dev/vidply.chunk-CGYIED2I.js.map +7 -0
- package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-HZ7UOKOU.js} +3 -3
- package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-I6EDRYGJ.js} +33 -6
- package/dist/dev/vidply.chunk-I6EDRYGJ.js.map +7 -0
- package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-JKMQGLCJ.js} +5 -5
- package/dist/dev/vidply.chunk-JKMQGLCJ.js.map +7 -0
- package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-JZNZX32Z.js} +2 -2
- package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-P25MRJ6O.js} +2 -2
- package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-PRFNESYA.js} +2 -2
- package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-UI3RYCVF.js} +4 -3
- package/dist/dev/vidply.chunk-UI3RYCVF.js.map +7 -0
- package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-UNFGMABP.js} +2 -2
- package/dist/dev/vidply.chunk-UNFGMABP.js.map +7 -0
- package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-VAUVSQXC.js} +29 -2
- package/dist/dev/vidply.de-VAUVSQXC.js.map +7 -0
- package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-G65HFWO6.js} +29 -2
- package/dist/dev/vidply.es-G65HFWO6.js.map +7 -0
- package/dist/dev/vidply.esm.js +648 -35
- package/dist/dev/vidply.esm.js.map +3 -3
- package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-MJNOZELM.js} +29 -2
- package/dist/dev/vidply.fr-MJNOZELM.js.map +7 -0
- package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-INMDHIYJ.js} +29 -2
- package/dist/dev/vidply.ja-INMDHIYJ.js.map +7 -0
- package/dist/legacy/vidply.js +1062 -34
- package/dist/legacy/vidply.js.map +3 -3
- package/dist/legacy/vidply.min.js +2 -2
- package/dist/legacy/vidply.min.meta.json +127 -55
- package/dist/prod/vidply.AudioDescriptionManager-EKWUNOPE.min.js +6 -0
- package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-JTWSNHRQ.min.js} +2 -2
- package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-CIDAHFWM.min.js} +2 -2
- package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-VZXHPCPM.min.js} +2 -2
- package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-Q6EFYKID.min.js} +2 -2
- package/dist/prod/vidply.HTML5Renderer-XRDJUTOO.min.js +6 -0
- package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-JUOSA5AM.min.js} +2 -2
- package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-7ODD46PO.min.js} +2 -2
- package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-4B2QKYGQ.min.js} +2 -2
- package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-MR3UYLY7.min.js} +2 -2
- package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-ITRTC7NR.min.js} +2 -2
- package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-3EEDXU4T.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-5HTX5QXW.min.js} +1 -1
- package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-6OGHHIYP.min.js} +2 -2
- package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-GUPGMFUZ.min.js} +2 -2
- package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-MWEBITCP.min.js} +1 -1
- package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-PL3P3JHR.min.js} +1 -1
- package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-QQZGV7TD.min.js} +2 -2
- package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-U5IRRYG5.min.js} +1 -1
- package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-VKHV4QHA.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-XA4M574A.min.js} +1 -1
- package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-ZPTV4JEP.min.js} +2 -2
- package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-UZHFCEZU.min.js} +2 -2
- package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-BOISUV2S.min.js} +2 -2
- package/dist/prod/vidply.esm.min.js +2 -2
- package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-ICSMKPFM.min.js} +2 -2
- package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-VQ2HKGAY.min.js} +2 -2
- package/dist/vidply.css +5852 -5796
- package/dist/vidply.esm.min.meta.json +204 -149
- package/dist/vidply.min.css +1 -1
- package/package.json +11 -11
- package/src/controls/CaptionManager.ts +598 -598
- package/src/controls/ControlBar.ts +4191 -4159
- package/src/controls/KeyboardHelp.ts +326 -0
- package/src/controls/KeyboardManager.ts +387 -383
- package/src/controls/SettingsDialog.ts +488 -488
- package/src/controls/TranscriptManager.ts +2426 -2426
- package/src/core/AudioDescriptionManager.ts +863 -774
- package/src/core/DescriptionSpeechManager.ts +232 -0
- package/src/core/FloatingPlayerManager.ts +674 -674
- package/src/core/LazyInit.ts +80 -80
- package/src/core/MediaSessionManager.ts +341 -0
- package/src/core/MetadataAlertsManager.ts +514 -514
- package/src/core/Player.ts +2973 -2872
- package/src/core/PosterManager.ts +169 -169
- package/src/core/PseudoFullscreen.ts +179 -179
- package/src/core/ResponsiveManager.ts +231 -231
- package/src/core/ResumeManager.ts +236 -236
- package/src/core/SignLanguageManager.ts +1308 -1308
- package/src/core/ThemeManager.ts +157 -157
- package/src/features/PlaylistManager.ts +1838 -1827
- package/src/i18n/i18n.ts +223 -223
- package/src/i18n/languages/de.ts +244 -217
- package/src/i18n/languages/en.ts +245 -218
- package/src/i18n/languages/es.ts +241 -214
- package/src/i18n/languages/fr.ts +241 -214
- package/src/i18n/languages/ja.ts +241 -214
- package/src/icons/Icons.ts +168 -167
- package/src/index.ts +196 -193
- package/src/renderers/DASHRenderer.ts +1077 -1077
- package/src/renderers/HLSRenderer.ts +944 -944
- package/src/renderers/HTML5Renderer.ts +389 -389
- package/src/renderers/SoundCloudRenderer.ts +437 -437
- package/src/renderers/VimeoRenderer.ts +298 -298
- package/src/renderers/YouTubeRenderer.ts +290 -290
- package/src/styles/vidply.css +5852 -5796
- package/src/types/events.ts +128 -122
- package/src/types/globals.d.ts +313 -313
- package/src/types/options.ts +199 -182
- package/src/types/renderer.ts +58 -58
- package/src/utils/DOMUtils.ts +281 -281
- package/src/utils/DownloadInfo.ts +171 -171
- package/src/utils/DraggablePanel.ts +539 -539
- package/src/utils/DraggablePanelMenu.ts +195 -195
- package/src/utils/DraggableResizable.ts +997 -997
- package/src/utils/EventEmitter.ts +59 -59
- package/src/utils/FormUtils.ts +95 -95
- package/src/utils/MenuFactory.ts +483 -483
- package/src/utils/Sanitize.ts +70 -70
- package/src/utils/SettingsMenuFactory.ts +289 -289
- package/src/utils/StorageManager.ts +287 -287
- package/src/utils/TrackLabelUtils.ts +52 -52
- package/src/utils/UrlSafe.ts +69 -69
- package/src/utils/VideoFrameCapture.ts +114 -114
- package/src/utils/WindowComponents.ts +250 -250
- package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
- package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
- package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
- package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
- package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
- package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
- package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
- package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
- package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
- package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
- package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
- package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
- package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
- package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
- package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
- package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
- package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
- package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
- package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
- package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
- package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
- package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
- package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
- /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-47RCKQF5.js.map} +0 -0
- /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-HLRQUOCU.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-HZ7UOKOU.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-JZNZX32Z.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-P25MRJ6O.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-PRFNESYA.js.map} +0 -0
package/src/i18n/i18n.ts
CHANGED
|
@@ -1,223 +1,223 @@
|
|
|
1
|
-
import { getBaseTranslations, getBuiltInLanguageLoaders, loadBuiltInTranslation, type TranslationData } from './translations.js';
|
|
2
|
-
import { deepSanitize, isForbiddenKey } from '../utils/Sanitize.js';
|
|
3
|
-
|
|
4
|
-
declare global {
|
|
5
|
-
interface Window {
|
|
6
|
-
jsyaml?: { load(input: string): unknown };
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Escape RegExp meta-characters so a placeholder name like
|
|
12
|
-
* `{name.first}` does not turn into a wildcard or backreference when
|
|
13
|
-
* compiled into a `RegExp`.
|
|
14
|
-
*/
|
|
15
|
-
function escapeRegExp(input: string): string {
|
|
16
|
-
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Recursively copy a translation object onto a null-prototype map and
|
|
21
|
-
* drop forbidden keys (`__proto__`, `prototype`, `constructor`) so a
|
|
22
|
-
* malicious translation file cannot pollute Object.prototype on its way
|
|
23
|
-
* through `Object.assign`.
|
|
24
|
-
*/
|
|
25
|
-
function deepSanitizeTranslations(input: unknown): TranslationData {
|
|
26
|
-
return deepSanitize<TranslationData>(input);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
class I18n {
|
|
30
|
-
currentLanguage: string;
|
|
31
|
-
translations: Record<string, TranslationData>;
|
|
32
|
-
private loadingPromises: Map<string, Promise<unknown>>;
|
|
33
|
-
private builtInLanguageLoaders: Record<string, () => Promise<Record<string, TranslationData>>>;
|
|
34
|
-
|
|
35
|
-
constructor() {
|
|
36
|
-
this.currentLanguage = 'en';
|
|
37
|
-
this.translations = Object.create(null) as Record<string, TranslationData>;
|
|
38
|
-
Object.assign(this.translations, getBaseTranslations());
|
|
39
|
-
this.loadingPromises = new Map();
|
|
40
|
-
this.builtInLanguageLoaders = getBuiltInLanguageLoaders();
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
setLanguage(lang: string): void {
|
|
44
|
-
if (this.translations[lang]) {
|
|
45
|
-
this.currentLanguage = lang;
|
|
46
|
-
} else {
|
|
47
|
-
console.warn(`Language "${lang}" not found, falling back to English`);
|
|
48
|
-
this.currentLanguage = 'en';
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
getLanguage(): string {
|
|
53
|
-
return this.currentLanguage;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async ensureLanguage(lang: string): Promise<string | null> {
|
|
57
|
-
const normalizedLang = (lang || '').toLowerCase();
|
|
58
|
-
if (!normalizedLang) return this.currentLanguage;
|
|
59
|
-
|
|
60
|
-
if (this.translations[normalizedLang]) {
|
|
61
|
-
return normalizedLang;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (this.loadingPromises.has(normalizedLang)) {
|
|
65
|
-
await this.loadingPromises.get(normalizedLang);
|
|
66
|
-
return this.translations[normalizedLang] ? normalizedLang : null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (!this.builtInLanguageLoaders[normalizedLang]) {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const loadPromise = (async () => {
|
|
74
|
-
try {
|
|
75
|
-
const loaded = await loadBuiltInTranslation(normalizedLang);
|
|
76
|
-
if (loaded) {
|
|
77
|
-
this.translations[normalizedLang] = deepSanitizeTranslations(loaded);
|
|
78
|
-
}
|
|
79
|
-
} catch (error) {
|
|
80
|
-
console.warn(`Language "${normalizedLang}" failed to load:`, error);
|
|
81
|
-
} finally {
|
|
82
|
-
this.loadingPromises.delete(normalizedLang);
|
|
83
|
-
}
|
|
84
|
-
})();
|
|
85
|
-
|
|
86
|
-
this.loadingPromises.set(normalizedLang, loadPromise);
|
|
87
|
-
await loadPromise;
|
|
88
|
-
|
|
89
|
-
return this.translations[normalizedLang] ? normalizedLang : null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
t(key: string, replacements: Record<string, string | number> = {}): string {
|
|
93
|
-
const keys = key.split('.');
|
|
94
|
-
let value: unknown = this.translations[this.currentLanguage];
|
|
95
|
-
|
|
96
|
-
for (const k of keys) {
|
|
97
|
-
if (value && typeof value === 'object' && k in (value as Record<string, unknown>)) {
|
|
98
|
-
value = (value as Record<string, unknown>)[k];
|
|
99
|
-
} else {
|
|
100
|
-
value = this.translations.en;
|
|
101
|
-
for (const fallbackKey of keys) {
|
|
102
|
-
if (value && typeof value === 'object' && fallbackKey in (value as Record<string, unknown>)) {
|
|
103
|
-
value = (value as Record<string, unknown>)[fallbackKey];
|
|
104
|
-
} else {
|
|
105
|
-
return key;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
break;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (typeof value === 'string') {
|
|
113
|
-
let result = value;
|
|
114
|
-
for (const [placeholder, replacement] of Object.entries(replacements)) {
|
|
115
|
-
if (isForbiddenKey(placeholder)) continue;
|
|
116
|
-
const safe = escapeRegExp(placeholder);
|
|
117
|
-
result = result.replace(new RegExp(`\\{${safe}\\}`, 'g'), String(replacement));
|
|
118
|
-
}
|
|
119
|
-
return result;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return typeof value === 'string' ? value : key;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
addTranslation(lang: string, newTranslations: TranslationData): void {
|
|
126
|
-
if (isForbiddenKey(lang)) {
|
|
127
|
-
console.warn(`[VidPly] Refusing to register language with forbidden name "${lang}"`);
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
if (!this.translations[lang]) {
|
|
131
|
-
this.translations[lang] = Object.create(null) as TranslationData;
|
|
132
|
-
}
|
|
133
|
-
const sanitized = deepSanitizeTranslations(newTranslations);
|
|
134
|
-
Object.assign(this.translations[lang], sanitized);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Load a translation file from a URL. Bounded by an `AbortSignal.timeout`
|
|
139
|
-
* (default 8s) plus an optional caller-supplied signal — typically the
|
|
140
|
-
* Player's lifecycle controller — so a torn-down player does not keep
|
|
141
|
-
* the request alive.
|
|
142
|
-
*/
|
|
143
|
-
async loadLanguageFromUrl(
|
|
144
|
-
langCode: string,
|
|
145
|
-
url: string,
|
|
146
|
-
options: { signal?: AbortSignal; timeoutMs?: number } = {}
|
|
147
|
-
): Promise<unknown> {
|
|
148
|
-
if (this.loadingPromises.has(url)) {
|
|
149
|
-
return this.loadingPromises.get(url);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const loadPromise = (async () => {
|
|
153
|
-
const signals: AbortSignal[] = [];
|
|
154
|
-
if (options.signal) signals.push(options.signal);
|
|
155
|
-
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
156
|
-
signals.push(AbortSignal.timeout(options.timeoutMs ?? 8000));
|
|
157
|
-
}
|
|
158
|
-
const signal = signals.length === 0
|
|
159
|
-
? undefined
|
|
160
|
-
: signals.length === 1
|
|
161
|
-
? signals[0]
|
|
162
|
-
: (AbortSignal as { any?: (sigs: AbortSignal[]) => AbortSignal }).any?.(signals) ?? signals[0];
|
|
163
|
-
|
|
164
|
-
try {
|
|
165
|
-
const response = await fetch(url, { signal });
|
|
166
|
-
if (!response.ok) {
|
|
167
|
-
throw new Error(`Failed to load language file: ${response.statusText}`);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const contentType = response.headers.get('content-type') || '';
|
|
171
|
-
let loadedTranslations: TranslationData;
|
|
172
|
-
|
|
173
|
-
const buffer = await response.arrayBuffer();
|
|
174
|
-
const utf8Text = new TextDecoder('utf-8').decode(buffer);
|
|
175
|
-
|
|
176
|
-
if (contentType.includes('application/json') || url.endsWith('.json')) {
|
|
177
|
-
loadedTranslations = JSON.parse(utf8Text);
|
|
178
|
-
} else if (
|
|
179
|
-
contentType.includes('text/yaml') ||
|
|
180
|
-
contentType.includes('application/x-yaml') ||
|
|
181
|
-
url.endsWith('.yaml') ||
|
|
182
|
-
url.endsWith('.yml')
|
|
183
|
-
) {
|
|
184
|
-
try {
|
|
185
|
-
loadedTranslations = JSON.parse(utf8Text);
|
|
186
|
-
} catch {
|
|
187
|
-
if (typeof window !== 'undefined' && window.jsyaml) {
|
|
188
|
-
loadedTranslations = window.jsyaml.load(utf8Text) as TranslationData;
|
|
189
|
-
} else {
|
|
190
|
-
console.warn('YAML parsing requires js-yaml library. Please include it or use JSON format.');
|
|
191
|
-
throw new Error('YAML parsing not available. Please use JSON format or include js-yaml library.');
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
} else {
|
|
195
|
-
loadedTranslations = JSON.parse(utf8Text);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
this.addTranslation(langCode, loadedTranslations);
|
|
199
|
-
return loadedTranslations;
|
|
200
|
-
} catch (error) {
|
|
201
|
-
console.error(`Error loading language file from ${url}:`, error);
|
|
202
|
-
throw error;
|
|
203
|
-
} finally {
|
|
204
|
-
this.loadingPromises.delete(url);
|
|
205
|
-
}
|
|
206
|
-
})();
|
|
207
|
-
|
|
208
|
-
this.loadingPromises.set(url, loadPromise);
|
|
209
|
-
return loadPromise;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
async loadLanguagesFromUrls(
|
|
213
|
-
languageMap: Record<string, string>,
|
|
214
|
-
options: { signal?: AbortSignal; timeoutMs?: number } = {}
|
|
215
|
-
): Promise<void> {
|
|
216
|
-
const promises = Object.entries(languageMap).map(([langCode, url]) =>
|
|
217
|
-
this.loadLanguageFromUrl(langCode, url, options)
|
|
218
|
-
);
|
|
219
|
-
await Promise.all(promises);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export const i18n = new I18n();
|
|
1
|
+
import { getBaseTranslations, getBuiltInLanguageLoaders, loadBuiltInTranslation, type TranslationData } from './translations.js';
|
|
2
|
+
import { deepSanitize, isForbiddenKey } from '../utils/Sanitize.js';
|
|
3
|
+
|
|
4
|
+
declare global {
|
|
5
|
+
interface Window {
|
|
6
|
+
jsyaml?: { load(input: string): unknown };
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Escape RegExp meta-characters so a placeholder name like
|
|
12
|
+
* `{name.first}` does not turn into a wildcard or backreference when
|
|
13
|
+
* compiled into a `RegExp`.
|
|
14
|
+
*/
|
|
15
|
+
function escapeRegExp(input: string): string {
|
|
16
|
+
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Recursively copy a translation object onto a null-prototype map and
|
|
21
|
+
* drop forbidden keys (`__proto__`, `prototype`, `constructor`) so a
|
|
22
|
+
* malicious translation file cannot pollute Object.prototype on its way
|
|
23
|
+
* through `Object.assign`.
|
|
24
|
+
*/
|
|
25
|
+
function deepSanitizeTranslations(input: unknown): TranslationData {
|
|
26
|
+
return deepSanitize<TranslationData>(input);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class I18n {
|
|
30
|
+
currentLanguage: string;
|
|
31
|
+
translations: Record<string, TranslationData>;
|
|
32
|
+
private loadingPromises: Map<string, Promise<unknown>>;
|
|
33
|
+
private builtInLanguageLoaders: Record<string, () => Promise<Record<string, TranslationData>>>;
|
|
34
|
+
|
|
35
|
+
constructor() {
|
|
36
|
+
this.currentLanguage = 'en';
|
|
37
|
+
this.translations = Object.create(null) as Record<string, TranslationData>;
|
|
38
|
+
Object.assign(this.translations, getBaseTranslations());
|
|
39
|
+
this.loadingPromises = new Map();
|
|
40
|
+
this.builtInLanguageLoaders = getBuiltInLanguageLoaders();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setLanguage(lang: string): void {
|
|
44
|
+
if (this.translations[lang]) {
|
|
45
|
+
this.currentLanguage = lang;
|
|
46
|
+
} else {
|
|
47
|
+
console.warn(`Language "${lang}" not found, falling back to English`);
|
|
48
|
+
this.currentLanguage = 'en';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getLanguage(): string {
|
|
53
|
+
return this.currentLanguage;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async ensureLanguage(lang: string): Promise<string | null> {
|
|
57
|
+
const normalizedLang = (lang || '').toLowerCase();
|
|
58
|
+
if (!normalizedLang) return this.currentLanguage;
|
|
59
|
+
|
|
60
|
+
if (this.translations[normalizedLang]) {
|
|
61
|
+
return normalizedLang;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (this.loadingPromises.has(normalizedLang)) {
|
|
65
|
+
await this.loadingPromises.get(normalizedLang);
|
|
66
|
+
return this.translations[normalizedLang] ? normalizedLang : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!this.builtInLanguageLoaders[normalizedLang]) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const loadPromise = (async () => {
|
|
74
|
+
try {
|
|
75
|
+
const loaded = await loadBuiltInTranslation(normalizedLang);
|
|
76
|
+
if (loaded) {
|
|
77
|
+
this.translations[normalizedLang] = deepSanitizeTranslations(loaded);
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.warn(`Language "${normalizedLang}" failed to load:`, error);
|
|
81
|
+
} finally {
|
|
82
|
+
this.loadingPromises.delete(normalizedLang);
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
|
|
86
|
+
this.loadingPromises.set(normalizedLang, loadPromise);
|
|
87
|
+
await loadPromise;
|
|
88
|
+
|
|
89
|
+
return this.translations[normalizedLang] ? normalizedLang : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
t(key: string, replacements: Record<string, string | number> = {}): string {
|
|
93
|
+
const keys = key.split('.');
|
|
94
|
+
let value: unknown = this.translations[this.currentLanguage];
|
|
95
|
+
|
|
96
|
+
for (const k of keys) {
|
|
97
|
+
if (value && typeof value === 'object' && k in (value as Record<string, unknown>)) {
|
|
98
|
+
value = (value as Record<string, unknown>)[k];
|
|
99
|
+
} else {
|
|
100
|
+
value = this.translations.en;
|
|
101
|
+
for (const fallbackKey of keys) {
|
|
102
|
+
if (value && typeof value === 'object' && fallbackKey in (value as Record<string, unknown>)) {
|
|
103
|
+
value = (value as Record<string, unknown>)[fallbackKey];
|
|
104
|
+
} else {
|
|
105
|
+
return key;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof value === 'string') {
|
|
113
|
+
let result = value;
|
|
114
|
+
for (const [placeholder, replacement] of Object.entries(replacements)) {
|
|
115
|
+
if (isForbiddenKey(placeholder)) continue;
|
|
116
|
+
const safe = escapeRegExp(placeholder);
|
|
117
|
+
result = result.replace(new RegExp(`\\{${safe}\\}`, 'g'), String(replacement));
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return typeof value === 'string' ? value : key;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
addTranslation(lang: string, newTranslations: TranslationData): void {
|
|
126
|
+
if (isForbiddenKey(lang)) {
|
|
127
|
+
console.warn(`[VidPly] Refusing to register language with forbidden name "${lang}"`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!this.translations[lang]) {
|
|
131
|
+
this.translations[lang] = Object.create(null) as TranslationData;
|
|
132
|
+
}
|
|
133
|
+
const sanitized = deepSanitizeTranslations(newTranslations);
|
|
134
|
+
Object.assign(this.translations[lang], sanitized);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Load a translation file from a URL. Bounded by an `AbortSignal.timeout`
|
|
139
|
+
* (default 8s) plus an optional caller-supplied signal — typically the
|
|
140
|
+
* Player's lifecycle controller — so a torn-down player does not keep
|
|
141
|
+
* the request alive.
|
|
142
|
+
*/
|
|
143
|
+
async loadLanguageFromUrl(
|
|
144
|
+
langCode: string,
|
|
145
|
+
url: string,
|
|
146
|
+
options: { signal?: AbortSignal; timeoutMs?: number } = {}
|
|
147
|
+
): Promise<unknown> {
|
|
148
|
+
if (this.loadingPromises.has(url)) {
|
|
149
|
+
return this.loadingPromises.get(url);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const loadPromise = (async () => {
|
|
153
|
+
const signals: AbortSignal[] = [];
|
|
154
|
+
if (options.signal) signals.push(options.signal);
|
|
155
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
156
|
+
signals.push(AbortSignal.timeout(options.timeoutMs ?? 8000));
|
|
157
|
+
}
|
|
158
|
+
const signal = signals.length === 0
|
|
159
|
+
? undefined
|
|
160
|
+
: signals.length === 1
|
|
161
|
+
? signals[0]
|
|
162
|
+
: (AbortSignal as { any?: (sigs: AbortSignal[]) => AbortSignal }).any?.(signals) ?? signals[0];
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const response = await fetch(url, { signal });
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new Error(`Failed to load language file: ${response.statusText}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const contentType = response.headers.get('content-type') || '';
|
|
171
|
+
let loadedTranslations: TranslationData;
|
|
172
|
+
|
|
173
|
+
const buffer = await response.arrayBuffer();
|
|
174
|
+
const utf8Text = new TextDecoder('utf-8').decode(buffer);
|
|
175
|
+
|
|
176
|
+
if (contentType.includes('application/json') || url.endsWith('.json')) {
|
|
177
|
+
loadedTranslations = JSON.parse(utf8Text);
|
|
178
|
+
} else if (
|
|
179
|
+
contentType.includes('text/yaml') ||
|
|
180
|
+
contentType.includes('application/x-yaml') ||
|
|
181
|
+
url.endsWith('.yaml') ||
|
|
182
|
+
url.endsWith('.yml')
|
|
183
|
+
) {
|
|
184
|
+
try {
|
|
185
|
+
loadedTranslations = JSON.parse(utf8Text);
|
|
186
|
+
} catch {
|
|
187
|
+
if (typeof window !== 'undefined' && window.jsyaml) {
|
|
188
|
+
loadedTranslations = window.jsyaml.load(utf8Text) as TranslationData;
|
|
189
|
+
} else {
|
|
190
|
+
console.warn('YAML parsing requires js-yaml library. Please include it or use JSON format.');
|
|
191
|
+
throw new Error('YAML parsing not available. Please use JSON format or include js-yaml library.');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
loadedTranslations = JSON.parse(utf8Text);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.addTranslation(langCode, loadedTranslations);
|
|
199
|
+
return loadedTranslations;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.error(`Error loading language file from ${url}:`, error);
|
|
202
|
+
throw error;
|
|
203
|
+
} finally {
|
|
204
|
+
this.loadingPromises.delete(url);
|
|
205
|
+
}
|
|
206
|
+
})();
|
|
207
|
+
|
|
208
|
+
this.loadingPromises.set(url, loadPromise);
|
|
209
|
+
return loadPromise;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async loadLanguagesFromUrls(
|
|
213
|
+
languageMap: Record<string, string>,
|
|
214
|
+
options: { signal?: AbortSignal; timeoutMs?: number } = {}
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
const promises = Object.entries(languageMap).map(([langCode, url]) =>
|
|
217
|
+
this.loadLanguageFromUrl(langCode, url, options)
|
|
218
|
+
);
|
|
219
|
+
await Promise.all(promises);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const i18n = new I18n();
|