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>
This commit is contained in:
xRangerDE 2026-05-10 14:02:42 +02:00
parent 766cdfe371
commit 3e1d4e188c
9 changed files with 96 additions and 10 deletions

View File

@ -314,19 +314,19 @@
<div class="cutter-info" id="cutterInfo" style="display:none"> <div class="cutter-info" id="cutterInfo" style="display:none">
<div class="cutter-info-item"> <div class="cutter-info-item">
<span class="cutter-info-label">Dauer</span> <span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span>
<span class="cutter-info-value" id="infoDuration">--:--:--</span> <span class="cutter-info-value" id="infoDuration">--:--:--</span>
</div> </div>
<div class="cutter-info-item"> <div class="cutter-info-item">
<span class="cutter-info-label">Auflosung</span> <span class="cutter-info-label" id="cutterInfoResolutionLabel">Aufloesung</span>
<span class="cutter-info-value" id="infoResolution">----x----</span> <span class="cutter-info-value" id="infoResolution">----x----</span>
</div> </div>
<div class="cutter-info-item"> <div class="cutter-info-item">
<span class="cutter-info-label">FPS</span> <span class="cutter-info-label" id="cutterInfoFpsLabel">FPS</span>
<span class="cutter-info-value" id="infoFps">--</span> <span class="cutter-info-value" id="infoFps">--</span>
</div> </div>
<div class="cutter-info-item"> <div class="cutter-info-item">
<span class="cutter-info-label">Auswahl</span> <span class="cutter-info-label" id="cutterInfoSelectionLabel">Auswahl</span>
<span class="cutter-info-value" id="infoSelection">--:--:--</span> <span class="cutter-info-value" id="infoSelection">--:--:--</span>
</div> </div>
</div> </div>
@ -339,11 +339,11 @@
<div class="time-inputs"> <div class="time-inputs">
<div class="time-input-group"> <div class="time-input-group">
<label>Start:</label> <label id="cutterStartLabel">Start:</label>
<input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()"> <input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()">
</div> </div>
<div class="time-input-group"> <div class="time-input-group">
<label>Ende:</label> <label id="cutterEndLabel">Ende:</label>
<input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()"> <input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()">
</div> </div>
</div> </div>
@ -574,6 +574,7 @@
<div class="status-dot" id="statusDot"></div> <div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span> <span id="statusText">Nicht verbunden</span>
</div> </div>
<span id="statusBarQueueSummary" style="color: var(--text-secondary); font-size:12px; margin-left:auto; padding-right:12px;"></span>
<span id="versionText">v4.1.13</span> <span id="versionText">v4.1.13</span>
</div> </div>
</main> </main>

View File

@ -3841,6 +3841,31 @@ ipcMain.handle('retry-failed-downloads', () => {
return downloadQueue; return downloadQueue;
}); });
ipcMain.handle('retry-queue-item', (_, id: string) => {
if (typeof id !== 'string' || !id) return downloadQueue;
const idx = downloadQueue.findIndex((it) => it.id === id);
if (idx < 0) return downloadQueue;
const item = downloadQueue[idx];
if (item.status !== 'error') return downloadQueue;
downloadQueue[idx] = {
...item,
status: 'pending',
progress: 0,
last_error: ''
};
saveQueue(downloadQueue);
emitQueueUpdated();
appendDebugLog('queue-item-retry-single', { id, title: item.title });
if (!isDownloading) {
void processQueue();
}
return downloadQueue;
});
ipcMain.handle('create-merge-group', (_, itemIds: string[]) => { ipcMain.handle('create-merge-group', (_, itemIds: string[]) => {
const selectedItems = downloadQueue.filter(item => itemIds.includes(item.id)); const selectedItems = downloadQueue.filter(item => itemIds.includes(item.id));

View File

@ -68,6 +68,7 @@ contextBridge.exposeInMainWorld('api', {
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds), reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
clearCompleted: () => ipcRenderer.invoke('clear-completed'), clearCompleted: () => ipcRenderer.invoke('clear-completed'),
retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'), retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'),
retryQueueItem: (id: string) => ipcRenderer.invoke('retry-queue-item', id),
createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds), createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds),
// Download // Download

View File

@ -187,6 +187,7 @@ interface ApiBridge {
reorderQueue(orderIds: string[]): Promise<QueueItem[]>; reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
clearCompleted(): Promise<QueueItem[]>; clearCompleted(): Promise<QueueItem[]>;
retryFailedDownloads(): Promise<QueueItem[]>; retryFailedDownloads(): Promise<QueueItem[]>;
retryQueueItem(id: string): Promise<QueueItem[]>;
createMergeGroup(itemIds: string[]): Promise<QueueItem[]>; createMergeGroup(itemIds: string[]): Promise<QueueItem[]>;
startDownload(): Promise<boolean>; startDownload(): Promise<boolean>;
pauseDownload(): Promise<boolean>; pauseDownload(): Promise<boolean>;

View File

@ -160,7 +160,9 @@ const UI_TEXT_DE = {
openFile: 'Datei oeffnen', openFile: 'Datei oeffnen',
showInFolder: 'Im Ordner zeigen', showInFolder: 'Im Ordner zeigen',
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',
statusBarSummary: '{downloading} aktiv, {pending} wartet'
}, },
vods: { vods: {
noneTitle: 'Keine VODs', noneTitle: 'Keine VODs',
@ -227,7 +229,13 @@ const UI_TEXT_DE = {
cutting: 'Schneidet...', cutting: 'Schneidet...',
cut: 'Schneiden', cut: 'Schneiden',
cutSuccess: 'Video erfolgreich geschnitten!', cutSuccess: 'Video erfolgreich geschnitten!',
cutFailed: 'Fehler beim Schneiden des Videos.' cutFailed: 'Fehler beim Schneiden des Videos.',
infoDuration: 'Dauer',
infoResolution: 'Aufloesung',
infoFps: 'FPS',
infoSelection: 'Auswahl',
startLabel: 'Start:',
endLabel: 'Ende:'
}, },
merge: { merge: {
empty: 'Keine Videos ausgewahlt', empty: 'Keine Videos ausgewahlt',

View File

@ -160,7 +160,9 @@ const UI_TEXT_EN = {
openFile: 'Open file', openFile: 'Open file',
showInFolder: 'Show in folder', showInFolder: 'Show in folder',
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',
statusBarSummary: '{downloading} dl, {pending} queued'
}, },
vods: { vods: {
noneTitle: 'No VODs', noneTitle: 'No VODs',
@ -227,7 +229,13 @@ const UI_TEXT_EN = {
cutting: 'Cutting...', cutting: 'Cutting...',
cut: 'Cut', cut: 'Cut',
cutSuccess: 'Video cut successfully!', cutSuccess: 'Video cut successfully!',
cutFailed: 'Failed to cut video.' cutFailed: 'Failed to cut video.',
infoDuration: 'Duration',
infoResolution: 'Resolution',
infoFps: 'FPS',
infoSelection: 'Selection',
startLabel: 'Start:',
endLabel: 'End:'
}, },
merge: { merge: {
empty: 'No videos selected', empty: 'No videos selected',

View File

@ -127,6 +127,11 @@ async function retryFailedDownloads(): Promise<void> {
renderQueue(); renderQueue();
} }
async function retryQueueItem(id: string): Promise<void> {
queue = await window.api.retryQueueItem(id);
renderQueue();
}
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;
@ -382,6 +387,7 @@ function renderQueue(): void {
${renderQueueItemFileActions(item)} ${renderQueueItemFileActions(item)}
</div> </div>
</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> <span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
</div> </div>
`; `;

View File

@ -72,9 +72,17 @@ function applyLanguageToStaticUI(): void {
setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm); setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm);
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle); setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse); setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration);
setText('cutterInfoResolutionLabel', UI_TEXT.cutter.infoResolution);
setText('cutterInfoFpsLabel', UI_TEXT.cutter.infoFps);
setText('cutterInfoSelectionLabel', UI_TEXT.cutter.infoSelection);
setText('cutterStartLabel', UI_TEXT.cutter.startLabel);
setText('cutterEndLabel', UI_TEXT.cutter.endLabel);
setText('btnCut', UI_TEXT.cutter.cut);
setText('mergeTitle', UI_TEXT.static.mergeTitle); setText('mergeTitle', UI_TEXT.static.mergeTitle);
setText('mergeDesc', UI_TEXT.static.mergeDesc); setText('mergeDesc', UI_TEXT.static.mergeDesc);
setText('mergeAddBtn', UI_TEXT.static.mergeAdd); setText('mergeAddBtn', UI_TEXT.static.mergeAdd);
setText('btnMerge', UI_TEXT.merge.merge);
setText('designTitle', UI_TEXT.static.designTitle); setText('designTitle', UI_TEXT.static.designTitle);
setText('themeLabel', UI_TEXT.static.themeLabel); setText('themeLabel', UI_TEXT.static.themeLabel);
setText('themeLightOption', UI_TEXT.static.themeLight); setText('themeLightOption', UI_TEXT.static.themeLight);

View File

@ -44,6 +44,7 @@ async function init(): Promise<void> {
renderQueue(); renderQueue();
initQueueDragDrop(); initQueueDragDrop();
updateDownloadButtonState(); updateDownloadButtonState();
updateStatusBarQueueSummary();
// Restore persisted VOD filter into the input — the filter itself only // Restore persisted VOD filter into the input — the filter itself only
// takes effect once VODs load (renderVODs reads vodFilterQuery). // takes effect once VODs load (renderVODs reads vodFilterQuery).
@ -65,6 +66,7 @@ async function init(): Promise<void> {
window.api.onQueueUpdated((q: QueueItem[]) => { window.api.onQueueUpdated((q: QueueItem[]) => {
queue = mergeQueueState(Array.isArray(q) ? q : []); queue = mergeQueueState(Array.isArray(q) ? q : []);
renderQueue(); renderQueue();
updateStatusBarQueueSummary();
markQueueActivity(); markQueueActivity();
}); });
@ -89,6 +91,7 @@ async function init(): Promise<void> {
item.totalBytes = progress.totalBytes; item.totalBytes = progress.totalBytes;
item.progressStatus = progress.status; item.progressStatus = progress.status;
updateQueueItemProgress(progress); updateQueueItemProgress(progress);
updateStatusBarQueueSummary();
markQueueActivity(); markQueueActivity();
}); });
@ -248,6 +251,31 @@ function formatSpeedRenderer(bytesPerSec: number): string {
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`; return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
} }
function updateStatusBarQueueSummary(): void {
const node = document.getElementById('statusBarQueueSummary');
if (!node) return;
if (!Array.isArray(queue) || queue.length === 0) {
node.textContent = '';
return;
}
let downloading = 0;
let pending = 0;
for (const item of queue) {
if (item.status === 'downloading') downloading++;
else if (item.status === 'pending') pending++;
}
if (downloading === 0 && pending === 0) {
node.textContent = '';
return;
}
node.textContent = UI_TEXT.queue.statusBarSummary
.replace('{downloading}', String(downloading))
.replace('{pending}', String(pending));
}
async function updateStatsBar(): Promise<void> { async function updateStatsBar(): Promise<void> {
try { try {
const metrics = await window.api.getRuntimeMetrics(); const metrics = await window.api.getRuntimeMetrics();