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:
parent
766cdfe371
commit
3e1d4e188c
@ -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>
|
||||||
|
|||||||
25
src/main.ts
25
src/main.ts
@ -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));
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
1
src/renderer-globals.d.ts
vendored
1
src/renderer-globals.d.ts
vendored
@ -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>;
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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;">↻</span>` : ''}
|
||||||
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
|
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user