diff --git a/src/main.ts b/src/main.ts index e883004..f3b9074 100644 --- a/src/main.ts +++ b/src/main.ts @@ -207,6 +207,7 @@ interface Config { downloaded_vod_ids: string[]; streamlink_quality: string; notify_on_each_completion: boolean; + streamlink_disable_ads: boolean; } interface RuntimeMetrics { @@ -322,7 +323,8 @@ const defaultConfig: Config = { auto_resume_queue_on_startup: false, downloaded_vod_ids: [], streamlink_quality: 'best', - notify_on_each_completion: false + notify_on_each_completion: false, + streamlink_disable_ads: true }; // Whitelist of streamlink stream specifiers we surface in Settings. The @@ -391,7 +393,10 @@ function normalizeConfigTemplates(input: Config): Config { auto_resume_queue_on_startup: input.auto_resume_queue_on_startup === true, downloaded_vod_ids: trimmedIds, streamlink_quality: normalizeStreamlinkQuality(input.streamlink_quality), - notify_on_each_completion: input.notify_on_each_completion === true + notify_on_each_completion: input.notify_on_each_completion === true, + // Default-true on first launch (most users hit this), but respect + // an explicit `false` from the loaded config. + streamlink_disable_ads: input.streamlink_disable_ads !== false }; } @@ -694,7 +699,10 @@ let isDownloading = false; // and clip downloads via activeClipProcesses. Keeping these separate // prevents cancel-download from killing an unrelated cutter ffmpeg. let currentEditorProcess: ChildProcess | null = null; -let currentDownloadCancelled = false; +// Per-item cancellation lives in `cancelledItemIds`. The previous global +// `currentDownloadCancelled` flag was redundant once pause/cancel/remove +// started iterating activeDownloads and adding each item to that Set; it +// was removed in the 4.5.27 cleanup. let pauseRequested = false; let activeQueueItemId: string | null = null; let downloadStartTime = 0; @@ -2542,7 +2550,7 @@ async function splitMergedFile( const splitFiles: string[] = []; for (let i = 0; i < numParts; i++) { - if (currentDownloadCancelled) { + if (itemId && cancelledItemIds.has(itemId)) { return { success: false, files: splitFiles }; } @@ -2604,6 +2612,11 @@ function downloadVODPart( return new Promise((resolve) => { const streamlinkCmd = getStreamlinkCommand(); const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '-o', filename, '--force']; + if (config.streamlink_disable_ads !== false) { + // Skips Twitch mid-roll ads which would otherwise be embedded + // in the VOD output. Off only if the user explicitly disabled it. + args.push('--twitch-disable-ads'); + } let lastErrorLine = ''; const expectedDurationSeconds = parseClockDurationSeconds(endTime); let lastStreamlinkPercent = 0; @@ -2714,7 +2727,7 @@ function downloadVODPart( clearInterval(progressInterval); activeDownloads.delete(itemId); - if (currentDownloadCancelled || cancelledItemIds.has(itemId)) { + if (cancelledItemIds.has(itemId)) { cancelledItemIds.delete(itemId); appendDebugLog('download-part-cancelled', { itemId, filename }); resolve({ success: false, error: tBackend('downloadCancelled') }); @@ -2892,7 +2905,7 @@ async function downloadVOD( const downloadedFiles: string[] = []; for (let i = 0; i < numParts; i++) { - if (currentDownloadCancelled || cancelledItemIds.has(item.id)) break; + if (cancelledItemIds.has(item.id)) break; const partNum = clip.startPart + i; const startOffset = clip.startSec + (i * partDuration); @@ -2957,7 +2970,7 @@ async function downloadVOD( const downloadedFiles: string[] = []; for (let i = 0; i < numParts; i++) { - if (currentDownloadCancelled || cancelledItemIds.has(item.id)) break; + if (cancelledItemIds.has(item.id)) break; const startSec = i * partDuration; const endSec = Math.min((i + 1) * partDuration, totalDuration); @@ -3038,7 +3051,7 @@ async function processDownloadMergeGroup( } for (let i = 0; i < mg.items.length; i++) { - if (currentDownloadCancelled || cancelledItemIds.has(item.id)) { + if (cancelledItemIds.has(item.id)) { return { success: false, error: tBackend('downloadCancelled') }; } @@ -3150,7 +3163,7 @@ async function processDownloadMergeGroup( saveQueue(downloadQueue); emitQueueUpdated(); - if (currentDownloadCancelled || cancelledItemIds.has(item.id)) { + if (cancelledItemIds.has(item.id)) { return { success: false, error: tBackend('downloadCancelled') }; } @@ -3281,7 +3294,7 @@ async function processOneQueueItem(item: QueueItem): Promise { finalResult = result; - if (!isDownloading || currentDownloadCancelled || cancelledItemIds.has(item.id) || pauseRequested) { + if (!isDownloading || cancelledItemIds.has(item.id) || pauseRequested) { finalResult = { success: false, error: pauseRequested ? tBackend('downloadPaused') : tBackend('downloadCancelled') }; break; } @@ -3423,7 +3436,6 @@ async function processQueue(): Promise { isDownloading = true; pauseRequested = false; - currentDownloadCancelled = false; cancelledItemIds.clear(); mainWindow?.webContents.send('download-started'); emitQueueUpdated(); @@ -3948,7 +3960,6 @@ ipcMain.handle('remove-from-queue', (_, id: string) => { if (tracking?.process) { tracking.process.kill(); } - currentDownloadCancelled = true; activeDownloads.delete(id); activeQueueItemId = null; runtimeMetrics.activeItemId = null; @@ -4141,9 +4152,9 @@ ipcMain.handle('pause-download', () => { if (!isDownloading) return false; pauseRequested = true; - currentDownloadCancelled = true; // Kill queue downloads only — cutter/merger/splitter use currentEditorProcess - // and aren't affected by pause-download. + // and aren't affected by pause-download. Per-item cancel state lives in + // cancelledItemIds — every active item gets added below. for (const [id, tracking] of activeDownloads) { cancelledItemIds.add(id); if (tracking.process) { @@ -4156,7 +4167,6 @@ ipcMain.handle('pause-download', () => { ipcMain.handle('cancel-download', () => { isDownloading = false; pauseRequested = false; - currentDownloadCancelled = true; // Kill queue downloads only — see pause-download note above. for (const [id, tracking] of activeDownloads) { cancelledItemIds.add(id); diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 42beee3..936d8d6 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -189,7 +189,13 @@ const UI_TEXT_DE = { openFileFailed: 'Datei konnte nicht geoeffnet werden (evtl. verschoben oder geloescht).', outputFilesLabel: '{count} Ausgabedateien', retryItem: 'Diesen Eintrag erneut versuchen', - statusBarSummary: '{downloading} aktiv, {pending} wartet' + statusBarSummary: '{downloading} aktiv, {pending} wartet', + ctxMoveTop: 'Nach oben verschieben', + ctxMoveBottom: 'Nach unten verschieben', + ctxCopyUrl: 'URL kopieren', + ctxOpenOnTwitch: 'Auf Twitch oeffnen', + ctxRemove: 'Aus Queue entfernen', + ctxCopiedUrl: 'URL in Zwischenablage kopiert.' }, vods: { noneTitle: 'Keine VODs', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 71444e2..e5288cc 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -189,7 +189,13 @@ const UI_TEXT_EN = { openFileFailed: 'Could not open the file (it may have been moved or deleted).', outputFilesLabel: '{count} output files', retryItem: 'Retry this item', - statusBarSummary: '{downloading} dl, {pending} queued' + statusBarSummary: '{downloading} dl, {pending} queued', + ctxMoveTop: 'Move to top', + ctxMoveBottom: 'Move to bottom', + ctxCopyUrl: 'Copy URL', + ctxOpenOnTwitch: 'Open on Twitch', + ctxRemove: 'Remove from queue', + ctxCopiedUrl: 'URL copied to clipboard.' }, vods: { noneTitle: 'No VODs', diff --git a/src/renderer-queue.ts b/src/renderer-queue.ts index d9681b6..6a6de0c 100644 --- a/src/renderer-queue.ts +++ b/src/renderer-queue.ts @@ -132,6 +132,153 @@ async function retryQueueItem(id: string): Promise { renderQueue(); } +let queueContextMenuInitialized = false; +let activeQueueContextMenu: HTMLElement | null = null; + +function closeQueueContextMenu(): void { + if (!activeQueueContextMenu) return; + activeQueueContextMenu.remove(); + activeQueueContextMenu = null; +} + +function initQueueContextMenu(): void { + if (queueContextMenuInitialized) return; + queueContextMenuInitialized = true; + + const list = byId('queueList'); + list.addEventListener('contextmenu', (e: MouseEvent) => { + const itemEl = (e.target as HTMLElement).closest('.queue-item') as HTMLElement | null; + if (!itemEl) return; + const id = itemEl.dataset.id; + if (!id) return; + const item = queue.find((i) => i.id === id); + if (!item) return; + e.preventDefault(); + showQueueContextMenu(e.clientX, e.clientY, item); + }); +} + +function showQueueContextMenu(x: number, y: number, item: QueueItem): void { + closeQueueContextMenu(); + + const menu = document.createElement('div'); + menu.className = 'queue-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 makeItem = (label: string, onClick: () => void, disabled = false): HTMLElement => { + const el = document.createElement('div'); + el.textContent = label; + el.style.padding = '8px 12px'; + el.style.cursor = disabled ? 'not-allowed' : 'pointer'; + el.style.fontSize = '13px'; + el.style.color = disabled ? 'var(--text-secondary)' : 'var(--text)'; + el.style.borderRadius = '4px'; + el.style.opacity = disabled ? '0.55' : '1'; + if (!disabled) { + 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 { closeQueueContextMenu(); } + }); + } + return el; + }; + + const makeSeparator = (): HTMLElement => { + const sep = document.createElement('div'); + sep.style.height = '1px'; + sep.style.margin = '4px 6px'; + sep.style.background = 'var(--border-soft)'; + return sep; + }; + + const isPending = item.status === 'pending' || item.status === 'paused'; + const isFailed = item.status === 'error'; + const isCompleted = item.status === 'completed'; + + if (isPending) { + menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveTop, () => { void moveQueueItemTo(item.id, 'top'); })); + menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveBottom, () => { void moveQueueItemTo(item.id, 'bottom'); })); + menu.appendChild(makeSeparator()); + } + + if (isFailed) { + menu.appendChild(makeItem(UI_TEXT.queue.retryItem, () => { void retryQueueItem(item.id); })); + menu.appendChild(makeSeparator()); + } + + if (isCompleted && item.outputFiles && item.outputFiles.length > 0) { + const first = item.outputFiles[0]; + if (item.outputFiles.length === 1) { + menu.appendChild(makeItem(UI_TEXT.queue.openFile, () => { void window.api.openFile(first); })); + } + menu.appendChild(makeItem(UI_TEXT.queue.showInFolder, () => { void window.api.showInFolder(first); })); + menu.appendChild(makeSeparator()); + } + + menu.appendChild(makeItem(UI_TEXT.queue.ctxCopyUrl, () => { + try { + void navigator.clipboard.writeText(item.url); + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) toast(UI_TEXT.queue.ctxCopiedUrl, 'info'); + } catch { /* ignore */ } + })); + menu.appendChild(makeItem(UI_TEXT.queue.ctxOpenOnTwitch, () => { + void window.api.openExternal(item.url); + })); + menu.appendChild(makeSeparator()); + menu.appendChild(makeItem(UI_TEXT.queue.ctxRemove, () => { void removeFromQueue(item.id); })); + + document.body.appendChild(menu); + activeQueueContextMenu = menu; + + 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`; + + const dismissOnClick = (ev: MouseEvent) => { + if (!activeQueueContextMenu) return; + if (ev.target instanceof Node && activeQueueContextMenu.contains(ev.target)) return; + cleanup(); + }; + const dismissOnEscape = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') cleanup(); + }; + const dismissOnScroll = () => cleanup(); + const cleanup = (): void => { + closeQueueContextMenu(); + 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 moveQueueItemTo(id: string, where: 'top' | 'bottom'): Promise { + const idx = queue.findIndex((i) => i.id === id); + if (idx < 0) return; + const reordered = [...queue]; + const [moved] = reordered.splice(idx, 1); + if (where === 'top') reordered.unshift(moved); + else reordered.push(moved); + queue = reordered; + renderQueue(); + await window.api.reorderQueue(reordered.map((i) => i.id)); +} + function getQueueStatusLabel(item: QueueItem): string { if (item.status === 'completed') return UI_TEXT.queue.statusDone; if (item.status === 'error') return UI_TEXT.queue.statusFailed; @@ -394,6 +541,7 @@ function renderQueue(): void { }).join(''); updateMergeGroupButton(); + initQueueContextMenu(); lastQueueRenderFingerprint = renderFingerprint; }