Replace checkboxes with numbered selectors (1, 2, 3...) that show the merge order. Click order determines VOD sequence in the merged result. Chronological auto-sort removed — user controls the order. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
266 lines
9.3 KiB
TypeScript
266 lines
9.3 KiB
TypeScript
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}|${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();
|
|
}
|
|
|
|
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 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' : ''}">
|
|
${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}">${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>
|
|
<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);
|
|
}
|
|
}
|