Twitch-VOD-Manager/src/renderer-queue.ts
xRangerDE 3e1d4e188c feat: cutter/merge i18n + per-item retry + status-bar queue summary
Three Phase-6 wins.

1. Cutter & Merge tab labels were the same i18n gap as the trim-VOD
   dialog before 4.5.20: Dauer / Aufloesung / FPS / Auswahl / Start: /
   Ende: / Schneiden / Zusammenfuegen were hardcoded German in
   index.html. Each got an id + setText wiring + DE/EN locale strings
   (cutter.infoDuration / .infoResolution / .infoFps / .infoSelection
   / .startLabel / .endLabel; cutter.cut + merge.merge already existed
   for dynamic state, now also used as initial text on btnCut /
   btnMerge).

2. Per-item retry button on failed queue entries. The existing
   "retry failed" queue-action retried ALL failed items at once;
   when only one specific item should be retried (e.g. transient
   network blip on one URL), the user had to remove every other
   failed item first. New ipcMain.handle("retry-queue-item", id)
   resets that single item to status: pending and triggers
   processQueue if idle. A small ↻ icon now sits next to the
   remove (x) button on items in the error state.

3. Status bar queue summary. The footer previously showed only the
   connection status + version. With longer queues the user had to
   scroll the queue panel to see how many downloads were active
   versus pending. New span between the status indicator and the
   version reads "{downloading} dl, {pending} queued" (locale-aware,
   hidden when queue is empty). Updated on onQueueUpdated and
   onDownloadProgress so it stays live.

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

412 lines
16 KiB
TypeScript

function renderQueueItemFileActions(item: QueueItem): string {
if (item.status !== 'completed' || !item.outputFiles || item.outputFiles.length === 0) {
return '';
}
const first = item.outputFiles[0];
if (typeof first !== 'string' || !first) return '';
const safeFirst = escapeHtml(first);
const safeFirstAttr = first.replace(/'/g, "\\'").replace(/"/g, '&quot;');
const buttons: string[] = [];
// "Open file" only makes sense when there's exactly one output (a clip /
// full VOD download). For multi-part downloads "open the first part" is
// surprising — the user almost always wants the folder.
if (item.outputFiles.length === 1) {
buttons.push(`<button class="queue-detail-btn" onclick="invokeOpenFile('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.openFile)}</button>`);
}
buttons.push(`<button class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`);
const fileLabel = item.outputFiles.length === 1
? safeFirst
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;
return `
<div class="queue-output-row" style="display:flex; gap:6px; margin-top:6px; flex-wrap:wrap; align-items:center;">
${buttons.join('')}
<span style="color: var(--text-secondary,#888); font-size:11px; word-break:break-all;">${fileLabel}</span>
</div>
`;
}
async function invokeOpenFile(filePath: string): Promise<void> {
const ok = await window.api.openFile(filePath);
if (!ok) {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn');
}
}
async function invokeShowInFolder(filePath: string): Promise<void> {
const ok = await window.api.showInFolder(filePath);
if (!ok) {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn');
}
}
function buildQueueFingerprint(url: string, streamer: string, date: string, customClip?: CustomClip): string {
const clipFingerprint = customClip
? [
'clip',
customClip.startSec,
customClip.durationSec,
customClip.startPart,
customClip.filenameFormat,
(customClip.filenameTemplate || '').trim().toLowerCase()
].join(':')
: 'vod';
return [
(url || '').trim().toLowerCase().replace(/^https?:\/\/(www\.)?/, ''),
(streamer || '').trim().toLowerCase(),
(date || '').trim(),
clipFingerprint
].join('|');
}
let lastQueueRenderFingerprint = '';
function getQueueRenderFingerprint(items: QueueItem[]): string {
const lang = typeof currentLanguage === 'string' ? currentLanguage : 'en';
const pieces = items.map((item) => [
item.id,
item.status,
Math.round((Number(item.progress) || 0) * 10),
item.currentPart || 0,
item.totalParts || 0,
item.speed || '',
item.eta || '',
item.progressStatus || '',
item.last_error || '',
item.mergeGroup?.mergePhase || ''
].join(':'));
return `${lang}|${selectedQueueIds.join(',')}|${[...expandedQueueIds].join(',')}|${pieces.join('|')}`;
}
function hasActiveQueueDuplicate(url: string, streamer: string, date: string, customClip?: CustomClip): boolean {
const target = buildQueueFingerprint(url, streamer, date, customClip);
return queue.some((item) => {
if (item.status !== 'pending' && item.status !== 'downloading' && item.status !== 'paused') {
return false;
}
return buildQueueFingerprint(item.url, item.streamer, item.date, item.customClip) === target;
});
}
async function addToQueue(url: string, title: string, date: string, streamer: string, duration: string): Promise<void> {
if ((config.prevent_duplicate_downloads as boolean) !== false && hasActiveQueueDuplicate(url, streamer, date)) {
alert(UI_TEXT.queue.duplicateSkipped);
return;
}
queue = await window.api.addToQueue({
url,
title,
date,
streamer,
duration_str: duration
});
renderQueue();
}
async function removeFromQueue(id: string): Promise<void> {
queue = await window.api.removeFromQueue(id);
renderQueue();
}
async function clearCompleted(): Promise<void> {
queue = await window.api.clearCompleted();
renderQueue();
}
async function retryFailedDownloads(): Promise<void> {
queue = await window.api.retryFailedDownloads();
renderQueue();
}
async function retryQueueItem(id: string): Promise<void> {
queue = await window.api.retryQueueItem(id);
renderQueue();
}
function getQueueStatusLabel(item: QueueItem): string {
if (item.status === 'completed') return UI_TEXT.queue.statusDone;
if (item.status === 'error') return UI_TEXT.queue.statusFailed;
if (item.status === 'paused') return UI_TEXT.queue.statusPaused;
if (item.status === 'downloading') return UI_TEXT.queue.statusRunning;
return UI_TEXT.queue.statusWaiting;
}
function getQueueProgressText(item: QueueItem): string {
if (item.status === 'completed') return '100%';
if (item.status === 'error') return UI_TEXT.queue.progressError;
if (item.status === 'paused') return UI_TEXT.queue.progressReady;
if (item.status === 'pending') return UI_TEXT.queue.progressReady;
if (item.progress > 0) return `${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`;
return item.progressStatus || UI_TEXT.queue.progressLoading;
}
function getQueueMetaText(item: QueueItem): string {
if (item.status === 'error' && item.last_error) {
return item.last_error;
}
const parts: string[] = [];
if (item.currentPart && item.totalParts) {
parts.push(`${UI_TEXT.queue.part} ${item.currentPart}/${item.totalParts}`);
}
if (item.speed) {
parts.push(`${UI_TEXT.queue.speed}: ${item.speed}`);
}
if (item.eta) {
parts.push(`${UI_TEXT.queue.eta}: ${item.eta}`);
}
if (!parts.length && item.status === 'pending') {
parts.push(UI_TEXT.queue.readyToDownload);
}
if (!parts.length && item.status === 'paused') {
parts.push(UI_TEXT.queue.statusPaused);
}
if (!parts.length && item.status === 'downloading') {
parts.push(item.progressStatus || UI_TEXT.queue.started);
}
if (!parts.length && item.status === 'completed') {
parts.push(UI_TEXT.queue.done);
}
if (!parts.length && item.status === 'error') {
parts.push(UI_TEXT.queue.failed);
}
return parts.join(' | ');
}
function toggleQueueSelection(id: string): void {
const index = selectedQueueIds.indexOf(id);
if (index >= 0) {
selectedQueueIds.splice(index, 1);
} else {
selectedQueueIds.push(id);
}
renderQueue();
updateMergeGroupButton();
}
function updateMergeGroupButton(): void {
const btn = byId<HTMLButtonElement>('btnMergeGroup');
if (!btn) return;
// Clean up selections: only keep IDs that are still pending in queue
const validIds = new Set(
queue.filter(item => item.status === 'pending' && !item.mergeGroup).map(item => item.id)
);
selectedQueueIds = selectedQueueIds.filter(id => validIds.has(id));
if (selectedQueueIds.length >= 2) {
btn.style.display = '';
btn.textContent = `${UI_TEXT.mergeGroup.btn} (${selectedQueueIds.length})`;
btn.disabled = false;
} else {
btn.style.display = 'none';
}
}
async function createMergeGroupFromSelection(): Promise<void> {
if (selectedQueueIds.length < 2) return;
const ids = [...selectedQueueIds];
selectedQueueIds = [];
queue = await window.api.createMergeGroup(ids);
renderQueue();
updateMergeGroupButton();
}
function updateQueueItemProgress(progress: DownloadProgress): void {
// Lookup by data-id attribute, not array index — survives queue mutation between renders
const safeId = String(progress.id ?? '').replace(/"/g, '\\"');
if (!safeId) return;
const el = byId('queueList').querySelector(`[data-id="${safeId}"]`) as HTMLElement | null;
if (!el) return;
const item = queue.find(i => i.id === progress.id);
if (!item) return;
const bar = el.querySelector('.queue-progress-bar') as HTMLElement | null;
const text = el.querySelector('.queue-progress-text') as HTMLElement | null;
const meta = el.querySelector('.queue-meta') as HTMLElement | null;
if (bar) {
const isDeterminate = progress.progress > 0 && progress.progress <= 100;
const pct = isDeterminate ? Math.min(100, progress.progress) : 0;
bar.style.width = `${pct}%`;
bar.className = `queue-progress-bar${isDeterminate ? '' : ' indeterminate'}`;
}
if (text) text.textContent = getQueueProgressText(item);
if (meta) meta.textContent = getQueueMetaText(item);
}
function toggleQueueDetails(id: string): void {
if (expandedQueueIds.has(id)) {
expandedQueueIds.delete(id);
} else {
expandedQueueIds.add(id);
}
renderQueue();
}
function initQueueDragDrop(): void {
if (queueDragDropInitialized) return;
queueDragDropInitialized = true;
const list = byId('queueList');
list.addEventListener('dragstart', (e: DragEvent) => {
const el = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
if (!el) return;
// Prevent dragging items that are no longer pending (race window between status change and re-render)
const itemId = el.dataset.id;
if (itemId) {
const item = queue.find(i => i.id === itemId);
if (!item || item.status !== 'pending') {
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'none';
e.dataTransfer.clearData();
}
return;
}
}
draggedQueueItemId = el.dataset.id || null;
el.classList.add('dragging');
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
});
list.addEventListener('dragover', (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
});
list.addEventListener('drop', (e: DragEvent) => {
e.preventDefault();
const target = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
if (!target || !draggedQueueItemId) return;
const targetId = target.dataset.id;
if (!targetId || targetId === draggedQueueItemId) return;
const fromIdx = queue.findIndex(i => i.id === draggedQueueItemId);
const toIdx = queue.findIndex(i => i.id === targetId);
if (fromIdx < 0 || toIdx < 0) return;
const [moved] = queue.splice(fromIdx, 1);
queue.splice(toIdx, 0, moved);
window.api.reorderQueue(queue.map(i => i.id));
renderQueue();
});
list.addEventListener('dragend', () => {
draggedQueueItemId = null;
document.querySelectorAll('.queue-item.dragging').forEach(el => el.classList.remove('dragging'));
});
}
function renderQueue(): void {
if (!Array.isArray(queue)) {
queue = [];
}
const list = byId('queueList');
byId('queueCount').textContent = String(queue.length);
const retryBtn = byId<HTMLButtonElement>('btnRetryFailed');
const hasFailed = queue.some((item) => item.status === 'error');
retryBtn.disabled = !hasFailed;
const renderFingerprint = getQueueRenderFingerprint(queue);
if (renderFingerprint === lastQueueRenderFingerprint) {
return;
}
if (queue.length === 0) {
lastQueueRenderFingerprint = renderFingerprint;
list.innerHTML = `<div style="color: var(--text-secondary); font-size: 12px; text-align: center; padding: 15px;">${UI_TEXT.queue.empty}</div>`;
return;
}
list.innerHTML = queue.map((item: QueueItem) => {
const safeTitle = escapeHtml(item.title || UI_TEXT.vods.untitled);
const safeStatusLabel = escapeHtml(getQueueStatusLabel(item));
const safeProgressText = escapeHtml(getQueueProgressText(item));
const safeMeta = escapeHtml(getQueueMetaText(item));
const isClip = item.customClip ? '* ' : '';
const hasDeterminateProgress = item.progress > 0 && item.progress <= 100;
const progressValue = item.status === 'completed'
? 100
: (hasDeterminateProgress ? Math.max(0, Math.min(100, item.progress)) : 0);
const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : '';
const isMergeGroup = !!item.mergeGroup;
const showSelector = item.status === 'pending' && !isMergeGroup;
const selectionIndex = selectedQueueIds.indexOf(item.id);
const isSelected = selectionIndex >= 0;
const mergeIcon = isMergeGroup
? '<svg class="merge-group-icon" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> '
: '';
const mergeMetaExtra = isMergeGroup
? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})`
: '';
return `
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${item.id}">
${showSelector
? `<div class="queue-selector${isSelected ? ' selected' : ''}" onclick="toggleQueueSelection('${item.id}')">${isSelected ? selectionIndex + 1 : ''}</div>`
: ''
}
<div class="status ${item.status}"></div>
<div class="queue-main">
<div class="queue-title-row">
<div class="title" title="${safeTitle}" onclick="toggleQueueDetails('${item.id}')" style="cursor:pointer">${mergeIcon}${isClip}${safeTitle}</div>
<div class="queue-status-label">${safeStatusLabel}</div>
</div>
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>
<div class="queue-progress-wrap">
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
</div>
<div class="queue-progress-text">${safeProgressText}</div>
<div class="queue-details" id="details-${item.id}" style="display:${expandedQueueIds.has(item.id) ? 'block' : 'none'}">
<div>URL: ${escapeHtml(item.url)}</div>
<div>Streamer: ${escapeHtml(item.streamer)}</div>
<div>Dauer: ${escapeHtml(item.duration_str)}</div>
<div>Datum: ${escapeHtml(new Date(item.date).toLocaleString())}</div>
${renderQueueItemFileActions(item)}
</div>
</div>
${item.status === 'error' ? `<span class="queue-retry-btn" title="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')" style="cursor:pointer; color: var(--text-secondary); font-size:14px; padding: 0 6px;">&#x21bb;</span>` : ''}
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
</div>
`;
}).join('');
updateMergeGroupButton();
lastQueueRenderFingerprint = renderFingerprint;
}
async function toggleDownload(): Promise<void> {
if (downloading) {
await window.api.pauseDownload();
return;
}
const started = await window.api.startDownload();
if (!started) {
renderQueue();
alert(UI_TEXT.queue.emptyAlert);
}
}