@ -874,25 +874,41 @@ function _updateRowInPlace(tr, job) {
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 ;
// Write DOM only when the target value actually changes — a no-op progress
// tick (same pct, same speed) then performs zero DOM work. Massive saver
// when most of the visible jobs are idle/queued/done and only a few are
// actively uploading.
const newClass = ` queue-row ${ statusClass } ${ selectedJobIds . has ( job . id ) ? ' selected' : '' } ` ;
if ( tr . className !== newClass ) tr . className = newClass ;
if ( tr . dataset . link !== link ) tr . dataset . link = link ;
const cells = tr . children ;
if ( cells . length < 8 ) return false ; // structure mismatch, needs full rebuild
cells [ 1 ] . textContent = uploadedSize ;
if ( cells [ 1 ] . textContent !== uploadedSize ) 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 ;
if ( badge ) {
const badgeClass = ` status-badge ${ statusClass } ` ;
if ( badge . className !== badgeClass ) badge . className = badgeClass ;
if ( badge . textContent !== statusText ) badge . textContent = statusText ;
}
if ( cells [ 4 ] . textContent !== elapsed ) cells [ 4 ] . textContent = elapsed ;
if ( cells [ 5 ] . textContent !== remaining ) cells [ 5 ] . textContent = remaining ;
if ( cells [ 6 ] . textContent !== speed ) cells [ 6 ] . textContent = speed ;
const fill = cells [ 7 ] . querySelector ( '.progress-bar-fill' ) ;
if ( fill ) { fill . style . width = pct + '%' ; fill . className = ` progress-bar-fill ${ statusClass } ` ; }
if ( fill ) {
const pctStr = pct + '%' ;
if ( fill . style . width !== pctStr ) fill . style . width = pctStr ;
const fillClass = ` progress-bar-fill ${ statusClass } ` ;
if ( fill . className !== fillClass ) fill . className = fillClass ;
}
const pctSpan = cells [ 7 ] . querySelector ( '.progress-pct' ) ;
if ( pctSpan ) pctSpan . textContent = job . status === 'preview' ? '' : pct + '%' ;
if ( pctSpan ) {
const pctText = job . status === 'preview' ? '' : pct + '%' ;
if ( pctSpan . textContent !== pctText ) pctSpan . textContent = pctText ;
}
return true ;
}
@ -999,11 +1015,22 @@ function _onQueueScroll() {
const _collatorDE = new Intl . Collator ( 'de' , { sensitivity : 'base' , numeric : true } ) ;
const _collatorSimple = new Intl . Collator ( 'de' ) ;
// Queue sort memoization. Keys that don't change during upload (filename, host,
// size) reuse the cached result across progress-driven re-renders. Dynamic keys
// (status/speed/progress) are recomputed each call since the sort order itself
// moves every tick. For a queue of 1000+ jobs sorted by filename, this skips
// the Collator-based O(n log n) sort on every 200ms progress render.
let _queueSortCache = { sig : '' , result : [ ] } ;
const _STATIC _SORT _KEYS = new Set ( [ 'filename' , 'host' , 'size' ] ) ;
function sortQueueJobs ( jobs ) {
const { key , direction } = queueSortState ;
const factor = direction === 'asc' ? 1 : - 1 ;
const canCache = _STATIC _SORT _KEYS . has ( key ) ;
const sig = canCache ? ` ${ key } | ${ direction } | ${ jobs . length } ` : '' ;
if ( sig && _queueSortCache . sig === sig ) return _queueSortCache . result ;
return jobs . slice ( ) . sort ( ( a , b ) => {
const sorted = jobs . slice ( ) . sort ( ( a , b ) => {
let cmp = 0 ;
if ( key === 'filename' ) cmp = _collatorDE . compare ( a . fileName , b . fileName ) ;
else if ( key === 'size' ) cmp = ( a . bytesTotal || 0 ) - ( b . bytesTotal || 0 ) ;
@ -1013,6 +1040,8 @@ function sortQueueJobs(jobs) {
else if ( key === 'progress' ) cmp = ( a . progress || 0 ) - ( b . progress || 0 ) ;
return cmp * factor ;
} ) ;
if ( sig ) _queueSortCache = { sig , result : sorted } ;
return sorted ;
}
function getStatusOrder ( status ) {
@ -1968,7 +1997,14 @@ function applySummaryResults(summary) {
// Single-pass queue stats computation (shared by status bar + stats panel).
// Also tracks inProgressBytes so the status bar doesn't need a second scan.
//
// Memoized within a single tick: back-to-back calls (updateStatusBar +
// updateStatsPanel fire together 4× /sec during upload) share one scan. The
// cache is cleared on microtask so the next tick picks up fresh state.
let _queueStatsCache = null ;
function _computeQueueStats ( ) {
if ( _queueStatsCache ) return _queueStatsCache ;
let remaining = 0 , inProgress = 0 , done = 0 , errors = 0 ;
let bytesRemaining = 0 , totalSize = 0 , remainingSize = 0 , inProgressBytes = 0 ;
const total = queueJobs . length ;
@ -1999,7 +2035,9 @@ function _computeQueueStats() {
}
}
return { total , remaining , inProgress , done , errors , bytesRemaining , totalSize , remainingSize , inProgressBytes } ;
_queueStatsCache = { total , remaining , inProgress , done , errors , bytesRemaining , totalSize , remainingSize , inProgressBytes } ;
queueMicrotask ( ( ) => { _queueStatsCache = null ; } ) ;
return _queueStatsCache ;
}
function updateStatusBar ( ) {