feat: trim-VOD dialog i18n + Twitch API help link + log file shortcut

Three small UX wins.

1. Trim-VOD dialog: every inner label was hardcoded German in
   index.html (Start:, Ende:, Startzeit (HH:MM:SS):, Dauer:, Start
   Part-Nummer..., Leer lassen = Teil 1, Dateinamen-Format:, Zur
   Queue hinzufuegen). EN-mode users had a German dialog. Each
   element now has an id + setText wiring + DE/EN locale strings.

2. Settings -> Twitch API card now opens with a help line + link
   to dev.twitch.tv/console/apps. Uses window.api.openExternal so
   the link opens in the user's default browser instead of the
   Electron renderer (which has nodeIntegration off / no native
   navigation). Fixes the "no idea how to set this up" first-run
   friction.

3. Settings -> Live Debug Log gets an "Open log file" button next
   to Refresh. Uses a new ipcMain handle (open-debug-log-file ->
   shell.showItemInFolder on DEBUG_LOG_FILE) so users no longer
   have to navigate manually to ProgramData. As a small defensive
   bundle:
   - get-debug-log: lines parameter capped at [1, 5000] so a
     misbehaving renderer (or future feature) cannot ask main to
     slice millions of lines.
   - export-runtime-metrics: now uses writeFileAtomicSync (the
     fsync+rename helper from cycle 1) instead of plain
     writeFileSync so a power loss mid-export cannot leave a
     half-written metrics file at the user-chosen path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 13:33:10 +02:00
parent 9dcdb8086e
commit 16d2456770
9 changed files with 78 additions and 11 deletions

View File

@ -53,12 +53,12 @@
<!-- Start Zeit mit Slider --> <!-- Start Zeit mit Slider -->
<div style="margin-bottom: 15px;"> <div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px;">Start:</label> <label id="clipDialogStartLabel" style="display: block; margin-bottom: 5px;">Start:</label>
<input type="range" id="clipStartSlider" min="0" max="100" value="0" <input type="range" id="clipStartSlider" min="0" max="100" value="0"
style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;" style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;"
oninput="updateFromSlider('start')"> oninput="updateFromSlider('start')">
<div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;"> <div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;">
<label style="color: #888;">Startzeit (HH:MM:SS):</label> <label id="clipDialogStartTimeLabel" style="color: #888;">Startzeit (HH:MM:SS):</label>
<input type="text" id="clipStartTime" value="00:00:00" <input type="text" id="clipStartTime" value="00:00:00"
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 6px 10px; color: white; font-family: monospace; text-align: center;" style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 6px 10px; color: white; font-family: monospace; text-align: center;"
onchange="updateFromInput('start')"> onchange="updateFromInput('start')">
@ -67,12 +67,12 @@
<!-- End Zeit mit Slider --> <!-- End Zeit mit Slider -->
<div style="margin-bottom: 15px;"> <div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px;">Ende:</label> <label id="clipDialogEndLabel" style="display: block; margin-bottom: 5px;">Ende:</label>
<input type="range" id="clipEndSlider" min="0" max="100" value="60" <input type="range" id="clipEndSlider" min="0" max="100" value="60"
style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;" style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;"
oninput="updateFromSlider('end')"> oninput="updateFromSlider('end')">
<div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;"> <div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;">
<label style="color: #888;">Endzeit (HH:MM:SS):</label> <label id="clipDialogEndTimeLabel" style="color: #888;">Endzeit (HH:MM:SS):</label>
<input type="text" id="clipEndTime" value="00:01:00" <input type="text" id="clipEndTime" value="00:01:00"
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 6px 10px; color: white; font-family: monospace; text-align: center;" style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 6px 10px; color: white; font-family: monospace; text-align: center;"
onchange="updateFromInput('end')"> onchange="updateFromInput('end')">
@ -81,22 +81,22 @@
<!-- Dauer Anzeige --> <!-- Dauer Anzeige -->
<div style="text-align: center; margin-bottom: 20px;"> <div style="text-align: center; margin-bottom: 20px;">
<span style="color: #888;">Dauer: </span> <span id="clipDialogDurationLabel" style="color: #888;">Dauer: </span>
<span id="clipDurationDisplay" style="color: #00c853;">00:01:00</span> <span id="clipDurationDisplay" style="color: #00c853;">00:01:00</span>
</div> </div>
<!-- Teil Nummer --> <!-- Teil Nummer -->
<div style="margin-bottom: 15px;"> <div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 8px;">Start Part-Nummer (optional, fur Fortsetzung):</label> <label id="clipDialogPartLabel" style="display: block; margin-bottom: 8px;">Start Part-Nummer (optional, fur Fortsetzung):</label>
<input type="text" id="clipStartPart" placeholder="z.B. 42" <input type="text" id="clipStartPart" placeholder="z.B. 42"
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white;" style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white;"
oninput="updateFilenameExamples()"> oninput="updateFilenameExamples()">
<div style="color: #888; font-size: 12px; margin-top: 5px;">Leer lassen = Teil 1</div> <div id="clipDialogPartHint" style="color: #888; font-size: 12px; margin-top: 5px;">Leer lassen = Teil 1</div>
</div> </div>
<!-- Dateinamen Format --> <!-- Dateinamen Format -->
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 10px;">Dateinamen-Format:</label> <label id="clipDialogFormatLabel" style="display: block; margin-bottom: 10px;">Dateinamen-Format:</label>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;"> <label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
<input type="radio" name="filenameFormat" value="simple" checked onchange="updateFilenameExamples()" <input type="radio" name="filenameFormat" value="simple" checked onchange="updateFilenameExamples()"
style="width: 18px; height: 18px; accent-color: #9146FF;"> style="width: 18px; height: 18px; accent-color: #9146FF;">
@ -131,7 +131,7 @@
<!-- Button --> <!-- Button -->
<div style="text-align: center;"> <div style="text-align: center;">
<button class="btn-primary" style="background: #00c853; padding: 12px 30px; border: none; border-radius: 4px; color: white; font-weight: 600; cursor: pointer;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button> <button class="btn-primary" id="clipDialogConfirmBtn" style="background: #00c853; padding: 12px 30px; border: none; border-radius: 4px; color: white; font-weight: 600; cursor: pointer;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
</div> </div>
</div> </div>
</div> </div>
@ -429,6 +429,10 @@
<div class="settings-card"> <div class="settings-card">
<h3 id="apiTitle">Twitch API</h3> <h3 id="apiTitle">Twitch API</h3>
<p id="apiHelpText" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">
<span id="apiHelpIntro">Du brauchst eine Client-ID und ein Client-Secret von Twitch.</span>
<a href="#" id="apiHelpLink" onclick="event.preventDefault(); openTwitchDevConsole()" style="color: var(--accent); text-decoration: underline; cursor: pointer;">dev.twitch.tv/console/apps</a>
</p>
<div class="form-group"> <div class="form-group">
<label id="clientIdLabel">Client ID</label> <label id="clientIdLabel">Client ID</label>
<input type="text" id="clientId" placeholder="Twitch Client ID"> <input type="text" id="clientId" placeholder="Twitch Client ID">
@ -541,6 +545,7 @@
<h3 id="debugLogTitle">Live Debug-Log</h3> <h3 id="debugLogTitle">Live Debug-Log</h3>
<div class="form-row" style="margin-bottom: 10px; align-items: center;"> <div class="form-row" style="margin-bottom: 10px; align-items: center;">
<button class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button> <button class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button>
<button class="btn-secondary" id="btnOpenDebugLogFile" onclick="openDebugLogFile()">Log-Datei oeffnen</button>
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);"> <label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
<input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)"> <input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)">
<span id="autoRefreshText">Auto-Refresh</span> <span id="autoRefreshText">Auto-Refresh</span>

View File

@ -4146,7 +4146,16 @@ ipcMain.handle('run-preflight', async (_, autoFix: boolean = false) => {
}); });
ipcMain.handle('get-debug-log', async (_, lines: number = 200) => { ipcMain.handle('get-debug-log', async (_, lines: number = 200) => {
return readDebugLog(lines); // Cap so a misbehaving renderer (or future feature) cannot ask the
// main process to slice millions of lines from a multi-MB log.
const safeLines = Number.isFinite(lines) ? Math.max(1, Math.min(5000, Math.floor(lines))) : 200;
return readDebugLog(safeLines);
});
ipcMain.handle('open-debug-log-file', (): boolean => {
if (!fs.existsSync(DEBUG_LOG_FILE)) return false;
shell.showItemInFolder(DEBUG_LOG_FILE);
return true;
}); });
ipcMain.handle('is-downloading', () => isDownloading); ipcMain.handle('is-downloading', () => isDownloading);
@ -4169,7 +4178,10 @@ ipcMain.handle('export-runtime-metrics', async () => {
} }
const snapshot = getRuntimeMetricsSnapshot(); const snapshot = getRuntimeMetricsSnapshot();
fs.writeFileSync(dialogResult.filePath, JSON.stringify(snapshot, null, 2), 'utf-8'); // Atomic write: same fsync+rename pattern used for config/queue
// (cycle 1) so a power loss mid-export can't leave a half-written
// metrics file at the user's chosen path.
writeFileAtomicSync(dialogResult.filePath, JSON.stringify(snapshot, null, 2));
return { success: true, filePath: dialogResult.filePath }; return { success: true, filePath: dialogResult.filePath };
} catch (e) { } catch (e) {
appendDebugLog('runtime-metrics-export-failed', String(e)); appendDebugLog('runtime-metrics-export-failed', String(e));

View File

@ -85,6 +85,7 @@ contextBridge.exposeInMainWorld('api', {
openFolder: (path: string) => ipcRenderer.invoke('open-folder', path), openFolder: (path: string) => ipcRenderer.invoke('open-folder', path),
openFile: (path: string) => ipcRenderer.invoke('open-file', path), openFile: (path: string) => ipcRenderer.invoke('open-file', path),
showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path), showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path),
openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'),
// Video Cutter // Video Cutter
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath), getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),

View File

@ -200,6 +200,7 @@ interface ApiBridge {
openFolder(path: string): Promise<void>; openFolder(path: string): Promise<void>;
openFile(path: string): Promise<boolean>; openFile(path: string): Promise<boolean>;
showInFolder(path: string): Promise<boolean>; showInFolder(path: string): Promise<boolean>;
openDebugLogFile(): Promise<boolean>;
getVideoInfo(filePath: string): Promise<VideoInfo | null>; getVideoInfo(filePath: string): Promise<VideoInfo | null>;
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>; extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>; cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;

View File

@ -51,6 +51,9 @@ const UI_TEXT_DE = {
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren', smartSchedulerLabel: 'Smart Queue Scheduler aktivieren',
smartSchedulerHint: 'Bevorzugt kuerzere VODs und aeltere Queue-Eintraege zuerst, damit der Durchsatz gleichmaessig bleibt. Deaktivieren = strikte Einfuegereihenfolge.', smartSchedulerHint: 'Bevorzugt kuerzere VODs und aeltere Queue-Eintraege zuerst, damit der Durchsatz gleichmaessig bleibt. Deaktivieren = strikte Einfuegereihenfolge.',
streamerInvalid: 'Twitch-Username ungueltig (4-25 Zeichen, Buchstaben/Zahlen/Unterstrich).', streamerInvalid: 'Twitch-Username ungueltig (4-25 Zeichen, Buchstaben/Zahlen/Unterstrich).',
apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.',
apiHelpLinkText: 'dev.twitch.tv/console/apps',
openDebugLogFile: 'Log-Datei oeffnen',
duplicatePreventionLabel: 'Duplikate in Queue verhindern', duplicatePreventionLabel: 'Duplikate in Queue verhindern',
persistQueueLabel: 'Queue zwischen App-Starts speichern', persistQueueLabel: 'Queue zwischen App-Starts speichern',
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)', metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
@ -190,6 +193,15 @@ const UI_TEXT_DE = {
}, },
clips: { clips: {
dialogTitle: 'VOD zuschneiden', dialogTitle: 'VOD zuschneiden',
dialogStart: 'Start:',
dialogStartTime: 'Startzeit (HH:MM:SS):',
dialogEnd: 'Ende:',
dialogEndTime: 'Endzeit (HH:MM:SS):',
dialogDuration: 'Dauer: ',
dialogPartLabel: 'Start Part-Nummer (optional, fur Fortsetzung):',
dialogPartHint: 'Leer lassen = Teil 1',
dialogFormatLabel: 'Dateinamen-Format:',
dialogConfirm: 'Zur Queue hinzufuegen',
invalidDuration: 'Ungultig!', invalidDuration: 'Ungultig!',
endBeforeStart: 'Endzeit muss grosser als Startzeit sein!', endBeforeStart: 'Endzeit muss grosser als Startzeit sein!',
outOfRange: 'Zeit ausserhalb des VOD-Bereichs!', outOfRange: 'Zeit ausserhalb des VOD-Bereichs!',

View File

@ -51,6 +51,9 @@ const UI_TEXT_EN = {
smartSchedulerLabel: 'Enable smart queue scheduler', smartSchedulerLabel: 'Enable smart queue scheduler',
smartSchedulerHint: 'Prefers shorter VODs and older queue entries first so the queue throughput stays steady. Disable to drain in strict insertion order.', smartSchedulerHint: 'Prefers shorter VODs and older queue entries first so the queue throughput stays steady. Disable to drain in strict insertion order.',
streamerInvalid: 'Invalid Twitch username (4-25 chars, letters/digits/underscore).', streamerInvalid: 'Invalid Twitch username (4-25 chars, letters/digits/underscore).',
apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.',
apiHelpLinkText: 'dev.twitch.tv/console/apps',
openDebugLogFile: 'Open log file',
duplicatePreventionLabel: 'Prevent duplicate queue entries', duplicatePreventionLabel: 'Prevent duplicate queue entries',
persistQueueLabel: 'Keep queue between app restarts', persistQueueLabel: 'Keep queue between app restarts',
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)', metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
@ -190,6 +193,15 @@ const UI_TEXT_EN = {
}, },
clips: { clips: {
dialogTitle: 'Trim VOD', dialogTitle: 'Trim VOD',
dialogStart: 'Start:',
dialogStartTime: 'Start time (HH:MM:SS):',
dialogEnd: 'End:',
dialogEndTime: 'End time (HH:MM:SS):',
dialogDuration: 'Duration: ',
dialogPartLabel: 'Start part number (optional, for continuation):',
dialogPartHint: 'Leave empty = part 1',
dialogFormatLabel: 'Filename format:',
dialogConfirm: 'Add to queue',
invalidDuration: 'Invalid!', invalidDuration: 'Invalid!',
endBeforeStart: 'End time must be greater than start time!', endBeforeStart: 'End time must be greater than start time!',
outOfRange: 'Time is outside VOD range!', outOfRange: 'Time is outside VOD range!',

View File

@ -265,6 +265,14 @@ async function runPreflight(autoFix = false): Promise<void> {
} }
} }
async function openDebugLogFile(): Promise<void> {
const ok = await window.api.openDebugLogFile();
if (!ok) {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast) toast('Debug log file not yet present.', 'warn');
}
}
async function refreshDebugLog(): Promise<void> { async function refreshDebugLog(): Promise<void> {
const text = await window.api.getDebugLog(250); const text = await window.api.getDebugLog(250);
const panel = byId('debugLogOutput'); const panel = byId('debugLogOutput');

View File

@ -61,6 +61,15 @@ function applyLanguageToStaticUI(): void {
setText('clipsInfoText', UI_TEXT.static.clipsInfoText); setText('clipsInfoText', UI_TEXT.static.clipsInfoText);
setText('clipTemplateHelp', UI_TEXT.clips.templateHelp); setText('clipTemplateHelp', UI_TEXT.clips.templateHelp);
setPlaceholder('clipFilenameTemplate', UI_TEXT.clips.templatePlaceholder); setPlaceholder('clipFilenameTemplate', UI_TEXT.clips.templatePlaceholder);
setText('clipDialogStartLabel', UI_TEXT.clips.dialogStart);
setText('clipDialogStartTimeLabel', UI_TEXT.clips.dialogStartTime);
setText('clipDialogEndLabel', UI_TEXT.clips.dialogEnd);
setText('clipDialogEndTimeLabel', UI_TEXT.clips.dialogEndTime);
setText('clipDialogDurationLabel', UI_TEXT.clips.dialogDuration);
setText('clipDialogPartLabel', UI_TEXT.clips.dialogPartLabel);
setText('clipDialogPartHint', UI_TEXT.clips.dialogPartHint);
setText('clipDialogFormatLabel', UI_TEXT.clips.dialogFormatLabel);
setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm);
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle); setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse); setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
setText('mergeTitle', UI_TEXT.static.mergeTitle); setText('mergeTitle', UI_TEXT.static.mergeTitle);
@ -73,6 +82,8 @@ function applyLanguageToStaticUI(): void {
setText('languageDeText', UI_TEXT.static.languageDe); setText('languageDeText', UI_TEXT.static.languageDe);
setText('languageEnText', UI_TEXT.static.languageEn); setText('languageEnText', UI_TEXT.static.languageEn);
setText('apiTitle', UI_TEXT.static.apiTitle); setText('apiTitle', UI_TEXT.static.apiTitle);
setText('apiHelpIntro', UI_TEXT.static.apiHelpIntro);
setText('apiHelpLink', UI_TEXT.static.apiHelpLinkText);
setText('clientIdLabel', UI_TEXT.static.clientIdLabel); setText('clientIdLabel', UI_TEXT.static.clientIdLabel);
setText('clientSecretLabel', UI_TEXT.static.clientSecretLabel); setText('clientSecretLabel', UI_TEXT.static.clientSecretLabel);
setText('saveSettingsBtn', UI_TEXT.static.saveSettings); setText('saveSettingsBtn', UI_TEXT.static.saveSettings);
@ -129,6 +140,7 @@ function applyLanguageToStaticUI(): void {
setText('preflightResult', UI_TEXT.static.preflightEmpty); setText('preflightResult', UI_TEXT.static.preflightEmpty);
setText('debugLogTitle', UI_TEXT.static.debugLogTitle); setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
setText('btnRefreshLog', UI_TEXT.static.refreshLog); setText('btnRefreshLog', UI_TEXT.static.refreshLog);
setText('btnOpenDebugLogFile', UI_TEXT.static.openDebugLogFile);
setText('autoRefreshText', UI_TEXT.static.autoRefresh); setText('autoRefreshText', UI_TEXT.static.autoRefresh);
setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle); setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle);
setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh); setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh);

View File

@ -208,6 +208,10 @@ async function init(): Promise<void> {
scheduleQueueSync(QUEUE_SYNC_DEFAULT_MS); scheduleQueueSync(QUEUE_SYNC_DEFAULT_MS);
} }
function openTwitchDevConsole(): void {
void window.api.openExternal('https://dev.twitch.tv/console/apps');
}
function closeTopmostOpenModal(): boolean { function closeTopmostOpenModal(): boolean {
// Try each known modal in priority order: clip dialog, template guide, update modal // Try each known modal in priority order: clip dialog, template guide, update modal
const clipModal = document.getElementById('clipModal'); const clipModal = document.getElementById('clipModal');