diff --git a/public/index.html b/public/index.html index 3ec0aaa..fdd23d9 100644 --- a/public/index.html +++ b/public/index.html @@ -1134,6 +1134,52 @@ async function startUpload() { // Node only signs URLs; file data never touches the server. // Falls back to server-proxied chunked upload if presigned fails. // ============================================================ +// Upload speed tracker — smoothed speed + ETA +class SpeedTracker { + constructor(totalBytes) { + this.total = totalBytes; + this.startTime = Date.now(); + this.samples = []; // {time, bytes} + this.uploaded = 0; + } + update(bytes) { + this.uploaded = bytes; + const now = Date.now(); + this.samples.push({ time: now, bytes }); + // Keep last 5 seconds of samples for smoothing + const cutoff = now - 5000; + this.samples = this.samples.filter(s => s.time >= cutoff); + } + speed() { + if (this.samples.length < 2) return 0; + const first = this.samples[0]; + const last = this.samples[this.samples.length - 1]; + const dt = (last.time - first.time) / 1000; + if (dt < 0.5) return 0; + return (last.bytes - first.bytes) / dt; + } + eta() { + const s = this.speed(); + if (s <= 0) return ''; + const remaining = this.total - this.uploaded; + const secs = Math.round(remaining / s); + if (secs < 60) return secs + 's'; + if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's'; + return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm'; + } + format(pct) { + const s = this.speed(); + let txt = pct + '%'; + if (s > 0) { + const mbps = (s / (1024 * 1024)).toFixed(1); + txt += ' \u00b7 ' + mbps + ' MB/s'; + const e = this.eta(); + if (e) txt += ' \u00b7 ~' + e; + } + return txt; + } +} + const UPLOAD_CONCURRENCY = 6; // concurrent file uploads const CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB per chunk const CHUNKS_PARALLEL = 6; // concurrent chunks per file @@ -1161,6 +1207,8 @@ async function uploadFilePresigned(item, idx) { const mime = item.file.type || 'application/octet-stream'; const totalParts = Math.max(1, Math.ceil(item.file.size / CHUNK_SIZE)); + const tracker = new SpeedTracker(item.file.size); + // Small files (<= 8 MB): single presigned PUT if (totalParts === 1) { setFileStatus(idx, 'uploading', 'Getting URL\u2026'); @@ -1172,9 +1220,10 @@ async function uploadFilePresigned(item, idx) { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { + tracker.update(e.loaded); const pct = Math.round(e.loaded / e.total * 100); if (pb) pb.style.width = pct + '%'; - setFileStatus(idx, 'uploading', pct + '%'); + setFileStatus(idx, 'uploading', tracker.format(pct)); } }); xhr.addEventListener('load', async () => { @@ -1220,16 +1269,18 @@ async function uploadFilePresigned(item, idx) { if (e.lengthComputable) { const chunkPct = e.loaded / e.total; const totalDone = uploaded + chunkPct * chunkSize; + tracker.update(totalDone); const pct = Math.round(totalDone / item.file.size * 100); if (pb) pb.style.width = Math.min(pct, 99) + '%'; - setFileStatus(idx, 'uploading', Math.min(pct, 99) + '%'); + setFileStatus(idx, 'uploading', tracker.format(Math.min(pct, 99))); } }); completedParts.push({ PartNumber: i + 1, ETag: etag }); uploaded += chunkSize; + tracker.update(uploaded); const pct = Math.round(uploaded / item.file.size * 100); if (pb) pb.style.width = Math.min(pct, 99) + '%'; - setFileStatus(idx, 'uploading', Math.min(pct, 99) + '%'); + setFileStatus(idx, 'uploading', tracker.format(Math.min(pct, 99))); } catch (e) { chunkError = e; } } }