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; }
}
}