Twitch-VOD-Manager/src/renderer.ts

1077 lines
40 KiB
TypeScript

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<void> {
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<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 ?? 'en';
updateLanguagePicker(config.language ?? 'en');
byId<HTMLSelectElement>('downloadMode').value = config.download_mode ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String(config.part_minutes ?? 120);
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false;
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE;
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || DEFAULT_PARTS_TEMPLATE;
byId<HTMLInputElement>('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<void> {
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<void> {
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<string, string> = 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<string, string> = {
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<string, string> = {
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<HTMLInputElement>('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<HTMLInputElement>('clipStartTime').value);
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('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<HTMLInputElement>('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<TemplateGuideSource, string> = {
vod: 'templateGuideUseVod',
parts: 'templateGuideUseParts',
clip: 'templateGuideUseClip'
};
(Object.keys(activeId) as TemplateGuideSource[]).forEach((key) => {
const btn = byId<HTMLButtonElement>(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<HTMLInputElement>('templateGuideInput').value = template;
setPlaceholder('templateGuideInput', template);
updateTemplateGuidePresetButtons();
updateTemplateGuidePreview();
}
function updateTemplateGuidePreview(): void {
const input = byId<HTMLInputElement>('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<HTMLInputElement>('clipStartSlider').max = String(clipTotalSeconds);
byId<HTMLInputElement>('clipEndSlider').max = String(clipTotalSeconds);
byId<HTMLInputElement>('clipStartSlider').value = '0';
byId<HTMLInputElement>('clipEndSlider').value = String(Math.min(60, clipTotalSeconds));
byId<HTMLInputElement>('clipStartTime').value = '00:00:00';
byId<HTMLInputElement>('clipEndTime').value = formatSecondsToTime(Math.min(60, clipTotalSeconds));
byId<HTMLInputElement>('clipStartPart').value = '';
byId<HTMLInputElement>('clipFilenameTemplate').value = (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE;
query<HTMLInputElement>('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<HTMLInputElement>('clipStartSlider');
const endSlider = byId<HTMLInputElement>('clipEndSlider');
if (which === 'start') {
byId<HTMLInputElement>('clipStartTime').value = formatSecondsToTime(parseInt(startSlider.value, 10));
} else {
byId<HTMLInputElement>('clipEndTime').value = formatSecondsToTime(parseInt(endSlider.value, 10));
}
updateClipDuration();
}
function updateFromInput(which: string): void {
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
if (which === 'start') {
byId<HTMLInputElement>('clipStartSlider').value = String(Math.max(0, Math.min(startSec, clipTotalSeconds)));
} else {
byId<HTMLInputElement>('clipEndSlider').value = String(Math.max(0, Math.min(endSec, clipTotalSeconds)));
}
updateClipDuration();
}
function updateClipDuration(): void {
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('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<HTMLInputElement>('clipStartPart').value || '1';
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
const durationSec = Math.max(1, endSec - startSec);
const timeStr = formatSecondsToTimeDashed(startSec);
const template = byId<HTMLInputElement>('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<void> {
if (!clipDialogData) {
return;
}
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
const startPartStr = byId<HTMLInputElement>('clipStartPart').value.trim();
const startPart = startPartStr ? parseInt(startPartStr, 10) : 1;
const filenameFormat = getSelectedFilenameFormat();
const filenameTemplate = byId<HTMLInputElement>('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<void> {
const url = byId<HTMLInputElement>('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<void> {
const filePath = await window.api.selectVideoFile();
if (!filePath) {
return;
}
cutterFile = filePath;
byId<HTMLInputElement>('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<HTMLInputElement>('startTime').value = '00:00:00';
byId<HTMLInputElement>('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<HTMLInputElement>('startTime').value;
const endStr = byId<HTMLInputElement>('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<void> {
if (!cutterVideoInfo) {
return;
}
const timeline = byId<HTMLElement>('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<void> {
if (!cutterFile) {
return;
}
const preview = byId('cutterPreview');
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewLoading}</p></div>`;
const frame = await window.api.extractFrame(cutterFile, time);
if (frame) {
preview.innerHTML = `<img src="${frame}" alt="Preview">`;
return;
}
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewUnavailable}</p></div>`;
}
async function startCutting(): Promise<void> {
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<void> {
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 = `
<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">${UI_TEXT.merge.empty}</p>
</div>
`;
return;
}
list.innerHTML = mergeFiles.map((file: string, index: number) => {
const name = file.split(/[/\\]/).pop();
return `
<div class="file-item" draggable="true" data-index="${index}">
<div class="file-order">${index + 1}</div>
<div class="file-name" title="${file}">${name}</div>
<div class="file-actions">
<button class="file-btn" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>&#9650;</button>
<button class="file-btn" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>&#9660;</button>
<button class="file-btn remove" onclick="removeMergeFile(${index})">x</button>
</div>
</div>
`;
}).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<void> {
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();