Cast: card/list views, sort & filter, online voice picker, "Hear a line" sample button, AI character notes, import auto-save. Platform: WCAG 2.1 AA accessibility pass; German UI translation + language picker; installable PWA with offline shell; GZip + content-visibility virtualization + lazy images + Rehearser PCM memory cap (mobile stability); Playwright suite (desktop + iPhone); opt-in minified bundle build. Fixes: screenplay parser false characters; Fish-Speech inline-tag tones; narrator/voice pickers list full library; clone GUI rework; fish.audio import dedup; voice-ID rename; bulk-delete modal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
127 lines
7.2 KiB
JavaScript
127 lines
7.2 KiB
JavaScript
// ── Lightweight i18n ─────────────────────────────────────────────────────────
|
||
// English is the source language (keys = the exact English UI text). Switching
|
||
// language persists the choice and reloads, so translations are applied once to the
|
||
// freshly-rendered English DOM (no fragile reverse-translation / re-render tracking).
|
||
//
|
||
// Coverage v1: the static UI chrome (nav, section titles/subtitles, common
|
||
// buttons/labels). Add more by adding "English": "translation" pairs below, or call
|
||
// window.t('English') for JS-generated strings. Dynamic lists can be translated by
|
||
// calling window.applyI18n(container) after rendering.
|
||
|
||
const I18N_LANGS = { en: 'English', de: 'Deutsch' };
|
||
|
||
const I18N_DICT = {
|
||
de: {
|
||
// Brand / sidebar groups
|
||
'Voice Creator': 'Voice Creator',
|
||
'Clone · Design · Deploy': 'Klonen · Entwerfen · Bereitstellen',
|
||
'Voices': 'Stimmen', 'Setup': 'Einrichtung', 'Tags': 'Tags',
|
||
// Sidebar nav
|
||
'My Voices': 'Meine Stimmen', 'All voices': 'Alle Stimmen', 'Cloned': 'Geklont',
|
||
'Designed': 'Entworfen', 'Favorites': 'Favoriten', 'Hidden': 'Ausgeblendet',
|
||
'Library tools': 'Bibliothekswerkzeuge',
|
||
'Clone a Voice': 'Stimme klonen', 'Design a Voice': 'Stimme entwerfen',
|
||
'Get Voices Online': 'Stimmen online holen', 'Try It Out': 'Ausprobieren',
|
||
'Script Rehearser': 'Skript-Probe', 'Library': 'Bibliothek', 'Cast': 'Besetzung',
|
||
'Stage': 'Bühne', 'Summary': 'Zusammenfassung', 'Import / Export': 'Import / Export',
|
||
'Conversation': 'Unterhaltung', 'Benchmark': 'Benchmark', 'Engines': 'Engines',
|
||
'Language Models': 'Sprachmodelle', 'Speech to Text': 'Sprache-zu-Text',
|
||
'Text to Speech': 'Text-zu-Sprache', 'App Routing': 'App-Routing',
|
||
'Connect Apps': 'Apps verbinden', 'Settings': 'Einstellungen',
|
||
// Section titles
|
||
'Conversation Playground': 'Unterhaltungs-Spielplatz',
|
||
// Section subtitles
|
||
'Pick a voice on the left, edit on the right.': 'Wähle links eine Stimme, bearbeite sie rechts.',
|
||
'Capture 3–20 seconds of clean speech, trim it, name it, then save it as a reusable voice clone.':
|
||
'Nimm 3–20 Sekunden saubere Sprache auf, schneide sie zu, benenne sie und speichere sie als wiederverwendbaren Stimmklon.',
|
||
'Describe a voice in words and let the AI create it. No recording needed.':
|
||
'Beschreibe eine Stimme in Worten und lass sie von der KI erstellen. Keine Aufnahme nötig.',
|
||
'Browse public voice clip sources, preview direct audio files, and import voices from the web.':
|
||
'Durchsuche öffentliche Stimm-Quellen, höre Audiodateien vorab an und importiere Stimmen aus dem Web.',
|
||
'Generate speech from text using any backend and voice. Also transcribe audio and re-speak it.':
|
||
'Erzeuge Sprache aus Text mit beliebigem Backend und Stimme. Transkribiere Audio und sprich es neu ein.',
|
||
'Upload a script, cast characters to TTS voices or your own mic, then rehearse scene by scene.':
|
||
'Lade ein Skript hoch, besetze Figuren mit TTS-Stimmen oder deinem Mikrofon und probe Szene für Szene.',
|
||
// Common buttons / labels
|
||
'Save changes': 'Änderungen speichern', 'Save to library': 'In Bibliothek speichern',
|
||
'Save to Voice Library': 'In Stimmbibliothek speichern', 'Delete voice': 'Stimme löschen',
|
||
'Cancel': 'Abbrechen', 'Refresh': 'Aktualisieren', 'Delete': 'Löschen', 'Save': 'Speichern',
|
||
'Back': 'Zurück', 'Apply →': 'Anwenden →', 'Browse': 'Durchsuchen', 'Download': 'Herunterladen',
|
||
'Record': 'Aufnehmen', 'Stop': 'Stopp', 'Play': 'Abspielen', 'Play selection': 'Auswahl abspielen',
|
||
'Check level': 'Pegel prüfen', 'Stop monitor': 'Monitor stoppen', 'Auto trim': 'Auto-Zuschnitt',
|
||
'Auto-transcribe': 'Auto-Transkription', 'Active': 'Aktiv', 'copy ID': 'ID kopieren',
|
||
'edit ID': 'ID bearbeiten', 'New voice': 'Neue Stimme', 'All languages': 'Alle Sprachen',
|
||
'All genders': 'Alle Geschlechter', 'Microphone': 'Mikrofon', 'Upload file': 'Datei hochladen',
|
||
'Sort': 'Sortieren', 'Cards': 'Karten', 'List': 'Liste', 'Develop': 'Ausarbeiten',
|
||
'Match local': 'Lokal zuordnen', 'Match online': 'Online zuordnen', 'Design all': 'Alle entwerfen',
|
||
'I play this': 'Ich spiele das', 'Save to library': 'In Bibliothek speichern',
|
||
'Open rehearsal': 'Probe öffnen', 'Fetch voices': 'Stimmen abrufen',
|
||
'Name your voice': 'Benenne deine Stimme', 'Reference transcript': 'Referenz-Transkript',
|
||
'Preview': 'Vorschau', 'Trim your sample': 'Probe zuschneiden', 'Language': 'Sprache',
|
||
'Gender': 'Geschlecht', 'Voice': 'Stimme', 'Tags': 'Tags', 'Speaking style · voice-design prompt': 'Sprechstil · Voice-Design-Prompt',
|
||
// Common placeholders
|
||
'Search voices…': 'Stimmen suchen…', 'Search voices...': 'Stimmen suchen...',
|
||
'Filter name or tag…': 'Name oder Tag filtern…',
|
||
},
|
||
};
|
||
|
||
let _appLang = 'en';
|
||
try { _appLang = localStorage.getItem('app-lang') || 'en'; } catch (_) {}
|
||
window.__APP_LANG__ = _appLang;
|
||
|
||
// Translate a single source string (for JS-generated UI).
|
||
window.t = function (s) {
|
||
if (_appLang === 'en' || s == null) return s;
|
||
const d = I18N_DICT[_appLang];
|
||
return (d && d[String(s).trim()] != null) ? d[String(s).trim()] : s;
|
||
};
|
||
|
||
const _I18N_SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'CODE', 'PRE', 'OPTION']);
|
||
|
||
// Translate visible text nodes + common attributes within `root` (default: whole doc).
|
||
window.applyI18n = function (root) {
|
||
if (_appLang === 'en') return;
|
||
const dict = I18N_DICT[_appLang]; if (!dict) return;
|
||
root = root || document.body; if (!root) return;
|
||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||
acceptNode(n) {
|
||
if (!n.nodeValue || !n.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
|
||
const p = n.parentNode;
|
||
if (p && _I18N_SKIP.has(p.nodeName)) return NodeFilter.FILTER_REJECT;
|
||
return NodeFilter.FILTER_ACCEPT;
|
||
},
|
||
});
|
||
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
|
||
for (const n of nodes) {
|
||
const key = n.nodeValue.trim();
|
||
if (dict[key] != null) n.nodeValue = n.nodeValue.replace(key, dict[key]);
|
||
}
|
||
root.querySelectorAll('[placeholder],[title],[aria-label]').forEach(el => {
|
||
['placeholder', 'title', 'aria-label'].forEach(a => {
|
||
const v = el.getAttribute(a);
|
||
if (v && dict[v.trim()] != null) el.setAttribute(a, dict[v.trim()]);
|
||
});
|
||
});
|
||
};
|
||
|
||
// Language change → persist + reload (translations re-apply to fresh English DOM).
|
||
window.setAppLang = function (lang) {
|
||
if (!I18N_LANGS[lang]) return;
|
||
try { localStorage.setItem('app-lang', lang); } catch (_) {}
|
||
location.reload();
|
||
};
|
||
|
||
// Inject the picker into the sidebar brand once the DOM is ready.
|
||
window.initLangPicker = function () {
|
||
const brand = document.querySelector('.sidebar-brand');
|
||
if (!brand || document.getElementById('app-lang-picker')) return;
|
||
const sel = document.createElement('select');
|
||
sel.id = 'app-lang-picker';
|
||
sel.className = 'app-lang-picker';
|
||
sel.setAttribute('aria-label', 'Interface language');
|
||
sel.innerHTML = Object.entries(I18N_LANGS)
|
||
.map(([code, name]) => `<option value="${code}"${code === _appLang ? ' selected' : ''}>${name}</option>`).join('');
|
||
sel.addEventListener('change', () => window.setAppLang(sel.value));
|
||
brand.appendChild(sel);
|
||
};
|