Twitch-VOD-Manager/src/renderer.ts
xRangerDE e098708398 feat: stats-bar pause-on-hidden + bulk-mark downloaded + title tooltip
Three Phase-13 wins.

1. Stats bar polling pauses while document.hidden. Previously
   setInterval(updateStatsBar, 5000) ran forever, including while
   the user had a different tab focused or the window minimised.
   Now wraps start/stopStatsBarPolling and listens to
   visibilitychange. When the page becomes visible the interval
   restarts; while hidden it sleeps. Saves an IPC round-trip every
   5s when nobody's looking.

2. Bulk mark / unmark "as downloaded" on the VOD bulk-bar. Companion
   to the per-card right-click context menu's mark/unmark items —
   when the user has 5 VODs selected they now get one click to
   toggle the green check on all of them instead of right-clicking
   each. Uses the existing markVodDownloaded IPC, refreshes the
   local config copy + re-renders the grid so badges update live.

3. VOD card title tooltip. The card title is text-overflow:ellipsis
   so longer titles get cut off. Adding title="${full title}"
   surfaces the full text on hover via the native browser tooltip
   — no custom UI needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:20:42 +02:00

1337 lines
49 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> {
const [loadedConfig, initialQueue, isDown, version] = await Promise.all([
window.api.getConfig(),
window.api.getQueue(),
window.api.isDownloading(),
window.api.getVersion()
]);
config = loadedConfig;
const language = setLanguage((config.language as string) || 'en');
config.language = language;
queue = Array.isArray(initialQueue) ? initialQueue : [];
downloading = isDown;
markQueueActivity();
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;
initSettingsAutoSave();
changeTheme(config.theme ?? 'twitch');
renderStreamers();
renderQueue();
initQueueDragDrop();
updateDownloadButtonState();
updateStatusBarQueueSummary();
// Restore persisted VOD filter into the input — the filter itself only
// takes effect once VODs load (renderVODs reads vodFilterQuery).
vodFilterQuery = loadPersistedVodFilter();
const vodFilterInput = document.getElementById('vodFilterInput') as HTMLInputElement | null;
if (vodFilterInput) vodFilterInput.value = vodFilterQuery;
syncVodFilterClearButton();
// Restore persisted VOD sort key. Apply localized labels to <option>s
// before syncing the select value so the right option is preselected
// even on first load before any language change fires.
vodSortKey = loadPersistedVodSort();
refreshVodSortSelectLabels();
syncVodSortSelect();
// Restore "hide downloaded" toggle state.
vodHideDownloaded = loadPersistedHideDownloaded();
syncVodHideDownloadedToggle();
// Restore per-streamer VOD scroll positions from prior sessions.
loadVodScrollPositions();
initVodScrollTracking();
initCutterDragDrop();
// Restore last active tab from previous session (default 'vods')
showTab(loadPersistedActiveTab());
window.api.onQueueUpdated(async (q: QueueItem[]) => {
const previouslyCompleted = new Set(queue.filter((i) => i.status === 'completed').map((i) => i.id));
const next = Array.isArray(q) ? q : [];
const newlyCompletedItem = next.some((i) => i.status === 'completed' && !previouslyCompleted.has(i.id));
queue = mergeQueueState(next);
// When an item flips to 'completed' the main process appends its
// VOD ID to config.downloaded_vod_ids. Refresh our local config
// copy so the "already downloaded" badge on the VOD grid updates
// live without waiting for a settings save.
if (newlyCompletedItem) {
try {
config = await window.api.getConfig();
} catch { /* network blip — next sync will refresh */ }
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
renderVodGridFromCurrentState();
}
}
renderQueue();
updateStatusBarQueueSummary();
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;
updateQueueItemProgress(progress);
updateStatusBarQueueSummary();
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) + '%';
});
// Update stats bar — paused while the window is hidden so we don't
// burn IPC chatter on a tab nobody is looking at.
void updateStatsBar();
startStatsBarPolling();
document.addEventListener('visibilitychange', () => {
if (document.hidden) stopStatsBarPolling();
else startStatsBarPolling();
});
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);
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Esc closes any open modal — works regardless of focus, so users can dismiss
// a modal that took focus from inside an input field
if (e.key === 'Escape') {
if (closeTopmostOpenModal()) {
e.preventDefault();
return;
}
// No modal open: if the VOD filter has focus or content, clear it.
// Otherwise let Esc bubble (e.g. blur).
if (e.target instanceof HTMLInputElement && e.target.id === 'vodFilterInput') {
if (vodFilterQuery) {
clearVodFilter();
e.preventDefault();
}
return;
}
}
// Ctrl+F (or Cmd+F): focus the VOD filter — only when on the VODs tab.
// Browser's default Ctrl+F is suppressed because Electron's renderer
// doesn't have a native find bar anyway.
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'f' || e.key === 'F')) {
const onVodsTab = document.getElementById('vodsTab')?.classList.contains('active');
if (onVodsTab) {
e.preventDefault();
focusVodFilter();
return;
}
}
// Skip rest if user is typing in an input field
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return;
// Ctrl+1..5 jumps directly to a tab (Cmd on macOS via metaKey)
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key >= '1' && e.key <= '5') {
const tabIndex = parseInt(e.key, 10) - 1;
if (tabIndex >= 0 && tabIndex < TAB_IDS.length) {
e.preventDefault();
showTab(TAB_IDS[tabIndex]);
return;
}
}
if (e.key === 'Delete' && selectedQueueIds.length > 0) {
// Delete selected queue items
const idsToRemove = [...selectedQueueIds];
selectedQueueIds = [];
(async () => {
for (const id of idsToRemove) {
queue = await window.api.removeFromQueue(id);
}
renderQueue();
})();
}
if ((e.key === 's' || e.key === 'S') && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
toggleDownload();
}
});
scheduleQueueSync(QUEUE_SYNC_DEFAULT_MS);
}
function openTwitchDevConsole(): void {
void window.api.openExternal('https://dev.twitch.tv/console/apps');
}
function closeTopmostOpenModal(): boolean {
// Try each known modal in priority order: clip dialog, template guide, update modal
const clipModal = document.getElementById('clipModal');
if (clipModal?.classList.contains('show')) {
closeClipDialog();
return true;
}
const templateGuideModal = document.getElementById('templateGuideModal');
if (templateGuideModal?.classList.contains('show')) {
closeTemplateGuide();
return true;
}
const updateModal = document.getElementById('updateModal');
if (updateModal?.classList.contains('show')) {
dismissUpdateModal();
return true;
}
return false;
}
function formatBytesRenderer(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatSpeedRenderer(bytesPerSec: number): string {
if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s`;
if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
}
function updateStatusBarQueueSummary(): void {
const node = document.getElementById('statusBarQueueSummary');
if (!node) return;
if (!Array.isArray(queue) || queue.length === 0) {
node.textContent = '';
return;
}
let downloading = 0;
let pending = 0;
for (const item of queue) {
if (item.status === 'downloading') downloading++;
else if (item.status === 'pending') pending++;
}
if (downloading === 0 && pending === 0) {
node.textContent = '';
return;
}
node.textContent = UI_TEXT.queue.statusBarSummary
.replace('{downloading}', String(downloading))
.replace('{pending}', String(pending));
}
let statsBarPollTimer: number | null = null;
function startStatsBarPolling(): void {
stopStatsBarPolling();
if (document.hidden) return;
statsBarPollTimer = window.setInterval(updateStatsBar, 5000);
}
function stopStatsBarPolling(): void {
if (statsBarPollTimer !== null) {
window.clearInterval(statsBarPollTimer);
statsBarPollTimer = null;
}
}
async function updateStatsBar(): Promise<void> {
try {
const metrics = await window.api.getRuntimeMetrics();
const bar = byId('statsBar');
if (!bar) return;
const totalDL = formatBytesRenderer(metrics.downloadedBytesTotal);
const avgSpeed = metrics.avgSpeedBytesPerSec > 0 ? formatSpeedRenderer(metrics.avgSpeedBytesPerSec) : '-';
bar.textContent = `${totalDL} | ${avgSpeed} avg | ${metrics.downloadsCompleted} done | ${metrics.downloadsFailed} failed`;
} catch { }
}
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;
}
// Keep the higher progress value to prevent backward jumps from stale data
const bestProgress = (prev.status === 'downloading' && prev.progress > item.progress)
? prev.progress
: (item.progress > 0 ? item.progress : prev.progress);
return {
...item,
progress: bestProgress,
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();
}
}
const TAB_IDS = ['vods', 'clips', 'cutter', 'merge', 'settings'] as const;
const ACTIVE_TAB_STORAGE_KEY = 'twitch-vod-manager:active-tab';
function isKnownTab(value: string): value is typeof TAB_IDS[number] {
return (TAB_IDS as readonly string[]).includes(value);
}
function loadPersistedActiveTab(): string {
try {
const stored = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY);
if (stored && isKnownTab(stored)) return stored;
} catch { /* localStorage may be unavailable in privacy modes */ }
return 'vods';
}
function persistActiveTab(tab: string): void {
if (!isKnownTab(tab)) return;
try { localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, tab); } catch { }
}
function showTab(tab: string): void {
queryAll('.nav-item').forEach((i) => i.classList.remove('active'));
queryAll('.tab-content').forEach((c) => c.classList.remove('active'));
const navItem = query(`.nav-item[data-tab="${tab}"]`);
if (!navItem) {
// Unknown tab — fall back to vods so the user is never stuck on an empty screen
showTab('vods');
return;
}
navItem.classList.add('active');
byId(tab + 'Tab').classList.add('active');
const titles: Record<string, string> = UI_TEXT.tabs;
// Only show the streamer name on the VODs tab — otherwise the title would
// mismatch the tab content (e.g. "streamer X" while on Settings)
byId('pageTitle').textContent = (tab === 'vods' && currentStreamer)
? currentStreamer
: (titles[tab] || UI_TEXT.appName);
persistActiveTab(tab);
}
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' | 'parts' {
const selected = query<HTMLInputElement>('input[name="filenameFormat"]:checked').value;
if (selected === 'template') return 'template';
if (selected === 'timestamp') return 'timestamp';
if (selected === 'parts') return 'parts';
return '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('formatParts').textContent = `${dateStr}_Part${partNum.padStart(2, '0')}.mp4 ${UI_TEXT.clips.formatParts}`;
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 durationSec = endSec - startSec;
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 (isNaN(startSec) || isNaN(endSec) || isNaN(durationSec)) {
alert('Invalid time values');
return;
}
if (startSec < 0) {
alert(UI_TEXT.clips.outOfRange);
return;
}
if (durationSec <= 0) {
alert(UI_TEXT.clips.endBeforeStart);
return;
}
if (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 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;
}
// Backend now produces locale-aware error strings via tBackend(),
// so we no longer need a renderer-side translation table here.
const backendError = (result.error || '').trim();
status.textContent = UI_TEXT.clips.errorPrefix + (backendError || UI_TEXT.clips.unknownError);
status.className = 'clip-status error';
}
async function loadCutterFromPath(filePath: string): Promise<void> {
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);
}
async function selectCutterVideo(): Promise<void> {
const filePath = await window.api.selectVideoFile();
if (!filePath) return;
await loadCutterFromPath(filePath);
}
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();