diff --git a/src/main.ts b/src/main.ts index 4772ab8..f1efe26 100644 --- a/src/main.ts +++ b/src/main.ts @@ -675,7 +675,23 @@ let downloadedBytes = 0; // Per-item tracking for parallel downloads const activeDownloads = new Map(); const cancelledItemIds = new Set(); +// userId -> login reverse map. Bounded via Map insertion-order eviction so +// a long-running session doesn't grow it unbounded across thousands of +// streamer lookups. Values are short (~20 char each) but accumulate. +const USER_ID_LOGIN_CACHE_MAX = 4096; const userIdLoginCache = new Map(); +function setUserIdLogin(userId: string, login: string): void { + if (!userId || !login) return; + if (userIdLoginCache.has(userId)) { + userIdLoginCache.delete(userId); + } + userIdLoginCache.set(userId, login); + while (userIdLoginCache.size > USER_ID_LOGIN_CACHE_MAX) { + const oldest = userIdLoginCache.keys().next().value as string | undefined; + if (!oldest) break; + userIdLoginCache.delete(oldest); + } +} const loginToUserIdCache = new Map>(); const vodListCache = new Map>(); const clipInfoCache = new Map>(); @@ -1484,6 +1500,40 @@ function emitQueueUpdated(force = false): void { lastQueueBroadcastFingerprint = nextFingerprint; mainWindow?.webContents.send('queue-updated', downloadQueue); + updateTaskbarProgress(); +} + +// Per-item taskbar progress is tracked here because main's downloadQueue +// items don't update their .progress field mid-download (only the renderer +// gets a stream of progress events). Map is cleared in processOneQueueItem.finally. +const activeDownloadProgress = new Map(); + +function recordDownloadProgress(progress: DownloadProgress): void { + const p = Number(progress.progress); + const fraction = Number.isFinite(p) && p > 0 && p <= 100 ? p / 100 : 0.3; + activeDownloadProgress.set(progress.id, fraction); + updateTaskbarProgress(); +} + +function clearDownloadProgress(itemId: string): void { + activeDownloadProgress.delete(itemId); + updateTaskbarProgress(); +} + +// Aggregate progress across all currently-downloading items, mapped to the +// Windows taskbar progress indicator (-1 = no progress, 0..1 = fraction). +// Visible whenever the user has minimised / collapsed the window. Indeterminate +// downloads (no percentage yet) report a 30% bar so the taskbar still shows +// activity instead of going cold. +function updateTaskbarProgress(): void { + if (!mainWindow || mainWindow.isDestroyed()) return; + const entries = Array.from(activeDownloadProgress.values()); + if (entries.length === 0) { + try { mainWindow.setProgressBar(-1); } catch { /* unsupported on some platforms */ } + return; + } + const avg = entries.reduce((s, v) => s + v, 0) / entries.length; + try { mainWindow.setProgressBar(Math.max(0, Math.min(1, avg))); } catch { /* ignore */ } } function hasQueueItemId(id: string): boolean { @@ -1826,7 +1876,7 @@ async function getPublicUserId(username: string): Promise { if (!user?.id) return null; setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES); - userIdLoginCache.set(user.id, user.login || login); + setUserIdLogin(user.id, user.login || login); return user.id; } @@ -1919,7 +1969,7 @@ async function getUserId(username: string): Promise { if (!user?.id) return await getUserViaPublicApi(); setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES); - userIdLoginCache.set(user.id, user.login || login); + setUserIdLogin(user.id, user.login || login); return user.id; } catch (e) { if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { @@ -1929,7 +1979,7 @@ async function getUserId(username: string): Promise { if (!user?.id) return await getUserViaPublicApi(); setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES); - userIdLoginCache.set(user.id, user.login || login); + setUserIdLogin(user.id, user.login || login); return user.id; } catch (retryError) { console.error('Error getting user after relogin:', retryError); @@ -2009,7 +2059,7 @@ async function getVODs(userId: string, forceRefresh = false): Promise { if (pageCount === 0) { const login = pageVods[0]?.user_login; if (login) { - userIdLoginCache.set(userId, normalizeLogin(login)); + setUserIdLogin(userId, normalizeLogin(login)); } } @@ -3190,9 +3240,11 @@ async function processOneQueueItem(item: QueueItem): Promise { const result = item.mergeGroup ? await processDownloadMergeGroup(item, (progress) => { mainWindow?.webContents.send('download-progress', progress); + recordDownloadProgress(progress); }) : await downloadVOD(item, (progress) => { mainWindow?.webContents.send('download-progress', progress); + recordDownloadProgress(progress); }); if (result.success) { @@ -3294,6 +3346,7 @@ async function processOneQueueItem(item: QueueItem): Promise { cancelledItemIds.delete(item.id); // Release only THIS item's claimed filenames (other parallel downloads keep their claims) releaseClaimedFilenamesForItem(item.id); + clearDownloadProgress(item.id); } } @@ -4276,6 +4329,22 @@ ipcMain.handle('export-runtime-metrics', async () => { } }); +ipcMain.handle('mark-vod-downloaded', (_, vodId: string, mark: boolean): { success: boolean } => { + if (typeof vodId !== 'string' || !vodId) return { success: false }; + if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = []; + const has = config.downloaded_vod_ids.includes(vodId); + if (mark && !has) { + config.downloaded_vod_ids.push(vodId); + } else if (!mark && has) { + config.downloaded_vod_ids = config.downloaded_vod_ids.filter((id) => id !== vodId); + } else { + return { success: true }; + } + saveConfig(config); + appendDebugLog('mark-vod-downloaded', { vodId, mark }); + return { success: true }; +}); + ipcMain.handle('reset-downloaded-vod-ids', () => { const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0; config.downloaded_vod_ids = []; diff --git a/src/preload.ts b/src/preload.ts index 95400d4..7446c92 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -111,6 +111,8 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.invoke('export-runtime-metrics'), resetDownloadedVodIds: (): Promise<{ success: boolean; removedCount: number }> => ipcRenderer.invoke('reset-downloaded-vod-ids'), + markVodDownloaded: (vodId: string, mark: boolean): Promise<{ success: boolean }> => + ipcRenderer.invoke('mark-vod-downloaded', vodId, mark), exportConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> => ipcRenderer.invoke('export-config'), importConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> => diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index 7afe690..5124f42 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -218,6 +218,7 @@ interface ApiBridge { getRuntimeMetrics(): Promise; exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>; resetDownloadedVodIds(): Promise<{ success: boolean; removedCount: number }>; + markVodDownloaded(vodId: string, mark: boolean): Promise<{ success: boolean }>; exportConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>; importConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>; onDownloadProgress(callback: (progress: DownloadProgress) => void): void; diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index bb72e82..a110e70 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -207,7 +207,13 @@ const UI_TEXT_DE = { bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).', alreadyDownloaded: 'Bereits heruntergeladen', hideDownloaded: 'Bereits geladene ausblenden', - hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind' + hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind', + openOnTwitch: 'Auf Twitch oeffnen', + ctxOpenOnTwitch: 'Auf Twitch oeffnen', + ctxCopyUrl: 'VOD-URL kopieren', + ctxCopiedUrl: 'URL in Zwischenablage kopiert.', + ctxMarkDownloaded: 'Als heruntergeladen markieren', + ctxUnmarkDownloaded: 'Markierung entfernen' }, clips: { dialogTitle: 'VOD zuschneiden', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 4235437..1e9ee0b 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -207,7 +207,13 @@ const UI_TEXT_EN = { bulkAddSkipped: 'No VODs were added (already in queue or invalid).', alreadyDownloaded: 'Already downloaded', hideDownloaded: 'Hide downloaded', - hideDownloadedTitle: 'Hide VODs that are marked as already downloaded' + hideDownloadedTitle: 'Hide VODs that are marked as already downloaded', + openOnTwitch: 'Open on Twitch', + ctxOpenOnTwitch: 'Open on Twitch', + ctxCopyUrl: 'Copy VOD URL', + ctxCopiedUrl: 'URL copied to clipboard.', + ctxMarkDownloaded: 'Mark as downloaded', + ctxUnmarkDownloaded: 'Unmark downloaded' }, clips: { dialogTitle: 'Trim VOD', diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index ac0e75f..beaff1f 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -199,36 +199,73 @@ function focusVodFilter(): void { function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set): string { const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180'); const date = formatUiDate(vod.created_at); - const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '"'); const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled); const safeUrlAttr = escapeHtml(vod.url); + const safeTitleAttr = escapeHtml(vod.title || ''); + const safeStreamerAttr = escapeHtml(streamer); + const safeDateAttr = escapeHtml(vod.created_at); + const safeDurationAttr = escapeHtml(vod.duration); + const safeIdAttr = escapeHtml(vod.id); const isChecked = selectedVodUrls.has(vod.url); const isAlreadyDownloaded = downloadedIds ? downloadedIds.has(vod.id) : false; const downloadedBadge = isAlreadyDownloaded ? `
` : ''; + // All identity attributes go on data-* — a delegated listener on #vodGrid + // reads them at click time. This removes the previous inline-onclick + // template-injection pattern (escapedTitle dance) which was fragile for + // titles containing backslashes / HTML entities like '. return ` -
- +
+ ${downloadedBadge} - +
${safeDisplayTitle}
${date} - ${vod.duration} - ${formatUiNumber(vod.view_count)} ${UI_TEXT.vods.views} + ${escapeHtml(vod.duration)} + ${formatUiNumber(vod.view_count)} ${escapeHtml(UI_TEXT.vods.views)}
- - + +
`; } +interface VodCardContext { + id: string; + url: string; + title: string; + date: string; + streamer: string; + duration: string; +} + +function readVodCardContext(card: HTMLElement | null): VodCardContext | null { + if (!card) return null; + const url = card.dataset.vodUrl; + if (!url) return null; + return { + id: card.dataset.vodId || '', + url, + title: card.dataset.vodTitle || '', + date: card.dataset.vodDate || '', + streamer: card.dataset.vodStreamer || '', + duration: card.dataset.vodDuration || '' + }; +} + let streamerDragInitialized = false; let draggedStreamerName: string | null = null; @@ -434,19 +471,167 @@ function initVodGridSelectionDelegation(): void { const grid = document.getElementById('vodGrid'); if (!grid) return; + grid.addEventListener('click', (e) => { const target = e.target as HTMLElement; - if (!(target instanceof HTMLInputElement)) return; - if (!target.classList.contains('vod-select-checkbox')) return; - const url = target.dataset.vodUrl || ''; - if (!url) return; - if (target.checked) selectedVodUrls.add(url); - else selectedVodUrls.delete(url); - // Keep card visual + bar in sync without a full re-render of all cards + // 1) Checkbox toggles (bulk-select) + if (target instanceof HTMLInputElement && target.classList.contains('vod-select-checkbox')) { + const url = target.dataset.vodUrl || ''; + if (!url) return; + if (target.checked) selectedVodUrls.add(url); + else selectedVodUrls.delete(url); + const card = target.closest('.vod-card') as HTMLElement | null; + if (card) card.classList.toggle('selected', target.checked); + updateVodBulkBar(); + return; + } + + // 2) Action buttons (trim / queue) — replaces the previous inline + // onclick template that mangled titles with special characters + const btn = target.closest('button[data-vod-action]') as HTMLButtonElement | null; + if (btn) { + const ctx = readVodCardContext(btn.closest('.vod-card') as HTMLElement | null); + if (!ctx) return; + if (btn.dataset.vodAction === 'trim') { + openClipDialog(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration); + } else if (btn.dataset.vodAction === 'queue') { + void addToQueue(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration); + } + return; + } + + // 3) Click on thumbnail / title / meta -> open VOD on Twitch in the + // OS default browser. Convenient + non-destructive. const card = target.closest('.vod-card') as HTMLElement | null; - if (card) card.classList.toggle('selected', target.checked); - updateVodBulkBar(); + if (!card) return; + if (target.closest('.vod-actions') || target.classList.contains('vod-select-checkbox')) return; + const ctx = readVodCardContext(card); + if (!ctx) return; + void window.api.openExternal(ctx.url); }); + + grid.addEventListener('contextmenu', (e) => { + const card = (e.target as HTMLElement).closest('.vod-card') as HTMLElement | null; + if (!card) return; + const ctx = readVodCardContext(card); + if (!ctx) return; + e.preventDefault(); + showVodContextMenu(e.clientX, e.clientY, ctx); + }); +} + +let activeVodContextMenu: HTMLElement | null = null; + +function closeVodContextMenu(): void { + if (!activeVodContextMenu) return; + activeVodContextMenu.remove(); + activeVodContextMenu = null; +} + +function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void { + closeVodContextMenu(); + + const menu = document.createElement('div'); + menu.className = 'vod-context-menu'; + menu.style.position = 'fixed'; + menu.style.zIndex = '9999'; + menu.style.background = 'var(--bg-card)'; + menu.style.border = '1px solid var(--border-soft)'; + menu.style.borderRadius = '6px'; + menu.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)'; + menu.style.padding = '4px'; + menu.style.minWidth = '200px'; + + const downloadedIds = new Set( + Array.isArray(config.downloaded_vod_ids) + ? (config.downloaded_vod_ids as string[]).filter((id) => typeof id === 'string') + : [] + ); + const isMarkedDownloaded = downloadedIds.has(ctx.id); + + const makeItem = (label: string, onClick: () => void): HTMLElement => { + const el = document.createElement('div'); + el.textContent = label; + el.style.padding = '8px 12px'; + el.style.cursor = 'pointer'; + el.style.fontSize = '13px'; + el.style.color = 'var(--text)'; + el.style.borderRadius = '4px'; + el.addEventListener('mouseenter', () => { el.style.background = 'rgba(145,70,255,0.15)'; }); + el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; }); + el.addEventListener('click', () => { + try { onClick(); } finally { closeVodContextMenu(); } + }); + return el; + }; + + menu.appendChild(makeItem(UI_TEXT.vods.ctxOpenOnTwitch, () => { + void window.api.openExternal(ctx.url); + })); + menu.appendChild(makeItem(UI_TEXT.vods.ctxCopyUrl, () => { + try { + void navigator.clipboard.writeText(ctx.url); + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) toast(UI_TEXT.vods.ctxCopiedUrl, 'info'); + } catch { /* ignore */ } + })); + menu.appendChild(makeItem(UI_TEXT.vods.trimButton, () => { + openClipDialog(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration); + })); + menu.appendChild(makeItem(UI_TEXT.vods.addQueue, () => { + void addToQueue(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration); + })); + menu.appendChild(makeItem( + isMarkedDownloaded ? UI_TEXT.vods.ctxUnmarkDownloaded : UI_TEXT.vods.ctxMarkDownloaded, + () => { void toggleVodDownloadedMark(ctx.id, !isMarkedDownloaded); } + )); + + document.body.appendChild(menu); + activeVodContextMenu = menu; + + // Reposition if it would clip off the viewport + const rect = menu.getBoundingClientRect(); + let left = x; + let top = y; + if (left + rect.width > window.innerWidth - 4) left = Math.max(4, window.innerWidth - rect.width - 4); + if (top + rect.height > window.innerHeight - 4) top = Math.max(4, window.innerHeight - rect.height - 4); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + + // Close on click anywhere else / Escape / scroll + const dismissOnClick = (ev: MouseEvent) => { + if (!activeVodContextMenu) return; + if (ev.target instanceof Node && activeVodContextMenu.contains(ev.target)) return; + closeVodContextMenu(); + document.removeEventListener('mousedown', dismissOnClick, true); + document.removeEventListener('keydown', dismissOnEscape, true); + document.removeEventListener('scroll', dismissOnScroll, true); + }; + const dismissOnEscape = (ev: KeyboardEvent) => { + if (ev.key !== 'Escape') return; + closeVodContextMenu(); + document.removeEventListener('mousedown', dismissOnClick, true); + document.removeEventListener('keydown', dismissOnEscape, true); + document.removeEventListener('scroll', dismissOnScroll, true); + }; + const dismissOnScroll = () => { + closeVodContextMenu(); + document.removeEventListener('mousedown', dismissOnClick, true); + document.removeEventListener('keydown', dismissOnEscape, true); + document.removeEventListener('scroll', dismissOnScroll, true); + }; + document.addEventListener('mousedown', dismissOnClick, true); + document.addEventListener('keydown', dismissOnEscape, true); + document.addEventListener('scroll', dismissOnScroll, true); +} + +async function toggleVodDownloadedMark(vodId: string, mark: boolean): Promise { + const result = await window.api.markVodDownloaded(vodId, mark); + if (!result?.success) return; + try { + config = await window.api.getConfig(); + } catch { /* ignore */ } + if (lastLoadedStreamer) renderVodGridFromCurrentState(); } function updateVodBulkBar(): void {