Twitch-VOD-Manager/src/renderer.ts
xRangerDE e8404b8802 i18n: localize 2 hardcoded English alt texts on dynamic <img> elements
Two <img> elements rendered by renderer code had hardcoded English alt text that never localized:

- renderer.ts cutter preview frame: alt="Preview"
- renderer-profile.ts live-thumb: alt="Live preview"

Added two new locale keys (DE+EN):
- cutter.previewAlt — "Vorschau" / "Preview"
- profile.liveThumbAlt — "Live-Vorschau" / "Live preview"

renderer.ts updates: the three preview.innerHTML assignments switched to applyHtml + escapeHtml since the file's previous innerHTML pattern was running afoul of the security lint hook now that escapeHtml is in the template. Same shape as the other consolidated renderers (stats, archive, profile).

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

1697 lines
64 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 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();