feat: --twitch-disable-ads + queue context menu + remove redundant flag

Three Phase-12 wins.

1. Streamlink --twitch-disable-ads is now a setting (default on, since
   most users hit this — Twitch mid-roll ads otherwise get embedded
   into the VOD output as black-screen audio gaps). Off only when the
   user explicitly opts out via the new checkbox in Download Settings.
   Applied in downloadVODPart args; clip downloads are unaffected
   (Twitch clips do not carry mid-roll ads).

2. Right-click context menu on queue items. Items vary by status:
   pending/paused -> Move to top, Move to bottom; failed -> Retry;
   completed -> Open file (when 1 output) / Show in folder; always
   -> Copy URL, Open on Twitch, Remove from queue. Move-to-top/
   bottom calls existing reorderQueue IPC. Menu auto-dismisses on
   outside-click / Escape / scroll, repositions to stay inside the
   viewport.

3. Removed the global currentDownloadCancelled flag. It was a
   leftover from before per-item tracking — every site that set it
   (pause-download / cancel-download / remove-from-queue) already
   added every active item to cancelledItemIds via the activeDownloads
   loop. The four read sites (downloadVODPart close handler,
   processOneQueueItem retry-loop guard, processDownloadMergeGroup
   phase 1 and phase 3 guards, splitMergedFile loop) now check
   cancelledItemIds.has(itemId) directly. splitMergedFile reads
   from its itemId parameter (added in cycle 1) so the per-item
   intent threads through correctly. Net: -8 lines, one less
   global flag to reason about, no behaviour change for the
   intended cases (per-item cancel via remove + bulk cancel via
   pause/cancel both still work because they each populate the
   per-item set).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 20:09:33 +02:00
parent 80aa66e46d
commit 1f2b5e583c
4 changed files with 187 additions and 17 deletions

View File

@ -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<void> {
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<void> {
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);

View File

@ -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',

View File

@ -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',

View File

@ -132,6 +132,153 @@ async function retryQueueItem(id: string): Promise<void> {
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<void> {
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;
}