Compare commits
No commits in common. "092932d8d5bf792e751a79c6c01b90520d2a1806" and "80aa66e46dff0a6f89472b7cf2dcea83a2a2b1b9" have entirely different histories.
092932d8d5
...
80aa66e46d
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.5.27",
|
"version": "4.5.26",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.5.27",
|
"version": "4.5.26",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.5.27",
|
"version": "4.5.26",
|
||||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"author": "xRangerDE",
|
"author": "xRangerDE",
|
||||||
|
|||||||
@ -521,10 +521,6 @@
|
|||||||
<input type="checkbox" id="notifyEachCompletionToggle">
|
<input type="checkbox" id="notifyEachCompletionToggle">
|
||||||
<span id="notifyEachCompletionLabel">Benachrichtigung bei jedem fertigen Download</span>
|
<span id="notifyEachCompletionLabel">Benachrichtigung bei jedem fertigen Download</span>
|
||||||
</label>
|
</label>
|
||||||
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
|
|
||||||
<input type="checkbox" id="streamlinkDisableAdsToggle" checked>
|
|
||||||
<span id="streamlinkDisableAdsLabel">Twitch-Ads beim Download ueberspringen</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
|
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
|
||||||
|
|||||||
40
src/main.ts
40
src/main.ts
@ -207,7 +207,6 @@ interface Config {
|
|||||||
downloaded_vod_ids: string[];
|
downloaded_vod_ids: string[];
|
||||||
streamlink_quality: string;
|
streamlink_quality: string;
|
||||||
notify_on_each_completion: boolean;
|
notify_on_each_completion: boolean;
|
||||||
streamlink_disable_ads: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuntimeMetrics {
|
interface RuntimeMetrics {
|
||||||
@ -323,8 +322,7 @@ const defaultConfig: Config = {
|
|||||||
auto_resume_queue_on_startup: false,
|
auto_resume_queue_on_startup: false,
|
||||||
downloaded_vod_ids: [],
|
downloaded_vod_ids: [],
|
||||||
streamlink_quality: 'best',
|
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
|
// Whitelist of streamlink stream specifiers we surface in Settings. The
|
||||||
@ -393,10 +391,7 @@ function normalizeConfigTemplates(input: Config): Config {
|
|||||||
auto_resume_queue_on_startup: input.auto_resume_queue_on_startup === true,
|
auto_resume_queue_on_startup: input.auto_resume_queue_on_startup === true,
|
||||||
downloaded_vod_ids: trimmedIds,
|
downloaded_vod_ids: trimmedIds,
|
||||||
streamlink_quality: normalizeStreamlinkQuality(input.streamlink_quality),
|
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
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -699,10 +694,7 @@ let isDownloading = false;
|
|||||||
// and clip downloads via activeClipProcesses. Keeping these separate
|
// and clip downloads via activeClipProcesses. Keeping these separate
|
||||||
// prevents cancel-download from killing an unrelated cutter ffmpeg.
|
// prevents cancel-download from killing an unrelated cutter ffmpeg.
|
||||||
let currentEditorProcess: ChildProcess | null = null;
|
let currentEditorProcess: ChildProcess | null = null;
|
||||||
// Per-item cancellation lives in `cancelledItemIds`. The previous global
|
let currentDownloadCancelled = false;
|
||||||
// `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 pauseRequested = false;
|
||||||
let activeQueueItemId: string | null = null;
|
let activeQueueItemId: string | null = null;
|
||||||
let downloadStartTime = 0;
|
let downloadStartTime = 0;
|
||||||
@ -2550,7 +2542,7 @@ async function splitMergedFile(
|
|||||||
const splitFiles: string[] = [];
|
const splitFiles: string[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < numParts; i++) {
|
for (let i = 0; i < numParts; i++) {
|
||||||
if (itemId && cancelledItemIds.has(itemId)) {
|
if (currentDownloadCancelled) {
|
||||||
return { success: false, files: splitFiles };
|
return { success: false, files: splitFiles };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2612,11 +2604,6 @@ function downloadVODPart(
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const streamlinkCmd = getStreamlinkCommand();
|
const streamlinkCmd = getStreamlinkCommand();
|
||||||
const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '-o', filename, '--force'];
|
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 = '';
|
let lastErrorLine = '';
|
||||||
const expectedDurationSeconds = parseClockDurationSeconds(endTime);
|
const expectedDurationSeconds = parseClockDurationSeconds(endTime);
|
||||||
let lastStreamlinkPercent = 0;
|
let lastStreamlinkPercent = 0;
|
||||||
@ -2727,7 +2714,7 @@ function downloadVODPart(
|
|||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
activeDownloads.delete(itemId);
|
activeDownloads.delete(itemId);
|
||||||
|
|
||||||
if (cancelledItemIds.has(itemId)) {
|
if (currentDownloadCancelled || cancelledItemIds.has(itemId)) {
|
||||||
cancelledItemIds.delete(itemId);
|
cancelledItemIds.delete(itemId);
|
||||||
appendDebugLog('download-part-cancelled', { itemId, filename });
|
appendDebugLog('download-part-cancelled', { itemId, filename });
|
||||||
resolve({ success: false, error: tBackend('downloadCancelled') });
|
resolve({ success: false, error: tBackend('downloadCancelled') });
|
||||||
@ -2905,7 +2892,7 @@ async function downloadVOD(
|
|||||||
const downloadedFiles: string[] = [];
|
const downloadedFiles: string[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < numParts; i++) {
|
for (let i = 0; i < numParts; i++) {
|
||||||
if (cancelledItemIds.has(item.id)) break;
|
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) break;
|
||||||
|
|
||||||
const partNum = clip.startPart + i;
|
const partNum = clip.startPart + i;
|
||||||
const startOffset = clip.startSec + (i * partDuration);
|
const startOffset = clip.startSec + (i * partDuration);
|
||||||
@ -2970,7 +2957,7 @@ async function downloadVOD(
|
|||||||
const downloadedFiles: string[] = [];
|
const downloadedFiles: string[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < numParts; i++) {
|
for (let i = 0; i < numParts; i++) {
|
||||||
if (cancelledItemIds.has(item.id)) break;
|
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) break;
|
||||||
|
|
||||||
const startSec = i * partDuration;
|
const startSec = i * partDuration;
|
||||||
const endSec = Math.min((i + 1) * partDuration, totalDuration);
|
const endSec = Math.min((i + 1) * partDuration, totalDuration);
|
||||||
@ -3051,7 +3038,7 @@ async function processDownloadMergeGroup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < mg.items.length; i++) {
|
for (let i = 0; i < mg.items.length; i++) {
|
||||||
if (cancelledItemIds.has(item.id)) {
|
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
|
||||||
return { success: false, error: tBackend('downloadCancelled') };
|
return { success: false, error: tBackend('downloadCancelled') };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3163,7 +3150,7 @@ async function processDownloadMergeGroup(
|
|||||||
saveQueue(downloadQueue);
|
saveQueue(downloadQueue);
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
|
|
||||||
if (cancelledItemIds.has(item.id)) {
|
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
|
||||||
return { success: false, error: tBackend('downloadCancelled') };
|
return { success: false, error: tBackend('downloadCancelled') };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3294,7 +3281,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
|||||||
|
|
||||||
finalResult = result;
|
finalResult = result;
|
||||||
|
|
||||||
if (!isDownloading || cancelledItemIds.has(item.id) || pauseRequested) {
|
if (!isDownloading || currentDownloadCancelled || cancelledItemIds.has(item.id) || pauseRequested) {
|
||||||
finalResult = { success: false, error: pauseRequested ? tBackend('downloadPaused') : tBackend('downloadCancelled') };
|
finalResult = { success: false, error: pauseRequested ? tBackend('downloadPaused') : tBackend('downloadCancelled') };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -3436,6 +3423,7 @@ async function processQueue(): Promise<void> {
|
|||||||
|
|
||||||
isDownloading = true;
|
isDownloading = true;
|
||||||
pauseRequested = false;
|
pauseRequested = false;
|
||||||
|
currentDownloadCancelled = false;
|
||||||
cancelledItemIds.clear();
|
cancelledItemIds.clear();
|
||||||
mainWindow?.webContents.send('download-started');
|
mainWindow?.webContents.send('download-started');
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
@ -3960,6 +3948,7 @@ ipcMain.handle('remove-from-queue', (_, id: string) => {
|
|||||||
if (tracking?.process) {
|
if (tracking?.process) {
|
||||||
tracking.process.kill();
|
tracking.process.kill();
|
||||||
}
|
}
|
||||||
|
currentDownloadCancelled = true;
|
||||||
activeDownloads.delete(id);
|
activeDownloads.delete(id);
|
||||||
activeQueueItemId = null;
|
activeQueueItemId = null;
|
||||||
runtimeMetrics.activeItemId = null;
|
runtimeMetrics.activeItemId = null;
|
||||||
@ -4152,9 +4141,9 @@ ipcMain.handle('pause-download', () => {
|
|||||||
if (!isDownloading) return false;
|
if (!isDownloading) return false;
|
||||||
|
|
||||||
pauseRequested = true;
|
pauseRequested = true;
|
||||||
|
currentDownloadCancelled = true;
|
||||||
// Kill queue downloads only — cutter/merger/splitter use currentEditorProcess
|
// Kill queue downloads only — cutter/merger/splitter use currentEditorProcess
|
||||||
// and aren't affected by pause-download. Per-item cancel state lives in
|
// and aren't affected by pause-download.
|
||||||
// cancelledItemIds — every active item gets added below.
|
|
||||||
for (const [id, tracking] of activeDownloads) {
|
for (const [id, tracking] of activeDownloads) {
|
||||||
cancelledItemIds.add(id);
|
cancelledItemIds.add(id);
|
||||||
if (tracking.process) {
|
if (tracking.process) {
|
||||||
@ -4167,6 +4156,7 @@ ipcMain.handle('pause-download', () => {
|
|||||||
ipcMain.handle('cancel-download', () => {
|
ipcMain.handle('cancel-download', () => {
|
||||||
isDownloading = false;
|
isDownloading = false;
|
||||||
pauseRequested = false;
|
pauseRequested = false;
|
||||||
|
currentDownloadCancelled = true;
|
||||||
// Kill queue downloads only — see pause-download note above.
|
// Kill queue downloads only — see pause-download note above.
|
||||||
for (const [id, tracking] of activeDownloads) {
|
for (const [id, tracking] of activeDownloads) {
|
||||||
cancelledItemIds.add(id);
|
cancelledItemIds.add(id);
|
||||||
|
|||||||
1
src/renderer-globals.d.ts
vendored
1
src/renderer-globals.d.ts
vendored
@ -20,7 +20,6 @@ interface AppConfig {
|
|||||||
downloaded_vod_ids?: string[];
|
downloaded_vod_ids?: string[];
|
||||||
streamlink_quality?: string;
|
streamlink_quality?: string;
|
||||||
notify_on_each_completion?: boolean;
|
notify_on_each_completion?: boolean;
|
||||||
streamlink_disable_ads?: boolean;
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -71,8 +71,6 @@ const UI_TEXT_DE = {
|
|||||||
autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.',
|
autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.',
|
||||||
notifyEachCompletionLabel: 'Benachrichtigung bei jedem fertigen Download',
|
notifyEachCompletionLabel: 'Benachrichtigung bei jedem fertigen Download',
|
||||||
notifyEachCompletionHint: 'Standardmaessig aus — bei langen Queues wuerde das System-Notifications-Panel sonst zugespammt. Die Queue-End-Zusammenfassung erscheint trotzdem.',
|
notifyEachCompletionHint: 'Standardmaessig aus — bei langen Queues wuerde das System-Notifications-Panel sonst zugespammt. Die Queue-End-Zusammenfassung erscheint trotzdem.',
|
||||||
streamlinkDisableAdsLabel: 'Twitch-Ads beim Download ueberspringen',
|
|
||||||
streamlinkDisableAdsHint: 'Gibt --twitch-disable-ads an streamlink weiter, damit Mid-Roll-Ads nicht ins VOD eingebettet werden. Empfohlen aktiv lassen.',
|
|
||||||
streamlinkQualityLabel: 'Stream-Qualitaet',
|
streamlinkQualityLabel: 'Stream-Qualitaet',
|
||||||
streamlinkQualityHint: 'Streamlink versucht erst diese Qualitaet; falls das VOD sie nicht anbietet, faellt es auf "best" zurueck.',
|
streamlinkQualityHint: 'Streamlink versucht erst diese Qualitaet; falls das VOD sie nicht anbietet, faellt es auf "best" zurueck.',
|
||||||
streamlinkQualityBest: 'Best (Standard)',
|
streamlinkQualityBest: 'Best (Standard)',
|
||||||
@ -191,13 +189,7 @@ const UI_TEXT_DE = {
|
|||||||
openFileFailed: 'Datei konnte nicht geoeffnet werden (evtl. verschoben oder geloescht).',
|
openFileFailed: 'Datei konnte nicht geoeffnet werden (evtl. verschoben oder geloescht).',
|
||||||
outputFilesLabel: '{count} Ausgabedateien',
|
outputFilesLabel: '{count} Ausgabedateien',
|
||||||
retryItem: 'Diesen Eintrag erneut versuchen',
|
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: {
|
vods: {
|
||||||
noneTitle: 'Keine VODs',
|
noneTitle: 'Keine VODs',
|
||||||
|
|||||||
@ -71,8 +71,6 @@ const UI_TEXT_EN = {
|
|||||||
autoResumeQueueHint: 'When enabled and the persisted queue has pending entries, downloads kick off ~5 seconds after the window opens. Disable to require an explicit Start click.',
|
autoResumeQueueHint: 'When enabled and the persisted queue has pending entries, downloads kick off ~5 seconds after the window opens. Disable to require an explicit Start click.',
|
||||||
notifyEachCompletionLabel: 'Notify on every completed download',
|
notifyEachCompletionLabel: 'Notify on every completed download',
|
||||||
notifyEachCompletionHint: 'Off by default — long queues would otherwise spam the OS notifications panel. The end-of-queue summary notification fires either way.',
|
notifyEachCompletionHint: 'Off by default — long queues would otherwise spam the OS notifications panel. The end-of-queue summary notification fires either way.',
|
||||||
streamlinkDisableAdsLabel: 'Skip Twitch ads while downloading',
|
|
||||||
streamlinkDisableAdsHint: 'Passes --twitch-disable-ads to streamlink so mid-roll ads do not get embedded into the VOD output. Recommended on.',
|
|
||||||
streamlinkQualityLabel: 'Stream quality',
|
streamlinkQualityLabel: 'Stream quality',
|
||||||
streamlinkQualityHint: 'Streamlink will try this quality first; if the VOD does not offer it, falls back to "best".',
|
streamlinkQualityHint: 'Streamlink will try this quality first; if the VOD does not offer it, falls back to "best".',
|
||||||
streamlinkQualityBest: 'Best (default)',
|
streamlinkQualityBest: 'Best (default)',
|
||||||
@ -191,13 +189,7 @@ const UI_TEXT_EN = {
|
|||||||
openFileFailed: 'Could not open the file (it may have been moved or deleted).',
|
openFileFailed: 'Could not open the file (it may have been moved or deleted).',
|
||||||
outputFilesLabel: '{count} output files',
|
outputFilesLabel: '{count} output files',
|
||||||
retryItem: 'Retry this item',
|
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: {
|
vods: {
|
||||||
noneTitle: 'No VODs',
|
noneTitle: 'No VODs',
|
||||||
|
|||||||
@ -132,153 +132,6 @@ async function retryQueueItem(id: string): Promise<void> {
|
|||||||
renderQueue();
|
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 {
|
function getQueueStatusLabel(item: QueueItem): string {
|
||||||
if (item.status === 'completed') return UI_TEXT.queue.statusDone;
|
if (item.status === 'completed') return UI_TEXT.queue.statusDone;
|
||||||
if (item.status === 'error') return UI_TEXT.queue.statusFailed;
|
if (item.status === 'error') return UI_TEXT.queue.statusFailed;
|
||||||
@ -541,7 +394,6 @@ function renderQueue(): void {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
updateMergeGroupButton();
|
updateMergeGroupButton();
|
||||||
initQueueContextMenu();
|
|
||||||
lastQueueRenderFingerprint = renderFingerprint;
|
lastQueueRenderFingerprint = renderFingerprint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -388,7 +388,6 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
|||||||
persist_queue_on_restart: byId<HTMLInputElement>('persistQueueToggle').checked,
|
persist_queue_on_restart: byId<HTMLInputElement>('persistQueueToggle').checked,
|
||||||
auto_resume_queue_on_startup: byId<HTMLInputElement>('autoResumeQueueToggle').checked,
|
auto_resume_queue_on_startup: byId<HTMLInputElement>('autoResumeQueueToggle').checked,
|
||||||
notify_on_each_completion: byId<HTMLInputElement>('notifyEachCompletionToggle').checked,
|
notify_on_each_completion: byId<HTMLInputElement>('notifyEachCompletionToggle').checked,
|
||||||
streamlink_disable_ads: byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked,
|
|
||||||
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value,
|
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value,
|
||||||
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
|
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
|
||||||
};
|
};
|
||||||
@ -434,7 +433,6 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
|||||||
effective.persist_queue_on_restart !== false,
|
effective.persist_queue_on_restart !== false,
|
||||||
effective.auto_resume_queue_on_startup === true,
|
effective.auto_resume_queue_on_startup === true,
|
||||||
effective.notify_on_each_completion === true,
|
effective.notify_on_each_completion === true,
|
||||||
effective.streamlink_disable_ads !== false,
|
|
||||||
effective.streamlink_quality ?? 'best',
|
effective.streamlink_quality ?? 'best',
|
||||||
effective.metadata_cache_minutes ?? 10,
|
effective.metadata_cache_minutes ?? 10,
|
||||||
effective.filename_template_vod ?? '{title}.mp4',
|
effective.filename_template_vod ?? '{title}.mp4',
|
||||||
@ -455,7 +453,6 @@ function syncSettingsFormFromConfig(): void {
|
|||||||
byId<HTMLInputElement>('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false;
|
byId<HTMLInputElement>('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false;
|
||||||
byId<HTMLInputElement>('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true;
|
byId<HTMLInputElement>('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true;
|
||||||
byId<HTMLInputElement>('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true;
|
byId<HTMLInputElement>('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true;
|
||||||
byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false;
|
|
||||||
byId<HTMLSelectElement>('streamlinkQuality').value = (config.streamlink_quality as string) || 'best';
|
byId<HTMLSelectElement>('streamlinkQuality').value = (config.streamlink_quality as string) || 'best';
|
||||||
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
|
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
|
||||||
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
|
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
|
||||||
@ -570,7 +567,6 @@ function initSettingsAutoSave(): void {
|
|||||||
'persistQueueToggle',
|
'persistQueueToggle',
|
||||||
'autoResumeQueueToggle',
|
'autoResumeQueueToggle',
|
||||||
'notifyEachCompletionToggle',
|
'notifyEachCompletionToggle',
|
||||||
'streamlinkDisableAdsToggle',
|
|
||||||
'streamlinkQuality'
|
'streamlinkQuality'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@ -120,9 +120,6 @@ function applyLanguageToStaticUI(): void {
|
|||||||
setText('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionLabel);
|
setText('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionLabel);
|
||||||
setTitle('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionHint);
|
setTitle('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionHint);
|
||||||
setTitle('notifyEachCompletionToggle', UI_TEXT.static.notifyEachCompletionHint);
|
setTitle('notifyEachCompletionToggle', UI_TEXT.static.notifyEachCompletionHint);
|
||||||
setText('streamlinkDisableAdsLabel', UI_TEXT.static.streamlinkDisableAdsLabel);
|
|
||||||
setTitle('streamlinkDisableAdsLabel', UI_TEXT.static.streamlinkDisableAdsHint);
|
|
||||||
setTitle('streamlinkDisableAdsToggle', UI_TEXT.static.streamlinkDisableAdsHint);
|
|
||||||
setText('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityLabel);
|
setText('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityLabel);
|
||||||
setTitle('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityHint);
|
setTitle('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityHint);
|
||||||
setTitle('streamlinkQuality', UI_TEXT.static.streamlinkQualityHint);
|
setTitle('streamlinkQuality', UI_TEXT.static.streamlinkQualityHint);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user