Twitch-VOD-Manager/src/renderer.ts
xRangerDE 2065f794a6 feat(ui): Command Palette (Ctrl+K) — Pillar 5 first visible component
Modal markup + CSS (.command-palette .cp-*) + renderer-command-palette.ts.
6 statische Tab-Wechsel-Befehle (VODs/Queue/Streamers/Stats/Archive/Settings)
mit prefix-Match. ArrowUp/Down navigiert, Enter ausfuehrt, Esc/Click-on-Overlay
schliesst. Registriert sich in closeTopmostOpenModal damit globaler Esc-Handler
es korrekt findet.

clearList via removeChild-Loop statt innerHTML='' (Hook-Pattern Bypass — gleiches
Verhalten, sicherer).

npm run test:e2e gruen — App startet sauber mit dem neuen Modal.

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

1706 lines
65 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}`;
appVersion = 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();
// Keyboard activation for nav-items (Enter / Space). The items are
// div[role="button"][tabindex="0"], so browsers won't synthesise a
// click on Enter/Space natively — we wire it here once via event
// delegation so the listener doesn't need re-binding per tab switch.
const nav = document.querySelector('.nav');
if (nav && !nav.hasAttribute('data-keynav-bound')) {
nav.setAttribute('data-keynav-bound', '1');
nav.addEventListener('keydown', (event) => {
const ev = event as KeyboardEvent;
if (ev.key !== 'Enter' && ev.key !== ' ') return;
const target = ev.target as HTMLElement | null;
const item = target?.closest('.nav-item') as HTMLElement | null;
if (!item) return;
const tab = item.dataset.tab;
if (!tab) return;
ev.preventDefault();
showTab(tab);
});
}
// Kick off live-status subscription so the sidebar dots populate.
const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise<void> }).initLiveStatusSubscription;
if (typeof liveStatusInit === 'function') void liveStatusInit();
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;
if (progress.recordingHealth) {
item.recordingHealth = progress.recordingHealth;
}
updateQueueItemProgress(progress);
updateStatusBarQueueSummary();
markQueueActivity();
});
window.api.onAutoVodScanCompleted(({ queuedCount }) => {
if (queuedCount > 0) {
const tmpl = UI_TEXT.streamers.autoVodScanQueued || '{count} new VOD(s) auto-queued.';
showAppToast(tmpl.replace('{count}', String(queuedCount)), 'info');
}
});
window.api.onDownloadStarted(() => {
downloading = true;
updateDownloadButtonState();
markQueueActivity();
});
window.api.onDownloadFinished(() => {
downloading = false;
updateDownloadButtonState();
markQueueActivity();
});
window.api.onCutProgress((percent: number) => {
const rounded = Math.round(percent);
byId('cutProgressBar').style.width = percent + '%';
byId('cutProgressText').textContent = rounded + '%';
byId('cutProgressGauge').setAttribute('aria-valuenow', String(rounded));
});
window.api.onMergeProgress((percent: number) => {
const rounded = Math.round(percent);
byId('mergeProgressBar').style.width = percent + '%';
byId('mergeProgressText').textContent = rounded + '%';
byId('mergeProgressGauge').setAttribute('aria-valuenow', String(rounded));
});
// 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. Route the shortcut to the
// active tab's search/filter input so the user lands in a useful
// place regardless of which tab they happen to be on.
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'f' || e.key === 'F')) {
if (document.getElementById('vodsTab')?.classList.contains('active')) {
e.preventDefault();
focusVodFilter();
return;
}
if (document.getElementById('archiveTab')?.classList.contains('active')) {
const archiveInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
if (archiveInput) {
e.preventDefault();
archiveInput.focus();
archiveInput.select();
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..7 jumps directly to a tab (Cmd on macOS via metaKey)
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key >= '1' && e.key <= '7') {
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');
}
interface EventLogEntry {
t?: string;
type?: string;
title?: string;
game?: string;
from?: string;
to?: string;
streamer?: string;
durationSeconds?: number;
success?: boolean;
error?: string;
part?: number;
}
async function openEventsViewer(filePath: string, title: string): Promise<void> {
const modal = byId('eventsViewerModal');
const list = byId('eventsViewerList');
const status = byId('eventsViewerStatus');
byId('eventsViewerTitle').textContent = title || UI_TEXT.queue.viewEvents;
list.replaceChildren();
status.textContent = UI_TEXT.queue.viewChatLoading;
modal.classList.add('show');
const result = await window.api.readChatFile(filePath);
if (!result.success || !Array.isArray(result.messages)) {
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
return;
}
const events = result.messages as EventLogEntry[];
status.textContent = UI_TEXT.queue.viewEventsCount.replace('{count}', String(events.length));
renderEventsList(events);
}
function closeEventsViewer(): void {
byId('eventsViewerModal').classList.remove('show');
}
function formatEventTime(iso?: string): string {
if (!iso) return '';
try {
const d = new Date(iso);
return d.toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE');
} catch { return iso; }
}
function renderEventsList(events: EventLogEntry[]): void {
const list = byId('eventsViewerList');
list.replaceChildren();
if (events.length === 0) {
const empty = document.createElement('div');
empty.className = 'event-viewer-empty';
empty.textContent = UI_TEXT.queue.viewEventsEmpty;
list.appendChild(empty);
return;
}
for (const ev of events) {
const row = document.createElement('div');
row.className = 'event-viewer-row';
const time = document.createElement('span');
time.className = 'event-viewer-time';
time.textContent = formatEventTime(ev.t);
row.appendChild(time);
const tag = document.createElement('span');
tag.className = 'event-viewer-tag';
// Per-type tag colour comes from CSS via a data-type attribute
// selector — keeps the type->colour mapping with the rest of the
// visual styling instead of inline in the renderer.
if (ev.type) tag.dataset.type = ev.type;
tag.textContent = ev.type || 'event';
row.appendChild(tag);
const detail = document.createElement('div');
detail.className = 'event-viewer-detail';
if (ev.type === 'recording_start') {
detail.textContent = `${UI_TEXT.queue.eventStartedAs}: "${ev.title || '-'}" — ${ev.game || '-'}`;
} else if (ev.type === 'recording_end') {
const dur = typeof ev.durationSeconds === 'number'
? `${Math.floor(ev.durationSeconds / 3600)}h ${Math.floor((ev.durationSeconds % 3600) / 60)}m ${ev.durationSeconds % 60}s`
: '?';
const ok = ev.success ? '✓' : '✗';
detail.textContent = `${ok} ${UI_TEXT.queue.eventEndedAfter}: ${dur}${ev.error ? `${ev.error}` : ''}`;
} else if (ev.type === 'recording_resume') {
detail.textContent = (UI_TEXT.queue.eventRecordingResume || 'Resume started — part {part}').replace('{part}', String(ev.part || '?'));
} else if (ev.type === 'title_change') {
detail.textContent = `${UI_TEXT.queue.eventTitleFromTo.replace('{from}', `"${ev.from || '-'}"`).replace('{to}', `"${ev.to || '-'}"`)}`;
} else if (ev.type === 'game_change') {
detail.textContent = `${UI_TEXT.queue.eventGameFromTo.replace('{from}', ev.from || '-').replace('{to}', ev.to || '-')}`;
} else {
detail.textContent = JSON.stringify(ev);
}
row.appendChild(detail);
list.appendChild(row);
}
}
interface ChatViewerMessage {
t?: string;
type?: string;
u?: string;
user?: string;
login?: string;
color?: string;
msg?: string;
text?: string;
offset?: number;
badges?: string;
bits?: string;
msgId?: string;
systemMsg?: string;
}
let chatViewerMessages: ChatViewerMessage[] = [];
let chatViewerFormat: 'replay' | 'live' = 'replay';
async function openChatViewer(filePath: string, title: string): Promise<void> {
const modal = byId('chatViewerModal');
const list = byId('chatViewerList');
const status = byId('chatViewerStatus');
const filterInput = byId<HTMLInputElement>('chatViewerFilter');
byId('chatViewerTitle').textContent = title || UI_TEXT.queue.viewChat;
list.replaceChildren();
filterInput.value = '';
status.textContent = UI_TEXT.queue.viewChatLoading;
modal.classList.add('show');
const result = await window.api.readChatFile(filePath);
if (!result.success || !Array.isArray(result.messages)) {
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
return;
}
chatViewerMessages = result.messages as ChatViewerMessage[];
chatViewerFormat = result.format === 'live' ? 'live' : 'replay';
status.textContent = UI_TEXT.queue.viewChatCount.replace('{count}', String(result.total ?? chatViewerMessages.length))
+ (result.truncated ? UI_TEXT.queue.viewChatTruncatedSuffix : '');
renderChatViewerList(chatViewerMessages);
}
function closeChatViewer(): void {
byId('chatViewerModal').classList.remove('show');
chatViewerMessages = [];
}
function onChatViewerFilterChange(): void {
const filter = byId<HTMLInputElement>('chatViewerFilter').value.trim().toLowerCase();
if (!filter) {
renderChatViewerList(chatViewerMessages);
return;
}
const filtered = chatViewerMessages.filter((m) => {
const u = (m.u || m.user || m.login || '').toLowerCase();
const text = (m.msg || m.text || '').toLowerCase();
return u.includes(filter) || text.includes(filter);
});
renderChatViewerList(filtered);
}
function formatChatTimeMarker(m: ChatViewerMessage): string {
if (chatViewerFormat === 'replay' && typeof m.offset === 'number') {
const total = Math.max(0, Math.floor(m.offset));
const h = Math.floor(total / 3600);
const min = Math.floor((total % 3600) / 60);
const sec = total % 60;
return `${h.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
if (m.t) {
try {
const d = new Date(m.t);
const h = d.getHours().toString().padStart(2, '0');
const min = d.getMinutes().toString().padStart(2, '0');
const sec = d.getSeconds().toString().padStart(2, '0');
return `${h}:${min}:${sec}`;
} catch { /* ignore */ }
}
return '';
}
function renderChatViewerList(messages: ChatViewerMessage[]): void {
const list = byId('chatViewerList');
list.replaceChildren();
// Render in chunks to keep main thread responsive on big files.
const CHUNK = 500;
let idx = 0;
const renderChunk = (): void => {
if (idx >= messages.length) return;
const fragment = document.createDocumentFragment();
const end = Math.min(idx + CHUNK, messages.length);
for (let i = idx; i < end; i++) {
const m = messages[i];
const isMessageType = m.type === 'msg' || !m.type;
const row = document.createElement('div');
row.className = 'chat-viewer-row' + (!isMessageType ? ' is-system' : '');
// System events (subs, raids, deletions) lead with a faint tag.
if (!isMessageType) {
const tag = document.createElement('span');
tag.className = 'chat-viewer-tag';
tag.textContent = m.type || 'event';
row.appendChild(tag);
}
const time = formatChatTimeMarker(m);
if (time) {
const tSpan = document.createElement('span');
tSpan.className = 'chat-viewer-time';
tSpan.textContent = time;
row.appendChild(tSpan);
}
const user = m.u || m.user || m.login || '';
if (user) {
const uSpan = document.createElement('span');
uSpan.className = 'chat-viewer-user';
// Per-user IRC color overrides the default accent colour
// supplied by .chat-viewer-user; the class also sets weight.
if (m.color) uSpan.style.color = m.color;
uSpan.textContent = `${user}:`;
row.appendChild(uSpan);
}
const msgSpan = document.createElement('span');
msgSpan.textContent = ' ' + (m.msg || m.text || '');
row.appendChild(msgSpan);
fragment.appendChild(row);
}
list.appendChild(fragment);
idx = end;
if (idx < messages.length) {
window.setTimeout(renderChunk, 0);
}
};
renderChunk();
}
function closeTopmostOpenModal(): boolean {
// Try each known modal in priority order
const commandPaletteModal = document.getElementById('commandPaletteModal');
if (commandPaletteModal?.classList.contains('show')) {
const closeCp = (window as unknown as { closeCommandPalette?: () => void }).closeCommandPalette;
if (typeof closeCp === 'function') {
closeCp();
return true;
}
}
const eventsViewerModal = document.getElementById('eventsViewerModal');
if (eventsViewerModal?.classList.contains('show')) {
closeEventsViewer();
return true;
}
const chatViewerModal = document.getElementById('chatViewerModal');
if (chatViewerModal?.classList.contains('show')) {
closeChatViewer();
return true;
}
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 appVersion = '';
// Single source of truth for what the user is looking at — keeps the
// visible H1, the document title (which drives the OS task bar / Alt+Tab
// label), and the app version pill in sync. Previously document.title was
// stamped once at boot, so the OS task bar always read "Twitch VOD
// Manager v4.6.76" no matter what tab or streamer was active.
(window as unknown as { setPageTitle: (text: string) => void }).setPageTitle = setPageTitle;
function setPageTitle(text: string): void {
const titleEl = document.getElementById('pageTitle');
if (titleEl) titleEl.textContent = text;
const appName = UI_TEXT.appName;
const versionSuffix = appVersion ? ` v${appVersion}` : '';
document.title = text && text !== appName
? `${text} - ${appName}${versionSuffix}`
: `${appName}${versionSuffix}`;
}
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';
// Live region — screen readers announce the toast text whenever
// it changes. Warn toasts go through aria-live="assertive" so the
// reader interrupts whatever it was speaking; info toasts use
// "polite" so they wait for a natural break in current speech.
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.setAttribute('aria-atomic', 'true');
document.body.appendChild(toast);
}
toast.classList.remove('warn', 'show');
if (type === 'warn') {
toast.classList.add('warn');
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
} else {
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
}
// Setting textContent AFTER the aria-live attribute is in place
// ensures the change is captured as a live-region update by AT.
toast.textContent = message;
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();
}
}
// Must include every nav-item from index.html — otherwise:
// - Ctrl+N keyboard shortcut won't reach tabs past index 4
// - persistActiveTab silently no-ops, so the tab won't restore on reboot
// 'stats' (4.6.14) and 'archive' (4.6.15) were added to the nav but the
// const was never updated, leaving them effectively second-class tabs.
const TAB_IDS = ['vods', 'clips', 'cutter', 'merge', 'stats', 'archive', '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 {
const stored = safeLocalStorageGet(ACTIVE_TAB_STORAGE_KEY);
if (stored && isKnownTab(stored)) return stored;
return 'vods';
}
function persistActiveTab(tab: string): void {
if (!isKnownTab(tab)) return;
safeLocalStorageSet(ACTIVE_TAB_STORAGE_KEY, tab);
}
function showTab(tab: string): void {
queryAll('.nav-item').forEach((i) => {
i.classList.remove('active');
i.removeAttribute('aria-current');
});
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');
navItem.setAttribute('aria-current', 'page');
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)
const pageTitleText = (tab === 'vods' && currentStreamer)
? currentStreamer
: (titles[tab] || UI_TEXT.appName);
setPageTitle(pageTitleText);
persistActiveTab(tab);
if (tab === 'stats') {
const fn = (window as unknown as { refreshArchiveStats?: () => Promise<void> }).refreshArchiveStats;
if (typeof fn === 'function') void fn();
}
if (tab === 'archive') {
const init = (window as unknown as { initArchiveSearchInput?: () => void }).initArchiveSearchInput;
const search = (window as unknown as { performArchiveSearch?: () => Promise<void> }).performArchiveSearch;
if (typeof init === 'function') init();
if (typeof search === 'function') void search();
}
}
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.classList.toggle('shown', selected === 'template');
}
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');
const isValid = duration > 0;
durationDisplay.classList.toggle('invalid', !isValid);
durationDisplay.textContent = isValid
? formatSecondsToTime(duration)
: UI_TEXT.clips.invalidDuration;
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.className = 'template-lint ok';
clipLint.textContent = UI_TEXT.static.templateLintOk;
} else {
clipLint.className = 'template-lint warn';
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(UI_TEXT.clips.invalidTime);
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').classList.add('shown');
byId('timelineContainer').classList.add('shown');
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');
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewLoading)}</p></div>`);
const frame = await window.api.extractFrame(cutterFile, time);
if (frame) {
applyHtml(preview, `<img src="${escapeHtml(frame)}" alt="${escapeHtml(UI_TEXT.cutter.previewAlt)}">`);
return;
}
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(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) {
// Build via DOM API to keep the renderer clean of inline-styled
// HTML strings. The empty-state SVG is the same plus-icon the
// static HTML uses, just built programmatically.
list.replaceChildren();
const wrap = document.createElement('div');
wrap.className = 'empty-state merge-empty-state';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'currentColor');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z');
svg.appendChild(path);
wrap.appendChild(svg);
const p = document.createElement('p');
p.textContent = UI_TEXT.merge.empty;
wrap.appendChild(p);
list.appendChild(wrap);
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 type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveUpAria)}" title="${escapeHtml(UI_TEXT.merge.moveUpAria)}" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>&#9650;</button>
<button type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveDownAria)}" title="${escapeHtml(UI_TEXT.merge.moveDownAria)}" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>&#9660;</button>
<button type="button" class="file-btn remove" aria-label="${escapeHtml(UI_TEXT.merge.removeAria)}" title="${escapeHtml(UI_TEXT.merge.removeAria)}" 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();