perf: major rendering optimization for large concurrent uploads

- Throttle progress events to 250ms intervals (was every byte chunk)
- Batch UI updates during uploads (render/statusbar/stats every 200ms)
- In-place row updates instead of full innerHTML table rebuild
- Single-pass queue stats computation (was 9 separate array filters)
- Remove CSS transition on progress bars (caused layout thrashing)
- Event delegation for recent files table (was per-row listener rebind)
- Increase persist debounce to 10s during uploads (was 3s)
- Remove redundant "Ziele auswählen" button (hoster selection on file add)
- Dark title bar via nativeTheme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-03-12 00:18:43 +01:00
parent b4c786cf04
commit 6b2b2ca04c
7 changed files with 165 additions and 59 deletions

View File

@ -59,10 +59,12 @@
"alwaysOnTop": false,
"shutdownAfterFinish": "nothing",
"logFilePath": "",
"sessionLog": false,
"resumeQueueOnLaunch": true,
"parallelUploadCount": 0,
"scaleParallelUploads": true,
"removeFromQueueOnDone": false,
"globalMaxSpeedKbs": 0,
"pendingQueue": {
"selectedUploadHosters": [
"doodstream.com",
@ -79,7 +81,7 @@
],
"queueJobs": [
{
"id": "preview-1773193741983-c1qa7p",
"id": "preview-1773271047205-k8l83r",
"file": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
"fileName": "Einfach mal die Fresse halten!!!.mp4",
"hoster": "doodstream.com",
@ -90,7 +92,7 @@
"maxAttempts": 0
},
{
"id": "preview-1773193741983-bvlsvn",
"id": "preview-1773271047206-npnpph",
"file": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
"fileName": "Einfach mal die Fresse halten!!!.mp4",
"hoster": "voe.sx",
@ -101,7 +103,7 @@
"maxAttempts": 0
},
{
"id": "preview-1773193741983-a7aixs",
"id": "preview-1773271047206-q2skl1",
"file": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
"fileName": "Einfach mal die Fresse halten!!!.mp4",
"hoster": "vidmoly.me",
@ -112,7 +114,7 @@
"maxAttempts": 0
},
{
"id": "preview-1773193741983-39jnfg",
"id": "preview-1773271047206-cek27b",
"file": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
"fileName": "Einfach mal die Fresse halten!!!.mp4",
"hoster": "byse.sx",

View File

@ -324,6 +324,9 @@ class UploadManager extends EventEmitter {
this.activeJobs.set(uploadId, { jobId, speedKbs: 0, bytesUploaded: 0 });
let lastEmitTime = 0;
const PROGRESS_EMIT_INTERVAL = 250; // ms throttle UI updates
const progressCb = (bytesUploaded, bytesTotal) => {
const now = Date.now();
const elapsed = Math.round((now - jobStart) / 1000);
@ -335,12 +338,16 @@ class UploadManager extends EventEmitter {
lastSpeedTime = now;
}
this.activeJobs.set(uploadId, { jobId, speedKbs: currentSpeedKbs, bytesUploaded });
// Throttle progress emissions to reduce IPC + rendering overhead
if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return;
lastEmitTime = now;
const remaining = currentSpeedKbs > 0
? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024))
: 0;
this.activeJobs.set(uploadId, { jobId, speedKbs: currentSpeedKbs, bytesUploaded });
this._emitProgress(uploadId, fileName, task.hoster, {
jobId,
status: 'uploading',

View File

@ -1,4 +1,5 @@
const { app, BrowserWindow, ipcMain, dialog, clipboard, powerSaveBlocker } = require('electron');
const { app, BrowserWindow, ipcMain, dialog, clipboard, powerSaveBlocker, nativeTheme } = require('electron');
nativeTheme.themeSource = 'dark';
const path = require('path');
const fs = require('fs');
const ConfigStore = require('./lib/config-store');

View File

@ -1,6 +1,6 @@
{
"name": "multi-hoster-uploader",
"version": "1.8.4",
"version": "1.8.5",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {

View File

@ -363,7 +363,7 @@ function persistQueueStateSoon(immediate) {
return;
}
// Use longer debounce during uploads to reduce disk I/O
const delay = uploading ? 3000 : 500;
const delay = uploading ? 10000 : 500;
queuePersistTimer = setTimeout(() => {
persistQueueStateNow().catch(() => {});
}, delay);
@ -570,12 +570,27 @@ const VIRTUAL_OVERSCAN = 10;
let _lastVisibleRange = { start: -1, end: -1 };
let _queueListenersBound = false;
// Throttled UI update scheduling max one render per 200ms during uploads
let _uiUpdateTimer = null;
const UI_UPDATE_INTERVAL = 200; // ms
function scheduleQueueRender() {
if (_renderQueued) return;
_renderQueued = true;
requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); });
}
function scheduleThrottledUIUpdate() {
if (_uiUpdateTimer) return;
_uiUpdateTimer = setTimeout(() => {
_uiUpdateTimer = null;
scheduleQueueRender();
updateQueueActionButtons();
updateStatusBar();
updateStatsPanel();
}, UI_UPDATE_INTERVAL);
}
function buildRowHtml(job) {
const statusClass = `status-${job.status}`;
const rowClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
@ -608,6 +623,42 @@ function buildRowHtml(job) {
</tr>`;
}
// In-place update of a single row's cells (avoids full innerHTML rebuild)
function _updateRowInPlace(tr, job) {
const statusClass = `status-${job.status}`;
const uploadedSize = job.status === 'preview'
? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...')
: `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`;
const statusText = getStatusText(job);
const elapsed = formatTime(job.elapsed);
const remaining = formatTime(job.remaining);
const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : '';
const pct = Math.min(100, Math.round((job.progress || 0) * 100));
const link = job.result ? (job.result.download_url || job.result.embed_url || '') : '';
// Update row class
tr.className = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
tr.dataset.link = link;
const cells = tr.children;
if (cells.length < 8) return false; // structure mismatch, needs full rebuild
cells[1].textContent = uploadedSize;
// cells[0] (filename) and cells[2] (hoster) don't change during upload
const badge = cells[3].querySelector('.status-badge');
if (badge) { badge.className = `status-badge ${statusClass}`; badge.textContent = statusText; }
cells[4].textContent = elapsed;
cells[5].textContent = remaining;
cells[6].textContent = speed;
const fill = cells[7].querySelector('.progress-bar-fill');
if (fill) { fill.style.width = pct + '%'; fill.className = `progress-bar-fill ${statusClass}`; }
const pctSpan = cells[7].querySelector('.progress-pct');
if (pctSpan) pctSpan.textContent = job.status === 'preview' ? '' : pct + '%';
return true;
}
function renderQueueTable() {
const tbody = document.getElementById('queueBody');
if (!tbody) return;
@ -615,10 +666,27 @@ function renderQueueTable() {
_sortedJobsCache = sortQueueJobs(queueJobs);
const totalRows = _sortedJobsCache.length;
// For small queues (<200 rows), use simple rendering
if (totalRows < 200) {
tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join('');
_lastVisibleRange = { start: -1, end: -1 };
// Try in-place update if row count matches (fast path)
const existingRows = tbody.querySelectorAll('.queue-row');
if (existingRows.length === totalRows && totalRows > 0) {
// In-place update no DOM destruction
for (let i = 0; i < totalRows; i++) {
const tr = existingRows[i];
const job = _sortedJobsCache[i];
// If row identity changed (different job), fall back to full rebuild
if (tr.dataset.jobId !== job.id) {
tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join('');
_lastVisibleRange = { start: -1, end: -1 };
break;
}
_updateRowInPlace(tr, job);
}
} else {
// Full rebuild needed (row count changed)
tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join('');
_lastVisibleRange = { start: -1, end: -1 };
}
} else {
// Virtual scrolling for large queues — force re-render
_lastVisibleRange = { start: -1, end: -1 };
@ -1185,10 +1253,15 @@ function handleProgress(data) {
queueJobs = queueJobs.filter(j => j !== job);
}
scheduleQueueRender();
updateQueueActionButtons();
updateStatusBar();
updateStatsPanel();
// Status changes (done/error/etc) get immediate render; ongoing progress is throttled
if (data.status === 'uploading') {
scheduleThrottledUIUpdate();
} else {
scheduleQueueRender();
updateQueueActionButtons();
updateStatusBar();
updateStatsPanel();
}
persistQueueStateSoon();
}
@ -1442,19 +1515,45 @@ function applySummaryResults(summary) {
}
}
function updateStatusBar() {
const counts = {
total: queueJobs.length,
remaining: queueJobs.filter((job) => ['preview', 'queued', 'getting-server', 'uploading', 'retrying'].includes(job.status)).length,
inProgress: queueJobs.filter((job) => ['getting-server', 'uploading', 'retrying'].includes(job.status)).length,
error: queueJobs.filter((job) => job.status === 'error').length
};
// Single-pass queue stats computation (shared by status bar + stats panel)
function _computeQueueStats() {
let remaining = 0, inProgress = 0, done = 0, errors = 0;
let bytesRemaining = 0, totalSize = 0, remainingSize = 0;
const total = queueJobs.length;
for (let i = 0; i < total; i++) {
const job = queueJobs[i];
const s = job.status;
const bt = job.bytesTotal || 0;
const bu = job.bytesUploaded || 0;
totalSize += bt;
if (s === 'uploading' || s === 'getting-server' || s === 'retrying') {
inProgress++;
remaining++;
bytesRemaining += Math.max(0, bt - bu);
remainingSize += Math.max(0, bt - bu);
} else if (s === 'preview' || s === 'queued') {
remaining++;
bytesRemaining += Math.max(0, bt - bu);
remainingSize += Math.max(0, bt - bu);
} else if (s === 'done') {
done++;
} else if (s === 'error') {
errors++;
} else if (s !== 'skipped') {
remainingSize += Math.max(0, bt - bu);
}
}
return { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize };
}
function updateStatusBar() {
const stats = _computeQueueStats();
const bytesRemaining = queueJobs
.filter((job) => ['getting-server', 'uploading', 'retrying', 'queued', 'preview'].includes(job.status))
.reduce((sum, job) => sum + Math.max(0, (job.bytesTotal || 0) - (job.bytesUploaded || 0)), 0);
const etaSeconds = lastUploadStats.globalSpeedKbs > 0
? Math.round(bytesRemaining / (lastUploadStats.globalSpeedKbs * 1024))
? Math.round(stats.bytesRemaining / (lastUploadStats.globalSpeedKbs * 1024))
: 0;
const stateText = lastUploadStats.state === 'uploading'
@ -1467,14 +1566,13 @@ function updateStatusBar() {
document.getElementById('sbState').textContent = stateText;
document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0);
const queueTotalBytes = queueJobs.reduce((sum, j) => sum + (j.bytesTotal || 0), 0);
document.getElementById('sbTotal').textContent = `${formatSize(lastUploadStats.totalBytes || 0)} / ${formatSize(queueTotalBytes)}`;
document.getElementById('sbTotal').textContent = `${formatSize(lastUploadStats.totalBytes || 0)} / ${formatSize(stats.totalSize)}`;
document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`;
document.getElementById('sbConnections').textContent = `Aktive Verbindungen ${lastUploadStats.activeJobs || 0}`;
document.getElementById('sbQueueCount').textContent = `Gesamt ${counts.total}`;
document.getElementById('sbRemainingCount').textContent = `Remaining ${counts.remaining}`;
document.getElementById('sbInProgressCount').textContent = `In Progress ${counts.inProgress}`;
document.getElementById('sbErrorCount').textContent = `Error ${counts.error}`;
document.getElementById('sbQueueCount').textContent = `Gesamt ${stats.total}`;
document.getElementById('sbRemainingCount').textContent = `Remaining ${stats.remaining}`;
document.getElementById('sbInProgressCount').textContent = `In Progress ${stats.inProgress}`;
document.getElementById('sbErrorCount').textContent = `Error ${stats.errors}`;
}
// --- Health Check ---
@ -2102,6 +2200,8 @@ function updateRecentSortHeaders() {
});
}
let _recentListenersBound = false;
function renderRecentUploadsPanel() {
const tbody = document.getElementById('recentFilesBody');
if (!tbody) return;
@ -2121,14 +2221,18 @@ function renderRecentUploadsPanel() {
</tr>
`).join('');
tbody.querySelectorAll('.recent-file-row').forEach(tr => {
tr.addEventListener('click', (e) => {
// Event delegation bind once, not per-row
if (!_recentListenersBound) {
_recentListenersBound = true;
tbody.addEventListener('click', (e) => {
const tr = e.target.closest('.recent-file-row');
if (!tr) return;
const id = parseInt(tr.dataset.order, 10);
if (e.ctrlKey || e.metaKey) {
if (selectedRecentIds.has(id)) selectedRecentIds.delete(id);
else selectedRecentIds.add(id);
} else if (e.shiftKey && selectedRecentIds.size > 0) {
const sortedOrders = rows.map(r => r.order);
const sortedOrders = sortRecentFiles(sessionFilesData).map(r => r.order);
const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o));
const curIdx = sortedOrders.indexOf(id);
if (lastIdx >= 0 && curIdx >= 0) {
@ -2143,12 +2247,13 @@ function renderRecentUploadsPanel() {
renderRecentUploadsPanel();
});
tr.addEventListener('dblclick', () => {
if (tr.classList.contains('error')) return;
tbody.addEventListener('dblclick', (e) => {
const tr = e.target.closest('.recent-file-row');
if (!tr || tr.classList.contains('error')) return;
const link = tr.dataset.link;
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
});
});
}
updateRecentSortHeaders();
}
@ -2222,7 +2327,6 @@ window.addEventListener('beforeunload', () => {
// --- Setup Listeners ---
function setupListeners() {
document.getElementById('addFilesBtn').addEventListener('click', pickFiles);
document.getElementById('chooseHostersBtn').addEventListener('click', openHosterModal);
document.getElementById('startUploadBtn').addEventListener('click', startUpload);
document.getElementById('startSelectedBtn').addEventListener('click', startSelectedUpload);
@ -2598,30 +2702,23 @@ function formatDuration(seconds) {
}
function updateStatsPanel() {
const total = queueJobs.length;
const done = queueJobs.filter(j => j.status === 'done').length;
const inProgress = queueJobs.filter(j => ['uploading', 'getting-server', 'retrying'].includes(j.status)).length;
const errors = queueJobs.filter(j => j.status === 'error').length;
const remaining = total - done - errors;
const totalSize = queueJobs.reduce((s, j) => s + (j.bytesTotal || 0), 0);
const remainingSize = queueJobs.filter(j => !['done', 'error', 'skipped'].includes(j.status))
.reduce((s, j) => s + ((j.bytesTotal || 0) - (j.bytesUploaded || 0)), 0);
const stats = _computeQueueStats();
const remaining = stats.total - stats.done - stats.errors;
const el = (id) => document.getElementById(id);
if (el('statQueueTotal')) el('statQueueTotal').textContent = total;
if (el('statQueueDone')) el('statQueueDone').textContent = done;
if (el('statQueueTotal')) el('statQueueTotal').textContent = stats.total;
if (el('statQueueDone')) el('statQueueDone').textContent = stats.done;
if (el('statQueueRemaining')) el('statQueueRemaining').textContent = remaining;
if (el('statQueueInProgress')) el('statQueueInProgress').textContent = inProgress;
if (el('statQueueError')) el('statQueueError').textContent = errors;
if (el('statSizeTotal')) el('statSizeTotal').textContent = formatBytes(totalSize);
if (el('statSizeRemaining')) el('statSizeRemaining').textContent = formatBytes(remainingSize);
if (el('statQueueInProgress')) el('statQueueInProgress').textContent = stats.inProgress;
if (el('statQueueError')) el('statQueueError').textContent = stats.errors;
if (el('statSizeTotal')) el('statSizeTotal').textContent = formatBytes(stats.totalSize);
if (el('statSizeRemaining')) el('statSizeRemaining').textContent = formatBytes(stats.remainingSize);
const speed = lastUploadStats.globalSpeedKbs || 0;
if (el('statSpeed')) el('statSpeed').textContent = speed > 0 ? formatBytes(speed * 1024) + '/s' : '0 B/s';
if (el('statEta')) {
if (speed > 0 && remainingSize > 0) {
el('statEta').textContent = formatDuration(Math.round(remainingSize / (speed * 1024)));
if (speed > 0 && stats.remainingSize > 0) {
el('statEta').textContent = formatDuration(Math.round(stats.remainingSize / (speed * 1024)));
} else {
el('statEta').textContent = '--:--';
}

View File

@ -24,7 +24,6 @@
<div id="upload-view" class="view active">
<div class="upload-toolbar">
<div class="toolbar-left">
<button class="btn btn-xs btn-secondary" id="chooseHostersBtn">Ziele auswählen</button>
<span class="hoster-summary" id="hosterSummary" style="display:none"></span>
</div>
<div class="toolbar-right">

View File

@ -329,8 +329,8 @@ body {
}
.progress-bar-fill {
height: 100%;
transition: width 0.3s ease;
border-radius: 2px;
will-change: width;
}
.progress-bar-fill.status-uploading { background: linear-gradient(90deg, #4a90d9, #5dabf7); }
.progress-bar-fill.status-getting-server { background: var(--accent); }