const QUEUE_SYNC_FAST_MS = 900; const QUEUE_SYNC_DEFAULT_MS = 1800; const QUEUE_SYNC_IDLE_MS = 4500; const QUEUE_SYNC_HIDDEN_MS = 9000; const QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS = 15000; async function init(): Promise { config = await window.api.getConfig(); const language = setLanguage((config.language as string) || 'en'); config.language = language; const initialQueue = await window.api.getQueue(); queue = Array.isArray(initialQueue) ? initialQueue : []; downloading = await window.api.isDownloading(); markQueueActivity(); const version = await window.api.getVersion(); byId('versionText').textContent = `v${version}`; byId('versionInfo').textContent = `Version: v${version}`; document.title = `${UI_TEXT.appName} v${version}`; byId('clientId').value = config.client_id ?? ''; byId('clientSecret').value = config.client_secret ?? ''; byId('downloadPath').value = config.download_path ?? ''; byId('themeSelect').value = config.theme ?? 'twitch'; byId('languageSelect').value = config.language ?? 'en'; updateLanguagePicker(config.language ?? 'en'); byId('downloadMode').value = config.download_mode ?? 'full'; byId('partMinutes').value = String(config.part_minutes ?? 120); byId('performanceMode').value = (config.performance_mode as string) || 'balanced'; byId('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false; byId('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false; byId('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10); byId('vodFilenameTemplate').value = (config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE; byId('partsFilenameTemplate').value = (config.filename_template_parts as string) || DEFAULT_PARTS_TEMPLATE; byId('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE; changeTheme(config.theme ?? 'twitch'); renderStreamers(); renderQueue(); updateDownloadButtonState(); window.api.onQueueUpdated((q: QueueItem[]) => { queue = mergeQueueState(Array.isArray(q) ? q : []); renderQueue(); markQueueActivity(); }); window.api.onQueueDuplicateSkipped((payload) => { const title = payload?.title ? ` (${payload.title})` : ''; showAppToast(`${UI_TEXT.queue.duplicateSkipped}${title}`, 'warn'); }); window.api.onDownloadProgress((progress: DownloadProgress) => { const item = queue.find((i: QueueItem) => i.id === progress.id); if (!item) { return; } item.status = 'downloading'; item.progress = progress.progress; item.speed = progress.speed; item.eta = progress.eta; item.currentPart = progress.currentPart; item.totalParts = progress.totalParts; item.downloadedBytes = progress.downloadedBytes; item.totalBytes = progress.totalBytes; item.progressStatus = progress.status; renderQueue(); markQueueActivity(); }); window.api.onDownloadStarted(() => { downloading = true; updateDownloadButtonState(); markQueueActivity(); }); window.api.onDownloadFinished(() => { downloading = false; updateDownloadButtonState(); markQueueActivity(); }); window.api.onCutProgress((percent: number) => { byId('cutProgressBar').style.width = percent + '%'; byId('cutProgressText').textContent = Math.round(percent) + '%'; }); window.api.onMergeProgress((percent: number) => { byId('mergeProgressBar').style.width = percent + '%'; byId('mergeProgressText').textContent = Math.round(percent) + '%'; }); if (config.client_id && config.client_secret) { await connect(); } else { updateStatus(UI_TEXT.status.noLogin, false); } if (config.streamers && config.streamers.length > 0) { await selectStreamer(config.streamers[0]); } setTimeout(() => { void checkUpdateSilent(); }, 3000); void runPreflight(false); void refreshDebugLog(); validateFilenameTemplates(); void refreshRuntimeMetrics(); document.addEventListener('visibilitychange', () => { scheduleQueueSync(document.hidden ? 600 : 150); }); scheduleQueueSync(QUEUE_SYNC_DEFAULT_MS); } let toastHideTimer: number | null = null; let queueSyncTimer: number | null = null; let queueSyncInFlight = false; let lastQueueActivityAt = Date.now(); function markQueueActivity(): void { lastQueueActivityAt = Date.now(); } function hasActiveQueueWork(): boolean { return queue.some((item) => item.status === 'pending' || item.status === 'downloading' || item.status === 'paused'); } function getNextQueueSyncDelayMs(): number { if (document.hidden) { return QUEUE_SYNC_HIDDEN_MS; } if (downloading || queue.some((item) => item.status === 'downloading')) { return QUEUE_SYNC_FAST_MS; } if (hasActiveQueueWork()) { return QUEUE_SYNC_DEFAULT_MS; } const idleForMs = Date.now() - lastQueueActivityAt; return idleForMs > QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS ? QUEUE_SYNC_IDLE_MS : QUEUE_SYNC_DEFAULT_MS; } function scheduleQueueSync(delayMs = getNextQueueSyncDelayMs()): void { if (queueSyncTimer) { clearTimeout(queueSyncTimer); queueSyncTimer = null; } queueSyncTimer = window.setTimeout(() => { queueSyncTimer = null; void runQueueSyncCycle(); }, Math.max(300, Math.floor(delayMs))); } async function runQueueSyncCycle(): Promise { if (queueSyncInFlight) { scheduleQueueSync(400); return; } queueSyncInFlight = true; try { await syncQueueAndDownloadState(); } catch { // ignore transient IPC errors and retry on next cycle } finally { queueSyncInFlight = false; scheduleQueueSync(); } } function showAppToast(message: string, type: 'info' | 'warn' = 'info'): void { let toast = document.getElementById('appToast'); if (!toast) { toast = document.createElement('div'); toast.id = 'appToast'; toast.className = 'app-toast'; document.body.appendChild(toast); } toast.textContent = message; toast.classList.remove('warn', 'show'); if (type === 'warn') { toast.classList.add('warn'); } requestAnimationFrame(() => { toast?.classList.add('show'); }); if (toastHideTimer) { clearTimeout(toastHideTimer); toastHideTimer = null; } toastHideTimer = window.setTimeout(() => { toast?.classList.remove('show'); }, 3200); } function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] { const prevById = new Map(queue.map((item) => [item.id, item])); return nextQueue.map((item) => { const prev = prevById.get(item.id); if (!prev) { return item; } if (item.status !== 'downloading') { return item; } return { ...item, progress: item.progress > 0 ? item.progress : prev.progress, speed: item.speed || prev.speed, eta: item.eta || prev.eta, currentPart: item.currentPart || prev.currentPart, totalParts: item.totalParts || prev.totalParts, downloadedBytes: item.downloadedBytes || prev.downloadedBytes, totalBytes: item.totalBytes || prev.totalBytes, progressStatus: item.progressStatus || prev.progressStatus }; }); } function getQueueStateFingerprint(items: QueueItem[]): string { return items.map((item) => [ item.id, item.status, Math.round((Number(item.progress) || 0) * 10), item.currentPart || 0, item.totalParts || 0, item.last_error || '', item.progressStatus || '' ].join(':')).join('|'); } function updateDownloadButtonState(): void { const btn = byId('btnStart'); const hasPaused = queue.some((item) => item.status === 'paused'); btn.textContent = downloading ? UI_TEXT.queue.stop : (hasPaused ? UI_TEXT.queue.resume : UI_TEXT.queue.start); btn.classList.toggle('downloading', downloading); } async function syncQueueAndDownloadState(): Promise { const previousFingerprint = getQueueStateFingerprint(queue); const latestQueue = await window.api.getQueue(); queue = mergeQueueState(Array.isArray(latestQueue) ? latestQueue : []); const nextFingerprint = getQueueStateFingerprint(queue); if (nextFingerprint !== previousFingerprint) { markQueueActivity(); } renderQueue(); const backendDownloading = await window.api.isDownloading(); if (backendDownloading !== downloading) { downloading = backendDownloading; updateDownloadButtonState(); } } function showTab(tab: string): void { queryAll('.nav-item').forEach((i) => i.classList.remove('active')); queryAll('.tab-content').forEach((c) => c.classList.remove('active')); query(`.nav-item[data-tab="${tab}"]`).classList.add('active'); byId(tab + 'Tab').classList.add('active'); const titles: Record = UI_TEXT.tabs; byId('pageTitle').textContent = currentStreamer || titles[tab] || UI_TEXT.appName; } function parseDurationToSeconds(durStr: string): number { let seconds = 0; const hours = durStr.match(/(\d+)h/); const minutes = durStr.match(/(\d+)m/); const secs = durStr.match(/(\d+)s/); if (hours) seconds += parseInt(hours[1], 10) * 3600; if (minutes) seconds += parseInt(minutes[1], 10) * 60; if (secs) seconds += parseInt(secs[1], 10); return seconds; } function formatSecondsToTime(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } function formatSecondsToTimeDashed(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`; } const DEFAULT_VOD_TEMPLATE = '{title}.mp4'; const DEFAULT_PARTS_TEMPLATE = '{date}_Part{part_padded}.mp4'; const DEFAULT_CLIP_TEMPLATE = '{date}_{part}.mp4'; type TemplateGuideSource = 'vod' | 'parts' | 'clip'; let templateGuideSource: TemplateGuideSource = 'vod'; function formatDateWithPattern(date: Date, pattern: string): string { const tokenMap: Record = { yyyy: date.getFullYear().toString(), yy: date.getFullYear().toString().slice(-2), MM: (date.getMonth() + 1).toString().padStart(2, '0'), M: (date.getMonth() + 1).toString(), dd: date.getDate().toString().padStart(2, '0'), d: date.getDate().toString(), HH: date.getHours().toString().padStart(2, '0'), H: date.getHours().toString(), hh: date.getHours().toString().padStart(2, '0'), h: date.getHours().toString(), mm: date.getMinutes().toString().padStart(2, '0'), m: date.getMinutes().toString(), ss: date.getSeconds().toString().padStart(2, '0'), s: date.getSeconds().toString() }; return pattern .replace(/yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token) .replace(/\\(.)/g, '$1'); } function formatSecondsWithPattern(totalSeconds: number, pattern: string): string { const safe = Math.max(0, Math.floor(totalSeconds)); const hours = Math.floor(safe / 3600); const minutes = Math.floor((safe % 3600) / 60); const seconds = safe % 60; const tokenMap: Record = { HH: hours.toString().padStart(2, '0'), H: hours.toString(), hh: hours.toString().padStart(2, '0'), h: hours.toString(), mm: minutes.toString().padStart(2, '0'), m: minutes.toString(), ss: seconds.toString().padStart(2, '0'), s: seconds.toString() }; return pattern .replace(/HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token) .replace(/\\(.)/g, '$1'); } function getSelectedFilenameFormat(): 'simple' | 'timestamp' | 'template' { const selected = query('input[name="filenameFormat"]:checked').value; return selected === 'template' ? 'template' : selected === 'timestamp' ? 'timestamp' : 'simple'; } function updateFilenameTemplateVisibility(): void { const selected = getSelectedFilenameFormat(); const wrap = byId('clipFilenameTemplateWrap'); wrap.style.display = selected === 'template' ? 'block' : 'none'; } interface TemplatePreviewContext { title: string; date: Date; streamer: string; partNum: string; startSec: number; durationSec: number; totalSec: number; } function buildTemplatePreview(template: string, context: TemplatePreviewContext): string { const dateStr = `${context.date.getDate().toString().padStart(2, '0')}.${(context.date.getMonth() + 1).toString().padStart(2, '0')}.${context.date.getFullYear()}`; const normalizedPart = context.partNum || '1'; let output = template .replace(/\{title\}/g, context.title || 'Untitled') .replace(/\{id\}/g, '123456789') .replace(/\{channel\}/g, context.streamer || 'streamer') .replace(/\{channel_id\}/g, '0') .replace(/\{date\}/g, dateStr) .replace(/\{part\}/g, normalizedPart) .replace(/\{part_padded\}/g, normalizedPart.padStart(2, '0')) .replace(/\{trim_start\}/g, formatSecondsToTimeDashed(context.startSec)) .replace(/\{trim_end\}/g, formatSecondsToTimeDashed(context.startSec + context.durationSec)) .replace(/\{trim_length\}/g, formatSecondsToTimeDashed(context.durationSec)) .replace(/\{length\}/g, formatSecondsToTimeDashed(context.totalSec)) .replace(/\{ext\}/g, 'mp4') .replace(/\{random_string\}/g, 'abcd1234'); output = output.replace(/\{date_custom="(.*?)"\}/g, (_, pattern: string) => formatDateWithPattern(context.date, pattern)); output = output.replace(/\{trim_start_custom="(.*?)"\}/g, (_, pattern: string) => formatSecondsWithPattern(context.startSec, pattern)); output = output.replace(/\{trim_end_custom="(.*?)"\}/g, (_, pattern: string) => formatSecondsWithPattern(context.startSec + context.durationSec, pattern)); output = output.replace(/\{trim_length_custom="(.*?)"\}/g, (_, pattern: string) => formatSecondsWithPattern(context.durationSec, pattern)); output = output.replace(/\{length_custom="(.*?)"\}/g, (_, pattern: string) => formatSecondsWithPattern(context.totalSec, pattern)); return output; } function getTemplateForSource(source: TemplateGuideSource): string { if (source === 'vod') { return ((config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE).trim() || DEFAULT_VOD_TEMPLATE; } if (source === 'parts') { return ((config.filename_template_parts as string) || DEFAULT_PARTS_TEMPLATE).trim() || DEFAULT_PARTS_TEMPLATE; } const clipField = document.getElementById('clipFilenameTemplate') as HTMLInputElement | null; const clipFromDialog = clipField?.value.trim() || ''; if (clipFromDialog) { return clipFromDialog; } return ((config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE).trim() || DEFAULT_CLIP_TEMPLATE; } function getTemplateGuidePreviewContext(source: TemplateGuideSource): { context: TemplatePreviewContext; contextText: string } { const now = new Date(); const sampleDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 15, 8); const sampleStreamer = currentStreamer || 'sample_streamer'; if (source === 'clip' && clipDialogData) { const startSec = parseTimeToSeconds(byId('clipStartTime').value); const endSec = parseTimeToSeconds(byId('clipEndTime').value); const clipDuration = Math.max(1, endSec - startSec); const totalSec = Math.max(1, clipTotalSeconds || parseDurationToSeconds(clipDialogData.duration)); return { context: { title: clipDialogData.title || 'Clip Title', date: new Date(clipDialogData.date), streamer: clipDialogData.streamer || sampleStreamer, partNum: byId('clipStartPart').value.trim() || '1', startSec, durationSec: clipDuration, totalSec }, contextText: UI_TEXT.static.templateGuideContextClipLive }; } if (source === 'parts') { const partLen = Math.max(60, Number(config.part_minutes ?? 120) * 60); return { context: { title: 'Epic Ranked Session', date: sampleDate, streamer: sampleStreamer, partNum: '3', startSec: partLen * 2, durationSec: partLen, totalSec: partLen * 5 }, contextText: UI_TEXT.static.templateGuideContextParts }; } if (source === 'clip') { return { context: { title: 'Funny Clip Moment', date: sampleDate, streamer: sampleStreamer, partNum: '1', startSec: 95, durationSec: 45, totalSec: 5400 }, contextText: UI_TEXT.static.templateGuideContextClip }; } return { context: { title: 'Epic Ranked Session', date: sampleDate, streamer: sampleStreamer, partNum: '1', startSec: 0, durationSec: 3 * 3600 + 12 * 60 + 5, totalSec: 3 * 3600 + 12 * 60 + 5 }, contextText: UI_TEXT.static.templateGuideContextVod }; } interface TemplateVariableDoc { placeholder: string; description: string; exampleTemplate: string; } function getTemplateVariableDocs(): TemplateVariableDoc[] { const de = currentLanguage !== 'en'; const text = (deText: string, enText: string) => de ? deText : enText; return [ { placeholder: '{title}', description: text('Titel des VODs/Clips', 'Title of the VOD/clip'), exampleTemplate: '{title}' }, { placeholder: '{id}', description: text('VOD-ID', 'VOD id'), exampleTemplate: '{id}' }, { placeholder: '{channel}', description: text('Kanalname', 'Channel name'), exampleTemplate: '{channel}' }, { placeholder: '{date}', description: text('Datum (DD.MM.YYYY)', 'Date (DD.MM.YYYY)'), exampleTemplate: '{date}' }, { placeholder: '{part}', description: text('Teilnummer', 'Part number'), exampleTemplate: '{part}' }, { placeholder: '{part_padded}', description: text('Teilnummer mit 2 Stellen', 'Part number padded to 2 digits'), exampleTemplate: '{part_padded}' }, { placeholder: '{trim_start}', description: text('Startzeit des Ausschnitts', 'Trim start time'), exampleTemplate: '{trim_start}' }, { placeholder: '{trim_end}', description: text('Endzeit des Ausschnitts', 'Trim end time'), exampleTemplate: '{trim_end}' }, { placeholder: '{trim_length}', description: text('Lange des Ausschnitts', 'Trimmed duration'), exampleTemplate: '{trim_length}' }, { placeholder: '{length}', description: text('Gesamtdauer', 'Total duration'), exampleTemplate: '{length}' }, { placeholder: '{ext}', description: text('Dateiendung', 'File extension'), exampleTemplate: '{ext}' }, { placeholder: '{random_string}', description: text('Zufallsstring (8 Zeichen)', 'Random string (8 chars)'), exampleTemplate: '{random_string}' }, { placeholder: '{date_custom="yyyy-MM-dd"}', description: text('Datum mit eigenem Format', 'Custom-formatted date'), exampleTemplate: '{date_custom="yyyy-MM-dd"}' }, { placeholder: '{trim_start_custom="HH-mm-ss"}', description: text('Startzeit mit eigenem Format', 'Custom-formatted trim start'), exampleTemplate: '{trim_start_custom="HH-mm-ss"}' }, { placeholder: '{trim_end_custom="HH-mm-ss"}', description: text('Endzeit mit eigenem Format', 'Custom-formatted trim end'), exampleTemplate: '{trim_end_custom="HH-mm-ss"}' }, { placeholder: '{trim_length_custom="HH-mm-ss"}', description: text('Trim-Dauer mit eigenem Format', 'Custom-formatted trim length'), exampleTemplate: '{trim_length_custom="HH-mm-ss"}' }, { placeholder: '{length_custom="HH-mm-ss"}', description: text('Gesamtdauer mit eigenem Format', 'Custom-formatted total duration'), exampleTemplate: '{length_custom="HH-mm-ss"}' } ]; } function renderTemplateGuideTable(context: TemplatePreviewContext): void { const body = byId('templateGuideBody'); body.innerHTML = ''; for (const item of getTemplateVariableDocs()) { const row = document.createElement('tr'); const varCell = document.createElement('td'); const descCell = document.createElement('td'); const exampleCell = document.createElement('td'); varCell.textContent = item.placeholder; descCell.textContent = item.description; exampleCell.textContent = buildTemplatePreview(item.exampleTemplate, context); row.append(varCell, descCell, exampleCell); body.appendChild(row); } } function updateTemplateGuidePresetButtons(): void { const activeId: Record = { vod: 'templateGuideUseVod', parts: 'templateGuideUseParts', clip: 'templateGuideUseClip' }; (Object.keys(activeId) as TemplateGuideSource[]).forEach((key) => { const btn = byId(activeId[key]); btn.classList.toggle('active', key === templateGuideSource); }); } function refreshTemplateGuideTexts(): void { setText('settingsTemplateGuideBtn', UI_TEXT.static.templateGuideButton); setText('clipTemplateGuideBtn', UI_TEXT.static.templateGuideButton); setText('templateGuideTitle', UI_TEXT.static.templateGuideTitle); setText('templateGuideIntro', UI_TEXT.static.templateGuideIntro); setText('templateGuideTemplateLabel', UI_TEXT.static.templateGuideTemplateLabel); setText('templateGuideOutputLabel', UI_TEXT.static.templateGuideOutputLabel); setText('templateGuideVarsTitle', UI_TEXT.static.templateGuideVarsTitle); setText('templateGuideVarCol', UI_TEXT.static.templateGuideVarCol); setText('templateGuideDescCol', UI_TEXT.static.templateGuideDescCol); setText('templateGuideExampleCol', UI_TEXT.static.templateGuideExampleCol); setText('templateGuideUseVod', UI_TEXT.static.templateGuideUseVod); setText('templateGuideUseParts', UI_TEXT.static.templateGuideUseParts); setText('templateGuideUseClip', UI_TEXT.static.templateGuideUseClip); setText('templateGuideCloseBtn', UI_TEXT.static.templateGuideClose); setPlaceholder('templateGuideInput', getTemplateForSource(templateGuideSource)); updateTemplateGuidePresetButtons(); const modal = document.getElementById('templateGuideModal'); if (modal?.classList.contains('show')) { updateTemplateGuidePreview(); } } function openTemplateGuide(source: TemplateGuideSource = 'vod'): void { templateGuideSource = source; byId('templateGuideModal').classList.add('show'); refreshTemplateGuideTexts(); setTemplateGuidePreset(source); } function closeTemplateGuide(): void { byId('templateGuideModal').classList.remove('show'); } function setTemplateGuidePreset(source: TemplateGuideSource): void { templateGuideSource = source; const template = getTemplateForSource(source); byId('templateGuideInput').value = template; setPlaceholder('templateGuideInput', template); updateTemplateGuidePresetButtons(); updateTemplateGuidePreview(); } function updateTemplateGuidePreview(): void { const input = byId('templateGuideInput'); const template = input.value.trim() || getTemplateForSource(templateGuideSource); const { context, contextText } = getTemplateGuidePreviewContext(templateGuideSource); byId('templateGuideOutput').textContent = buildTemplatePreview(template, context); byId('templateGuideContext').textContent = contextText; renderTemplateGuideTable(context); } function parseTimeToSeconds(timeStr: string): number { const parts = timeStr.split(':').map((p: string) => parseInt(p, 10) || 0); if (parts.length === 3) { return parts[0] * 3600 + parts[1] * 60 + parts[2]; } return 0; } function openClipDialog(url: string, title: string, date: string, streamer: string, duration: string): void { clipDialogData = { url, title, date, streamer, duration }; clipTotalSeconds = parseDurationToSeconds(duration); byId('clipDialogTitle').textContent = `${UI_TEXT.clips.dialogTitle} (${duration})`; byId('clipStartSlider').max = String(clipTotalSeconds); byId('clipEndSlider').max = String(clipTotalSeconds); byId('clipStartSlider').value = '0'; byId('clipEndSlider').value = String(Math.min(60, clipTotalSeconds)); byId('clipStartTime').value = '00:00:00'; byId('clipEndTime').value = formatSecondsToTime(Math.min(60, clipTotalSeconds)); byId('clipStartPart').value = ''; byId('clipFilenameTemplate').value = (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE; query('input[name="filenameFormat"][value="simple"]').checked = true; updateFilenameTemplateVisibility(); updateClipDuration(); updateFilenameExamples(); byId('clipModal').classList.add('show'); } function closeClipDialog(): void { byId('clipModal').classList.remove('show'); clipDialogData = null; } function updateFromSlider(which: string): void { const startSlider = byId('clipStartSlider'); const endSlider = byId('clipEndSlider'); if (which === 'start') { byId('clipStartTime').value = formatSecondsToTime(parseInt(startSlider.value, 10)); } else { byId('clipEndTime').value = formatSecondsToTime(parseInt(endSlider.value, 10)); } updateClipDuration(); } function updateFromInput(which: string): void { const startSec = parseTimeToSeconds(byId('clipStartTime').value); const endSec = parseTimeToSeconds(byId('clipEndTime').value); if (which === 'start') { byId('clipStartSlider').value = String(Math.max(0, Math.min(startSec, clipTotalSeconds))); } else { byId('clipEndSlider').value = String(Math.max(0, Math.min(endSec, clipTotalSeconds))); } updateClipDuration(); } function updateClipDuration(): void { const startSec = parseTimeToSeconds(byId('clipStartTime').value); const endSec = parseTimeToSeconds(byId('clipEndTime').value); const duration = endSec - startSec; const durationDisplay = byId('clipDurationDisplay'); if (duration > 0) { durationDisplay.textContent = formatSecondsToTime(duration); durationDisplay.style.color = '#00c853'; } else { durationDisplay.textContent = UI_TEXT.clips.invalidDuration; durationDisplay.style.color = '#ff4444'; } updateFilenameExamples(); } function updateFilenameExamples(): void { if (!clipDialogData) { return; } const date = new Date(clipDialogData.date); const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; const partNum = byId('clipStartPart').value || '1'; const startSec = parseTimeToSeconds(byId('clipStartTime').value); const endSec = parseTimeToSeconds(byId('clipEndTime').value); const durationSec = Math.max(1, endSec - startSec); const timeStr = formatSecondsToTimeDashed(startSec); const template = byId('clipFilenameTemplate').value.trim() || (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE; const unknownTokens = collectUnknownTemplatePlaceholders(template); const clipLint = byId('clipTemplateLint'); updateFilenameTemplateVisibility(); if (!unknownTokens.length) { clipLint.style.color = '#8bc34a'; clipLint.textContent = UI_TEXT.static.templateLintOk; } else { clipLint.style.color = '#ff8a80'; clipLint.textContent = `${UI_TEXT.static.templateLintWarn}: ${unknownTokens.join(' ')}`; } byId('formatSimple').textContent = `${dateStr}_${partNum}.mp4 ${UI_TEXT.clips.formatSimple}`; byId('formatTimestamp').textContent = `${dateStr}_CLIP_${timeStr}_${partNum}.mp4 ${UI_TEXT.clips.formatTimestamp}`; byId('formatTemplate').textContent = `${buildTemplatePreview(template, { title: clipDialogData.title, date, streamer: clipDialogData.streamer, partNum, startSec, durationSec, totalSec: clipTotalSeconds })} ${UI_TEXT.clips.formatTemplate}`; const guideModal = document.getElementById('templateGuideModal'); if (guideModal?.classList.contains('show') && templateGuideSource === 'clip') { updateTemplateGuidePreview(); } } async function confirmClipDialog(): Promise { if (!clipDialogData) { return; } const startSec = parseTimeToSeconds(byId('clipStartTime').value); const endSec = parseTimeToSeconds(byId('clipEndTime').value); const startPartStr = byId('clipStartPart').value.trim(); const startPart = startPartStr ? parseInt(startPartStr, 10) : 1; const filenameFormat = getSelectedFilenameFormat(); const filenameTemplate = byId('clipFilenameTemplate').value.trim(); if (endSec <= startSec) { alert(UI_TEXT.clips.endBeforeStart); return; } if (startSec < 0 || endSec > clipTotalSeconds) { alert(UI_TEXT.clips.outOfRange); return; } if (filenameFormat === 'template' && !filenameTemplate) { alert(UI_TEXT.clips.templateEmpty); return; } if (filenameFormat === 'template') { const unknownTokens = collectUnknownTemplatePlaceholders(filenameTemplate); if (unknownTokens.length > 0) { alert(`${UI_TEXT.static.templateLintWarn}: ${unknownTokens.join(' ')}`); return; } } const durationSec = endSec - startSec; const customClip: CustomClip = { startSec, durationSec, startPart, filenameFormat, filenameTemplate: filenameFormat === 'template' ? filenameTemplate : undefined }; if ((config.prevent_duplicate_downloads as boolean) !== false && hasActiveQueueDuplicate( clipDialogData.url, clipDialogData.streamer, clipDialogData.date, customClip )) { alert(UI_TEXT.queue.duplicateSkipped); return; } queue = await window.api.addToQueue({ url: clipDialogData.url, title: clipDialogData.title, date: clipDialogData.date, streamer: clipDialogData.streamer, duration_str: clipDialogData.duration, customClip }); renderQueue(); closeClipDialog(); } async function downloadClip(): Promise { const url = byId('clipUrl').value.trim(); const status = byId('clipStatus'); const btn = byId('btnClip'); if (!url) { status.textContent = UI_TEXT.clips.enterUrl; status.className = 'clip-status error'; return; } btn.disabled = true; 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 = UI_TEXT.clips.downloadButton; if (result.success) { status.textContent = UI_TEXT.clips.success; status.className = 'clip-status success'; return; } const backendError = (result.error || '').trim(); let localizedError = backendError; if (backendError === 'Ungueltige Clip-URL') { localizedError = currentLanguage === 'en' ? 'Invalid clip URL' : backendError; } else if (backendError === 'Clip nicht gefunden') { localizedError = currentLanguage === 'en' ? 'Clip not found' : backendError; } else if (backendError === 'Streamlink nicht gefunden') { localizedError = currentLanguage === 'en' ? 'Streamlink not found' : backendError; } else if (backendError.startsWith('Download fehlgeschlagen')) { localizedError = currentLanguage === 'en' ? backendError.replace('Download fehlgeschlagen', 'Download failed') : backendError; } status.textContent = UI_TEXT.clips.errorPrefix + (localizedError || UI_TEXT.clips.unknownError); status.className = 'clip-status error'; } async function selectCutterVideo(): Promise { const filePath = await window.api.selectVideoFile(); if (!filePath) { return; } cutterFile = filePath; byId('cutterFilePath').value = filePath; const info = await window.api.getVideoInfo(filePath); if (!info) { alert(UI_TEXT.cutter.videoInfoFailed); return; } cutterVideoInfo = info; cutterStartTime = 0; cutterEndTime = info.duration; byId('cutterInfo').style.display = 'flex'; byId('timelineContainer').style.display = 'block'; byId('btnCut').disabled = false; byId('infoDuration').textContent = formatTime(info.duration); byId('infoResolution').textContent = `${info.width}x${info.height}`; byId('infoFps').textContent = Math.round(info.fps); byId('infoSelection').textContent = formatTime(info.duration); byId('startTime').value = '00:00:00'; byId('endTime').value = formatTime(info.duration); updateTimeline(); await updatePreview(0); } function formatTime(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } function parseTime(timeStr: string): number { const parts = timeStr.split(':').map((p: string) => parseInt(p, 10) || 0); if (parts.length === 3) { return parts[0] * 3600 + parts[1] * 60 + parts[2]; } return 0; } function updateTimeline(): void { if (!cutterVideoInfo) { return; } const selection = byId('timelineSelection'); const startPercent = (cutterStartTime / cutterVideoInfo.duration) * 100; const endPercent = (cutterEndTime / cutterVideoInfo.duration) * 100; selection.style.left = startPercent + '%'; selection.style.width = (endPercent - startPercent) + '%'; const duration = cutterEndTime - cutterStartTime; byId('infoSelection').textContent = formatTime(duration); } function updateTimeFromInput(): void { const startStr = byId('startTime').value; const endStr = byId('endTime').value; cutterStartTime = Math.max(0, parseTime(startStr)); cutterEndTime = Math.min(cutterVideoInfo?.duration || 0, parseTime(endStr)); if (cutterEndTime <= cutterStartTime) { cutterEndTime = cutterStartTime + 1; } updateTimeline(); } async function seekTimeline(event: MouseEvent): Promise { if (!cutterVideoInfo) { return; } const timeline = byId('timeline'); const rect = timeline.getBoundingClientRect(); const percent = (event.clientX - rect.left) / rect.width; const time = percent * cutterVideoInfo.duration; byId('timelineCurrent').style.left = (percent * 100) + '%'; await updatePreview(time); } async function updatePreview(time: number): Promise { if (!cutterFile) { return; } const preview = byId('cutterPreview'); preview.innerHTML = `

${UI_TEXT.cutter.previewLoading}

`; const frame = await window.api.extractFrame(cutterFile, time); if (frame) { preview.innerHTML = `Preview`; return; } preview.innerHTML = `

${UI_TEXT.cutter.previewUnavailable}

`; } async function startCutting(): Promise { if (!cutterFile || isCutting) { return; } isCutting = true; byId('btnCut').disabled = true; 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 = UI_TEXT.cutter.cut; byId('cutProgress').classList.remove('show'); if (result.success) { alert(`${UI_TEXT.cutter.cutSuccess}\n\n${result.outputFile}`); return; } alert(UI_TEXT.cutter.cutFailed); } async function addMergeFiles(): Promise { const files = await window.api.selectMultipleVideos(); if (!files || files.length === 0) { return; } mergeFiles = [...mergeFiles, ...files]; renderMergeFiles(); } function renderMergeFiles(): void { const list = byId('mergeFileList'); byId('btnMerge').disabled = mergeFiles.length < 2; if (mergeFiles.length === 0) { list.innerHTML = `

${UI_TEXT.merge.empty}

`; return; } list.innerHTML = mergeFiles.map((file: string, index: number) => { const name = file.split(/[/\\]/).pop(); return `
${index + 1}
${name}
`; }).join(''); } function moveMergeFile(index: number, direction: number): void { const newIndex = index + direction; if (newIndex < 0 || newIndex >= mergeFiles.length) { return; } const temp = mergeFiles[index]; mergeFiles[index] = mergeFiles[newIndex]; mergeFiles[newIndex] = temp; renderMergeFiles(); } function removeMergeFile(index: number): void { mergeFiles.splice(index, 1); renderMergeFiles(); } async function startMerging(): Promise { if (mergeFiles.length < 2 || isMerging) { return; } const outputFile = await window.api.saveVideoDialog('merged_video.mp4'); if (!outputFile) { return; } isMerging = true; byId('btnMerge').disabled = true; 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 = UI_TEXT.merge.merge; byId('mergeProgress').classList.remove('show'); if (result.success) { alert(`${UI_TEXT.merge.success}\n\n${result.outputFile}`); mergeFiles = []; renderMergeFiles(); return; } alert(UI_TEXT.merge.failed); } void init();