Add live DE/EN language switching without restart (v3.8.6)

Introduce a centralized renderer text dictionary with German and English locales, add a language selector in settings, and apply translations instantly across UI sections and dynamic status labels without requiring app restart.
This commit is contained in:
xRangerDE 2026-02-14 05:31:28 +01:00
parent 59b507115c
commit 7fb7e4b03b
11 changed files with 473 additions and 113 deletions

View File

@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
"version": "3.8.5",
"version": "3.8.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "3.8.5",
"version": "3.8.6",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
"version": "3.8.5",
"version": "3.8.6",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "xRangerDE",

View File

@ -93,29 +93,29 @@
<aside class="sidebar">
<div class="logo">
<svg viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
Twitch VOD Manager
<span id="logoText">Twitch VOD Manager</span>
</div>
<nav class="nav">
<div class="nav-item active" data-tab="vods" onclick="showTab('vods')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
Twitch VODs
<span id="navVodsText">Twitch VODs</span>
</div>
<div class="nav-item" data-tab="clips" onclick="showTab('clips')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
Twitch Clips
<span id="navClipsText">Twitch Clips</span>
</div>
<div class="nav-item" data-tab="cutter" onclick="showTab('cutter')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg>
Video schneiden
<span id="navCutterText">Video schneiden</span>
</div>
<div class="nav-item" data-tab="merge" onclick="showTab('merge')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg>
Videos Zusammenfugen
<span id="navMergeText">Videos zusammenfugen</span>
</div>
<div class="nav-item" data-tab="settings" onclick="showTab('settings')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
Einstellungen
<span id="navSettingsText">Einstellungen</span>
</div>
</nav>
@ -124,13 +124,13 @@
<div class="queue-section">
<div class="queue-header">
<span class="queue-title">Warteschlange</span>
<span class="queue-title" id="queueTitleText">Warteschlange</span>
<span class="queue-count" id="queueCount">0</span>
</div>
<div class="queue-list" id="queueList"></div>
<div class="queue-actions">
<button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
<button class="btn btn-clear" onclick="clearCompleted()">Leeren</button>
<button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
</div>
</div>
</aside>
@ -145,7 +145,7 @@
</div>
<button class="btn-icon" onclick="refreshVODs()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
Aktualisieren
<span id="refreshText">Aktualisieren</span>
</button>
</div>
</header>
@ -165,18 +165,19 @@
<!-- Clips Tab -->
<div class="tab-content" id="clipsTab">
<div class="clip-input">
<h2>Twitch Clip-Download</h2>
<h2 id="clipsHeading">Twitch Clip-Download</h2>
<input type="text" id="clipUrl" placeholder="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...">
<button class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
<div class="clip-status" id="clipStatus"></div>
</div>
<div class="settings-card" style="max-width: 600px; margin: 20px auto;">
<h3>Info</h3>
<p style="color: var(--text-secondary); line-height: 1.6;">
Unterstutzte Formate:<br>
- https://clips.twitch.tv/ClipName<br>
- https://www.twitch.tv/streamer/clip/ClipName<br><br>
<h3 id="clipsInfoTitle">Info</h3>
<p style="color: var(--text-secondary); line-height: 1.6; white-space: pre-line;" id="clipsInfoText">
Unterstutzte Formate:
- https://clips.twitch.tv/ClipName
- https://www.twitch.tv/streamer/clip/ClipName
Clips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.
</p>
</div>
@ -186,10 +187,10 @@
<div class="tab-content" id="cutterTab">
<div class="cutter-container">
<div class="settings-card">
<h3>Video auswahlen</h3>
<h3 id="cutterSelectTitle">Video auswahlen</h3>
<div class="form-row">
<input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt...">
<button class="btn-secondary" onclick="selectCutterVideo()">Durchsuchen</button>
<button class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
</div>
</div>
@ -254,12 +255,12 @@
<div class="tab-content" id="mergeTab">
<div class="merge-container">
<div class="settings-card">
<h3>Videos zusammenfugen</h3>
<p style="color: var(--text-secondary); margin-bottom: 15px;">
<h3 id="mergeTitle">Videos zusammenfugen</h3>
<p style="color: var(--text-secondary); margin-bottom: 15px;" id="mergeDesc">
Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen.
Die Reihenfolge kann per Drag & Drop geandert werden.
</p>
<button class="btn-secondary" onclick="addMergeFiles()">+ Videos hinzufugen</button>
<button class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button>
</div>
<div class="file-list" id="mergeFileList">
@ -285,9 +286,9 @@
<!-- Settings Tab -->
<div class="tab-content" id="settingsTab">
<div class="settings-card">
<h3>Design</h3>
<h3 id="designTitle">Design</h3>
<div class="form-group">
<label>Theme</label>
<label id="themeLabel">Theme</label>
<select id="themeSelect" onchange="changeTheme(this.value)">
<option value="twitch">Twitch</option>
<option value="discord">Discord</option>
@ -295,48 +296,55 @@
<option value="apple">Apple</option>
</select>
</div>
<div class="form-group">
<label id="languageLabel">Sprache</label>
<select id="languageSelect" onchange="changeLanguage(this.value)">
<option value="de" id="languageDeText">Deutsch</option>
<option value="en" id="languageEnText">Englisch</option>
</select>
</div>
</div>
<div class="settings-card">
<h3>Twitch API</h3>
<h3 id="apiTitle">Twitch API</h3>
<div class="form-group">
<label>Client ID</label>
<label id="clientIdLabel">Client ID</label>
<input type="text" id="clientId" placeholder="Twitch Client ID">
</div>
<div class="form-group">
<label>Client Secret</label>
<label id="clientSecretLabel">Client Secret</label>
<input type="password" id="clientSecret" placeholder="Twitch Client Secret">
</div>
<button class="btn-primary" onclick="saveSettings()">Speichern & Verbinden</button>
<button class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
</div>
<div class="settings-card">
<h3>Download-Einstellungen</h3>
<h3 id="downloadSettingsTitle">Download-Einstellungen</h3>
<div class="form-group">
<label>Speicherort</label>
<label id="storageLabel">Speicherort</label>
<div class="form-row">
<input type="text" id="downloadPath" readonly>
<button class="btn-secondary" onclick="selectFolder()">Ordner</button>
<button class="btn-secondary" onclick="openFolder()">Offnen</button>
<button class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
</div>
</div>
<div class="form-group">
<label>Download-Modus</label>
<label id="modeLabel">Download-Modus</label>
<select id="downloadMode">
<option value="full">Ganzes VOD</option>
<option value="parts">In Teile splitten</option>
<option value="full" id="modeFullText">Ganzes VOD</option>
<option value="parts" id="modePartsText">In Teile splitten</option>
</select>
</div>
<div class="form-group">
<label>Teil-Lange (Minuten)</label>
<label id="partMinutesLabel">Teil-Lange (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480">
</div>
</div>
<div class="settings-card">
<h3>Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v3.8.5</p>
<button class="btn-secondary" onclick="checkUpdate()">Nach Updates suchen</button>
<h3 id="updateTitle">Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v3.8.6</p>
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
</div>
</div>
@ -346,11 +354,12 @@
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span>
</div>
<span id="versionText">v3.8.5</span>
<span id="versionText">v3.8.6</span>
</div>
</main>
</div>
<script src="../dist/renderer-texts.js"></script>
<script src="../dist/renderer-shared.js"></script>
<script src="../dist/renderer-settings.js"></script>
<script src="../dist/renderer-streamers.js"></script>

View File

@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
// ==========================================
// CONFIG & CONSTANTS
// ==========================================
const APP_VERSION = '3.8.5';
const APP_VERSION = '3.8.6';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths
@ -43,6 +43,7 @@ interface Config {
theme: string;
download_mode: 'parts' | 'full';
part_minutes: number;
language: 'de' | 'en';
}
interface VOD {
@ -116,7 +117,8 @@ const defaultConfig: Config = {
streamers: [],
theme: 'twitch',
download_mode: 'full',
part_minutes: 120
part_minutes: 120,
language: 'de'
};
function loadConfig(): Config {

View File

@ -6,6 +6,7 @@ interface AppConfig {
theme?: string;
download_mode?: 'parts' | 'full';
part_minutes?: number;
language?: 'de' | 'en';
[key: string]: unknown;
}

View File

@ -20,18 +20,18 @@ async function clearCompleted(): Promise<void> {
}
function getQueueStatusLabel(item: QueueItem): string {
if (item.status === 'completed') return 'Abgeschlossen';
if (item.status === 'error') return 'Fehlgeschlagen';
if (item.status === 'downloading') return 'Laeuft';
return 'Wartet';
if (item.status === 'completed') return UI_TEXT.queue.statusDone;
if (item.status === 'error') return UI_TEXT.queue.statusFailed;
if (item.status === 'downloading') return UI_TEXT.queue.statusRunning;
return UI_TEXT.queue.statusWaiting;
}
function getQueueProgressText(item: QueueItem): string {
if (item.status === 'completed') return '100%';
if (item.status === 'error') return 'Fehler';
if (item.status === 'pending') return 'Bereit';
if (item.status === 'error') return UI_TEXT.queue.progressError;
if (item.status === 'pending') return UI_TEXT.queue.progressReady;
if (item.progress > 0) return `${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`;
return item.progressStatus || 'Lade...';
return item.progressStatus || UI_TEXT.queue.progressLoading;
}
function getQueueMetaText(item: QueueItem): string {
@ -42,31 +42,31 @@ function getQueueMetaText(item: QueueItem): string {
const parts: string[] = [];
if (item.currentPart && item.totalParts) {
parts.push(`Teil ${item.currentPart}/${item.totalParts}`);
parts.push(`${UI_TEXT.queue.part} ${item.currentPart}/${item.totalParts}`);
}
if (item.speed) {
parts.push(`Geschwindigkeit: ${item.speed}`);
parts.push(`${UI_TEXT.queue.speed}: ${item.speed}`);
}
if (item.eta) {
parts.push(`Restzeit: ${item.eta}`);
parts.push(`${UI_TEXT.queue.eta}: ${item.eta}`);
}
if (!parts.length && item.status === 'pending') {
parts.push('Bereit zum Download');
parts.push(UI_TEXT.queue.readyToDownload);
}
if (!parts.length && item.status === 'downloading') {
parts.push(item.progressStatus || 'Download gestartet');
parts.push(item.progressStatus || UI_TEXT.queue.started);
}
if (!parts.length && item.status === 'completed') {
parts.push('Fertig');
parts.push(UI_TEXT.queue.done);
}
if (!parts.length && item.status === 'error') {
parts.push('Download fehlgeschlagen');
parts.push(UI_TEXT.queue.failed);
}
return parts.join(' | ');
@ -81,12 +81,12 @@ function renderQueue(): void {
byId('queueCount').textContent = String(queue.length);
if (queue.length === 0) {
list.innerHTML = '<div style="color: var(--text-secondary); font-size: 12px; text-align: center; padding: 15px;">Keine Downloads in der Warteschlange</div>';
list.innerHTML = `<div style="color: var(--text-secondary); font-size: 12px; text-align: center; padding: 15px;">${UI_TEXT.queue.empty}</div>`;
return;
}
list.innerHTML = queue.map((item: QueueItem) => {
const safeTitle = escapeHtml(item.title || 'Untitled');
const safeTitle = escapeHtml(item.title || UI_TEXT.vods.untitled);
const safeStatusLabel = escapeHtml(getQueueStatusLabel(item));
const safeProgressText = escapeHtml(getQueueProgressText(item));
const safeMeta = escapeHtml(getQueueMetaText(item));
@ -126,6 +126,6 @@ async function toggleDownload(): Promise<void> {
const started = await window.api.startDownload();
if (!started) {
renderQueue();
alert('Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.');
alert(UI_TEXT.queue.emptyAlert);
}
}

View File

@ -2,14 +2,14 @@ async function connect(): Promise<void> {
const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim());
if (!hasCredentials) {
isConnected = false;
updateStatus('Ohne Login (Public Modus)', false);
updateStatus(UI_TEXT.status.noLogin, false);
return;
}
updateStatus('Verbinde...', false);
updateStatus(UI_TEXT.status.connecting, false);
const success = await window.api.login();
isConnected = success;
updateStatus(success ? 'Verbunden' : 'Verbindung fehlgeschlagen - Public Modus aktiv', success);
updateStatus(success ? UI_TEXT.status.connected : UI_TEXT.status.connectFailedPublic, success);
}
function updateStatus(text: string, connected: boolean): void {
@ -19,6 +19,22 @@ function updateStatus(text: string, connected: boolean): void {
dot.classList.add(connected ? 'connected' : 'error');
}
function changeLanguage(lang: string): void {
const normalized = setLanguage(lang);
byId<HTMLSelectElement>('languageSelect').value = normalized;
config.language = normalized;
void window.api.saveConfig({ language: normalized });
const currentStatus = byId('statusText').textContent?.trim() || '';
updateStatus(localizeCurrentStatusText(currentStatus), isConnected);
renderQueue();
renderStreamers();
if (!currentStreamer) {
byId('pageTitle').textContent = UI_TEXT.tabs.vods;
}
}
async function saveSettings(): Promise<void> {
const clientId = byId<HTMLInputElement>('clientId').value.trim();
const clientSecret = byId<HTMLInputElement>('clientSecret').value.trim();

View File

@ -43,8 +43,8 @@ async function removeStreamer(name: string): Promise<void> {
byId('vodGrid').innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<h3>Keine VODs</h3>
<p>Wahle einen Streamer aus der Liste.</p>
<h3>${UI_TEXT.vods.noneTitle}</h3>
<p>${UI_TEXT.vods.noneText}</p>
</div>
`;
}
@ -59,14 +59,14 @@ async function selectStreamer(name: string): Promise<void> {
}
if (!isConnected) {
updateStatus('Ohne Login (Public Modus)', false);
updateStatus(UI_TEXT.status.noLogin, false);
}
byId('vodGrid').innerHTML = '<div class="empty-state"><p>Lade VODs...</p></div>';
byId('vodGrid').innerHTML = `<div class="empty-state"><p>${UI_TEXT.vods.loading}</p></div>`;
const userId = await window.api.getUserId(name);
if (!userId) {
byId('vodGrid').innerHTML = '<div class="empty-state"><h3>Streamer nicht gefunden</h3></div>';
byId('vodGrid').innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.notFound}</h3></div>`;
return;
}
@ -78,7 +78,7 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
const grid = byId('vodGrid');
if (!vods || vods.length === 0) {
grid.innerHTML = '<div class="empty-state"><h3>Keine VODs gefunden</h3><p>Dieser Streamer hat keine VODs.</p></div>';
grid.innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.noResultsTitle}</h3><p>${UI_TEXT.vods.noResultsText}</p></div>`;
return;
}
@ -86,7 +86,7 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
const date = new Date(vod.created_at).toLocaleDateString('de-DE');
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '&quot;');
const safeDisplayTitle = escapeHtml(vod.title || 'Unbenanntes VOD');
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
return `
<div class="vod-card">
@ -96,12 +96,12 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
<div class="vod-meta">
<span>${date}</span>
<span>${vod.duration}</span>
<span>${vod.view_count.toLocaleString()} Aufrufe</span>
<span>${vod.view_count.toLocaleString()} ${UI_TEXT.vods.views}</span>
</div>
</div>
<div class="vod-actions">
<button class="vod-btn secondary" onclick="openClipDialog('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">Clip</button>
<button class="vod-btn primary" onclick="addToQueue('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">+ Warteschlange</button>
<button class="vod-btn primary" onclick="addToQueue('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">${UI_TEXT.vods.addQueue}</button>
</div>
</div>
`;

View File

@ -0,0 +1,335 @@
type LanguageCode = 'de' | 'en';
const UI_TEXTS = {
de: {
appName: 'Twitch VOD Manager',
static: {
navVods: 'Twitch VODs',
navClips: 'Twitch Clips',
navCutter: 'Video schneiden',
navMerge: 'Videos zusammenfugen',
navSettings: 'Einstellungen',
queueTitle: 'Warteschlange',
clearQueue: 'Leeren',
refresh: 'Aktualisieren',
streamerPlaceholder: 'Streamer hinzufugen...',
clipsHeading: 'Twitch Clip-Download',
clipsInfoTitle: 'Info',
clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.',
cutterSelectTitle: 'Video auswahlen',
cutterBrowse: 'Durchsuchen',
mergeTitle: 'Videos zusammenfugen',
mergeDesc: 'Wahle mehrere Videos aus, um sie zu einem Video zusammenzufugen. Die Reihenfolge kann geandert werden.',
mergeAdd: '+ Videos hinzufugen',
designTitle: 'Design',
themeLabel: 'Theme',
languageLabel: 'Sprache',
languageDe: 'Deutsch',
languageEn: 'Englisch',
apiTitle: 'Twitch API',
clientIdLabel: 'Client ID',
clientSecretLabel: 'Client Secret',
saveSettings: 'Speichern & Verbinden',
downloadSettingsTitle: 'Download-Einstellungen',
storageLabel: 'Speicherort',
openFolder: 'Offnen',
modeLabel: 'Download-Modus',
modeFull: 'Ganzes VOD',
modeParts: 'In Teile splitten',
partMinutesLabel: 'Teil-Lange (Minuten)',
updateTitle: 'Updates',
checkUpdates: 'Nach Updates suchen',
notConnected: 'Nicht verbunden'
},
status: {
noLogin: 'Ohne Login (Public Modus)',
connecting: 'Verbinde...',
connected: 'Verbunden',
connectFailedPublic: 'Verbindung fehlgeschlagen - Public Modus aktiv'
},
tabs: {
vods: 'VODs',
clips: 'Clips',
cutter: 'Video schneiden',
merge: 'Videos zusammenfugen',
settings: 'Einstellungen'
},
queue: {
empty: 'Keine Downloads in der Warteschlange',
start: 'Start',
stop: 'Stoppen',
statusDone: 'Abgeschlossen',
statusFailed: 'Fehlgeschlagen',
statusRunning: 'Laeuft',
statusWaiting: 'Wartet',
progressError: 'Fehler',
progressReady: 'Bereit',
progressLoading: 'Lade...',
readyToDownload: 'Bereit zum Download',
started: 'Download gestartet',
done: 'Fertig',
failed: 'Download fehlgeschlagen',
speed: 'Geschwindigkeit',
eta: 'Restzeit',
part: 'Teil',
emptyAlert: 'Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.'
},
vods: {
noneTitle: 'Keine VODs',
noneText: 'Wahle einen Streamer aus der Liste.',
loading: 'Lade VODs...',
notFound: 'Streamer nicht gefunden',
noResultsTitle: 'Keine VODs gefunden',
noResultsText: 'Dieser Streamer hat keine VODs.',
untitled: 'Unbenanntes VOD',
views: 'Aufrufe',
addQueue: '+ Warteschlange'
},
clips: {
dialogTitle: 'Clip zuschneiden',
invalidDuration: 'Ungultig!',
endBeforeStart: 'Endzeit muss grosser als Startzeit sein!',
outOfRange: 'Zeit ausserhalb des VOD-Bereichs!',
enterUrl: 'Bitte URL eingeben',
loadingButton: 'Lade...',
loadingStatus: 'Download laeuft...',
downloadButton: 'Clip herunterladen',
success: 'Download erfolgreich!',
errorPrefix: 'Fehler: ',
unknownError: 'Unbekannter Fehler',
formatSimple: '(Standard)',
formatTimestamp: '(mit Zeitstempel)'
},
cutter: {
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
previewLoading: 'Lade Vorschau...',
previewUnavailable: 'Vorschau nicht verfugbar',
cutting: 'Schneidet...',
cut: 'Schneiden',
cutSuccess: 'Video erfolgreich geschnitten!',
cutFailed: 'Fehler beim Schneiden des Videos.'
},
merge: {
empty: 'Keine Videos ausgewahlt',
merging: 'Zusammenfugen...',
merge: 'Zusammenfugen',
success: 'Videos erfolgreich zusammengefugt!',
failed: 'Fehler beim Zusammenfugen der Videos.'
},
updates: {
latest: 'Du hast die neueste Version!',
downloading: 'Wird heruntergeladen...',
available: 'verfugbar!',
downloadNow: 'Jetzt herunterladen',
downloadLabel: 'Download',
ready: 'bereit zur Installation!',
installNow: 'Jetzt installieren'
}
},
en: {
appName: 'Twitch VOD Manager',
static: {
navVods: 'Twitch VODs',
navClips: 'Twitch Clips',
navCutter: 'Video Cutter',
navMerge: 'Merge Videos',
navSettings: 'Settings',
queueTitle: 'Queue',
clearQueue: 'Clear',
refresh: 'Refresh',
streamerPlaceholder: 'Add streamer...',
clipsHeading: 'Twitch Clip Download',
clipsInfoTitle: 'Info',
clipsInfoText: 'Supported formats:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips are saved in your download folder under "Clips/StreamerName/".',
cutterSelectTitle: 'Select video',
cutterBrowse: 'Browse',
mergeTitle: 'Merge videos',
mergeDesc: 'Select multiple videos to merge into one file. You can change the order before merging.',
mergeAdd: '+ Add videos',
designTitle: 'Design',
themeLabel: 'Theme',
languageLabel: 'Language',
languageDe: 'German',
languageEn: 'English',
apiTitle: 'Twitch API',
clientIdLabel: 'Client ID',
clientSecretLabel: 'Client Secret',
saveSettings: 'Save & Connect',
downloadSettingsTitle: 'Download Settings',
storageLabel: 'Storage Path',
openFolder: 'Open',
modeLabel: 'Download Mode',
modeFull: 'Full VOD',
modeParts: 'Split into parts',
partMinutesLabel: 'Part Length (Minutes)',
updateTitle: 'Updates',
checkUpdates: 'Check for updates',
notConnected: 'Not connected'
},
status: {
noLogin: 'No login (public mode)',
connecting: 'Connecting...',
connected: 'Connected',
connectFailedPublic: 'Connection failed - public mode active'
},
tabs: {
vods: 'VODs',
clips: 'Clips',
cutter: 'Video Cutter',
merge: 'Merge Videos',
settings: 'Settings'
},
queue: {
empty: 'No downloads in queue',
start: 'Start',
stop: 'Stop',
statusDone: 'Completed',
statusFailed: 'Failed',
statusRunning: 'Running',
statusWaiting: 'Waiting',
progressError: 'Error',
progressReady: 'Ready',
progressLoading: 'Loading...',
readyToDownload: 'Ready to download',
started: 'Download started',
done: 'Done',
failed: 'Download failed',
speed: 'Speed',
eta: 'ETA',
part: 'Part',
emptyAlert: 'Queue is empty. Add a VOD or clip first.'
},
vods: {
noneTitle: 'No VODs',
noneText: 'Select a streamer from the list.',
loading: 'Loading VODs...',
notFound: 'Streamer not found',
noResultsTitle: 'No VODs found',
noResultsText: 'This streamer has no VODs.',
untitled: 'Untitled VOD',
views: 'views',
addQueue: '+ Queue'
},
clips: {
dialogTitle: 'Trim clip',
invalidDuration: 'Invalid!',
endBeforeStart: 'End time must be greater than start time!',
outOfRange: 'Time is outside VOD range!',
enterUrl: 'Please enter a URL',
loadingButton: 'Loading...',
loadingStatus: 'Downloading...',
downloadButton: 'Download clip',
success: 'Download successful!',
errorPrefix: 'Error: ',
unknownError: 'Unknown error',
formatSimple: '(default)',
formatTimestamp: '(with timestamp)'
},
cutter: {
videoInfoFailed: 'Could not read video info. Is FFprobe installed?',
previewLoading: 'Loading preview...',
previewUnavailable: 'Preview unavailable',
cutting: 'Cutting...',
cut: 'Cut',
cutSuccess: 'Video cut successfully!',
cutFailed: 'Failed to cut video.'
},
merge: {
empty: 'No videos selected',
merging: 'Merging...',
merge: 'Merge',
success: 'Videos merged successfully!',
failed: 'Failed to merge videos.'
},
updates: {
latest: 'You are on the latest version!',
downloading: 'Downloading...',
available: 'available!',
downloadNow: 'Download now',
downloadLabel: 'Download',
ready: 'ready to install!',
installNow: 'Install now'
}
}
} as const;
let currentLanguage: LanguageCode = 'de';
let UI_TEXT: (typeof UI_TEXTS)[LanguageCode] = UI_TEXTS[currentLanguage];
function setText(id: string, value: string): void {
const node = document.getElementById(id);
if (node) node.textContent = value;
}
function setPlaceholder(id: string, value: string): void {
const node = document.getElementById(id) as HTMLInputElement | null;
if (node) node.placeholder = value;
}
function setLanguage(lang: string): LanguageCode {
currentLanguage = lang === 'en' ? 'en' : 'de';
UI_TEXT = UI_TEXTS[currentLanguage];
applyLanguageToStaticUI();
return currentLanguage;
}
function applyLanguageToStaticUI(): void {
setText('logoText', UI_TEXT.appName);
setText('navVodsText', UI_TEXT.static.navVods);
setText('navClipsText', UI_TEXT.static.navClips);
setText('navCutterText', UI_TEXT.static.navCutter);
setText('navMergeText', UI_TEXT.static.navMerge);
setText('navSettingsText', UI_TEXT.static.navSettings);
setText('queueTitleText', UI_TEXT.static.queueTitle);
setText('btnClear', UI_TEXT.static.clearQueue);
setText('refreshText', UI_TEXT.static.refresh);
setText('clipsHeading', UI_TEXT.static.clipsHeading);
setText('clipsInfoTitle', UI_TEXT.static.clipsInfoTitle);
setText('clipsInfoText', UI_TEXT.static.clipsInfoText);
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
setText('mergeTitle', UI_TEXT.static.mergeTitle);
setText('mergeDesc', UI_TEXT.static.mergeDesc);
setText('mergeAddBtn', UI_TEXT.static.mergeAdd);
setText('designTitle', UI_TEXT.static.designTitle);
setText('themeLabel', UI_TEXT.static.themeLabel);
setText('languageLabel', UI_TEXT.static.languageLabel);
setText('languageDeText', UI_TEXT.static.languageDe);
setText('languageEnText', UI_TEXT.static.languageEn);
setText('apiTitle', UI_TEXT.static.apiTitle);
setText('clientIdLabel', UI_TEXT.static.clientIdLabel);
setText('clientSecretLabel', UI_TEXT.static.clientSecretLabel);
setText('saveSettingsBtn', UI_TEXT.static.saveSettings);
setText('downloadSettingsTitle', UI_TEXT.static.downloadSettingsTitle);
setText('storageLabel', UI_TEXT.static.storageLabel);
setText('openFolderBtn', UI_TEXT.static.openFolder);
setText('modeLabel', UI_TEXT.static.modeLabel);
setText('modeFullText', UI_TEXT.static.modeFull);
setText('modePartsText', UI_TEXT.static.modeParts);
setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel);
setText('updateTitle', UI_TEXT.static.updateTitle);
setText('checkUpdateBtn', UI_TEXT.static.checkUpdates);
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
const status = document.getElementById('statusText')?.textContent?.trim() || '';
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {
setText('statusText', UI_TEXT.static.notConnected);
}
}
function localizeCurrentStatusText(current: string): string {
const map: Record<string, keyof typeof UI_TEXT.status> = {
[UI_TEXTS.de.status.noLogin]: 'noLogin',
[UI_TEXTS.en.status.noLogin]: 'noLogin',
[UI_TEXTS.de.status.connecting]: 'connecting',
[UI_TEXTS.en.status.connecting]: 'connecting',
[UI_TEXTS.de.status.connected]: 'connected',
[UI_TEXTS.en.status.connected]: 'connected',
[UI_TEXTS.de.status.connectFailedPublic]: 'connectFailedPublic',
[UI_TEXTS.en.status.connectFailedPublic]: 'connectFailedPublic'
};
const key = map[current];
return key ? UI_TEXT.status[key] : current;
}

View File

@ -7,7 +7,7 @@ async function checkUpdate(): Promise<void> {
setTimeout(() => {
if (byId('updateBanner').style.display !== 'flex') {
alert('Du hast die neueste Version!');
alert(UI_TEXT.updates.latest);
}
}, 2000);
}
@ -18,7 +18,7 @@ function downloadUpdate(): void {
return;
}
byId('updateButton').textContent = 'Wird heruntergeladen...';
byId('updateButton').textContent = UI_TEXT.updates.downloading;
byId('updateButton').disabled = true;
byId('updateProgress').style.display = 'block';
byId('updateProgressBar').classList.add('downloading');
@ -27,8 +27,8 @@ function downloadUpdate(): void {
window.api.onUpdateAvailable((info: UpdateInfo) => {
byId('updateBanner').style.display = 'flex';
byId('updateText').textContent = `Version ${info.version} verfügbar!`;
byId('updateButton').textContent = 'Jetzt herunterladen';
byId('updateText').textContent = `Version ${info.version} ${UI_TEXT.updates.available}`;
byId('updateButton').textContent = UI_TEXT.updates.downloadNow;
});
window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
@ -38,7 +38,7 @@ window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
const mb = (progress.transferred / 1024 / 1024).toFixed(1);
const totalMb = (progress.total / 1024 / 1024).toFixed(1);
byId('updateText').textContent = `Download: ${mb} / ${totalMb} MB (${progress.percent.toFixed(0)}%)`;
byId('updateText').textContent = `${UI_TEXT.updates.downloadLabel}: ${mb} / ${totalMb} MB (${progress.percent.toFixed(0)}%)`;
});
window.api.onUpdateDownloaded((info: UpdateInfo) => {
@ -48,7 +48,7 @@ window.api.onUpdateDownloaded((info: UpdateInfo) => {
bar.classList.remove('downloading');
bar.style.width = '100%';
byId('updateText').textContent = `Version ${info.version} bereit zur Installation!`;
byId('updateButton').textContent = 'Jetzt installieren';
byId('updateText').textContent = `Version ${info.version} ${UI_TEXT.updates.ready}`;
byId('updateButton').textContent = UI_TEXT.updates.installNow;
byId('updateButton').disabled = false;
});

View File

@ -1,17 +1,20 @@
async function init(): Promise<void> {
config = await window.api.getConfig();
const language = setLanguage((config.language as string) || 'de');
config.language = language;
const initialQueue = await window.api.getQueue();
queue = Array.isArray(initialQueue) ? initialQueue : [];
const version = await window.api.getVersion();
byId('versionText').textContent = `v${version}`;
byId('versionInfo').textContent = `Version: v${version}`;
document.title = `Twitch VOD Manager v${version}`;
document.title = `${UI_TEXT.appName} v${version}`;
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
byId<HTMLInputElement>('downloadPath').value = config.download_path ?? '';
byId<HTMLSelectElement>('themeSelect').value = config.theme ?? 'twitch';
byId<HTMLSelectElement>('languageSelect').value = config.language ?? 'de';
byId<HTMLSelectElement>('downloadMode').value = config.download_mode ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String(config.part_minutes ?? 120);
@ -66,7 +69,7 @@ async function init(): Promise<void> {
if (config.client_id && config.client_secret) {
await connect();
} else {
updateStatus('Ohne Login (Public Modus)', false);
updateStatus(UI_TEXT.status.noLogin, false);
}
if (config.streamers && config.streamers.length > 0) {
@ -111,7 +114,7 @@ function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] {
function updateDownloadButtonState(): void {
const btn = byId('btnStart');
btn.textContent = downloading ? 'Stoppen' : 'Start';
btn.textContent = downloading ? UI_TEXT.queue.stop : UI_TEXT.queue.start;
btn.classList.toggle('downloading', downloading);
}
@ -134,15 +137,9 @@ function showTab(tab: string): void {
query(`.nav-item[data-tab="${tab}"]`).classList.add('active');
byId(tab + 'Tab').classList.add('active');
const titles: Record<string, string> = {
vods: 'VODs',
clips: 'Clips',
cutter: 'Video schneiden',
merge: 'Videos Zusammenfugen',
settings: 'Einstellungen'
};
const titles: Record<string, string> = UI_TEXT.tabs;
byId('pageTitle').textContent = currentStreamer || titles[tab] || 'Twitch VOD Manager';
byId('pageTitle').textContent = currentStreamer || titles[tab] || UI_TEXT.appName;
}
function parseDurationToSeconds(durStr: string): number {
@ -185,7 +182,7 @@ function openClipDialog(url: string, title: string, date: string, streamer: stri
clipDialogData = { url, title, date, streamer, duration };
clipTotalSeconds = parseDurationToSeconds(duration);
byId('clipDialogTitle').textContent = `Clip zuschneiden (${duration})`;
byId('clipDialogTitle').textContent = `${UI_TEXT.clips.dialogTitle} (${duration})`;
byId<HTMLInputElement>('clipStartSlider').max = String(clipTotalSeconds);
byId<HTMLInputElement>('clipEndSlider').max = String(clipTotalSeconds);
byId<HTMLInputElement>('clipStartSlider').value = '0';
@ -241,7 +238,7 @@ function updateClipDuration(): void {
durationDisplay.textContent = formatSecondsToTime(duration);
durationDisplay.style.color = '#00c853';
} else {
durationDisplay.textContent = 'Ungultig!';
durationDisplay.textContent = UI_TEXT.clips.invalidDuration;
durationDisplay.style.color = '#ff4444';
}
@ -259,8 +256,8 @@ function updateFilenameExamples(): void {
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
const timeStr = formatSecondsToTimeDashed(startSec);
byId('formatSimple').textContent = `${dateStr}_${partNum}.mp4 (Standard)`;
byId('formatTimestamp').textContent = `${dateStr}_CLIP_${timeStr}_${partNum}.mp4 (mit Zeitstempel)`;
byId('formatSimple').textContent = `${dateStr}_${partNum}.mp4 ${UI_TEXT.clips.formatSimple}`;
byId('formatTimestamp').textContent = `${dateStr}_CLIP_${timeStr}_${partNum}.mp4 ${UI_TEXT.clips.formatTimestamp}`;
}
async function confirmClipDialog(): Promise<void> {
@ -275,12 +272,12 @@ async function confirmClipDialog(): Promise<void> {
const filenameFormat = query<HTMLInputElement>('input[name="filenameFormat"]:checked').value as 'simple' | 'timestamp';
if (endSec <= startSec) {
alert('Endzeit muss grosser als Startzeit sein!');
alert(UI_TEXT.clips.endBeforeStart);
return;
}
if (startSec < 0 || endSec > clipTotalSeconds) {
alert('Zeit ausserhalb des VOD-Bereichs!');
alert(UI_TEXT.clips.outOfRange);
return;
}
@ -310,28 +307,28 @@ async function downloadClip(): Promise<void> {
const btn = byId('btnClip');
if (!url) {
status.textContent = 'Bitte URL eingeben';
status.textContent = UI_TEXT.clips.enterUrl;
status.className = 'clip-status error';
return;
}
btn.disabled = true;
btn.textContent = 'Lade...';
status.textContent = 'Download lauft...';
btn.textContent = UI_TEXT.clips.loadingButton;
status.textContent = UI_TEXT.clips.loadingStatus;
status.className = 'clip-status loading';
const result = await window.api.downloadClip(url);
btn.disabled = false;
btn.textContent = 'Clip herunterladen';
btn.textContent = UI_TEXT.clips.downloadButton;
if (result.success) {
status.textContent = 'Download erfolgreich!';
status.textContent = UI_TEXT.clips.success;
status.className = 'clip-status success';
return;
}
status.textContent = 'Fehler: ' + (result.error || 'Unbekannter Fehler');
status.textContent = UI_TEXT.clips.errorPrefix + (result.error || UI_TEXT.clips.unknownError);
status.className = 'clip-status error';
}
@ -346,7 +343,7 @@ async function selectCutterVideo(): Promise<void> {
const info = await window.api.getVideoInfo(filePath);
if (!info) {
alert('Konnte Video-Informationen nicht lesen. FFprobe installiert?');
alert(UI_TEXT.cutter.videoInfoFailed);
return;
}
@ -436,7 +433,7 @@ async function updatePreview(time: number): Promise<void> {
}
const preview = byId('cutterPreview');
preview.innerHTML = '<div class="placeholder"><p>Lade Vorschau...</p></div>';
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewLoading}</p></div>`;
const frame = await window.api.extractFrame(cutterFile, time);
if (frame) {
@ -444,7 +441,7 @@ async function updatePreview(time: number): Promise<void> {
return;
}
preview.innerHTML = '<div class="placeholder"><p>Vorschau nicht verfugbar</p></div>';
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewUnavailable}</p></div>`;
}
async function startCutting(): Promise<void> {
@ -454,22 +451,22 @@ async function startCutting(): Promise<void> {
isCutting = true;
byId('btnCut').disabled = true;
byId('btnCut').textContent = 'Schneidet...';
byId('btnCut').textContent = UI_TEXT.cutter.cutting;
byId('cutProgress').classList.add('show');
const result = await window.api.cutVideo(cutterFile, cutterStartTime, cutterEndTime);
isCutting = false;
byId('btnCut').disabled = false;
byId('btnCut').textContent = 'Schneiden';
byId('btnCut').textContent = UI_TEXT.cutter.cut;
byId('cutProgress').classList.remove('show');
if (result.success) {
alert('Video erfolgreich geschnitten!\n\n' + result.outputFile);
alert(`${UI_TEXT.cutter.cutSuccess}\n\n${result.outputFile}`);
return;
}
alert('Fehler beim Schneiden des Videos.');
alert(UI_TEXT.cutter.cutFailed);
}
async function addMergeFiles(): Promise<void> {
@ -490,7 +487,7 @@ function renderMergeFiles(): void {
list.innerHTML = `
<div class="empty-state" style="padding: 40px 20px;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<p style="margin-top:10px">Keine Videos ausgewahlt</p>
<p style="margin-top:10px">${UI_TEXT.merge.empty}</p>
</div>
`;
return;
@ -541,24 +538,24 @@ async function startMerging(): Promise<void> {
isMerging = true;
byId('btnMerge').disabled = true;
byId('btnMerge').textContent = 'Zusammenfugen...';
byId('btnMerge').textContent = UI_TEXT.merge.merging;
byId('mergeProgress').classList.add('show');
const result = await window.api.mergeVideos(mergeFiles, outputFile);
isMerging = false;
byId('btnMerge').disabled = false;
byId('btnMerge').textContent = 'Zusammenfugen';
byId('btnMerge').textContent = UI_TEXT.merge.merge;
byId('mergeProgress').classList.remove('show');
if (result.success) {
alert('Videos erfolgreich zusammengefugt!\n\n' + result.outputFile);
alert(`${UI_TEXT.merge.success}\n\n${result.outputFile}`);
mergeFiles = [];
renderMergeFiles();
return;
}
alert('Fehler beim Zusammenfugen der Videos.');
alert(UI_TEXT.merge.failed);
}
void init();