openvibers/openvibe.shared ov-upload.js
ov-upload.js
| 1 | /* Shared OpenVibe upload UI. Sites supply their upload(file, {onProgress}) transport. */ |
| 2 | (function (root, factory) { |
| 3 | var api = factory(); |
| 4 | if (typeof module === 'object' && module.exports) module.exports = api; |
| 5 | if (root && root.document) root.OVUpload = api; |
| 6 | })(typeof globalThis !== 'undefined' ? globalThis : this, function () { |
| 7 | ; |
| 8 | function size(bytes) { |
| 9 | if (!Number.isFinite(bytes)) return '0 B'; |
| 10 | if (bytes < 1024) return Math.round(bytes) + ' B'; |
| 11 | var unit = Math.min(3, Math.floor(Math.log(bytes) / Math.log(1024))); |
| 12 | return (bytes / Math.pow(1024, unit)).toFixed(1) + ' ' + ['B', 'KB', 'MB', 'GB'][unit]; |
| 13 | } |
| 14 | function timeLeft(seconds) { |
| 15 | if (!Number.isFinite(seconds)) return 'Estimating time left'; |
| 16 | if (seconds < 5) return 'Almost done'; |
| 17 | if (seconds < 60) return 'About ' + Math.ceil(seconds / 5) * 5 + ' sec left'; |
| 18 | if (seconds < 3600) return 'About ' + Math.ceil(seconds / 60) + ' min left'; |
| 19 | return 'About ' + Math.ceil(seconds / 3600) + ' hr left'; |
| 20 | } |
| 21 | // SubtleCrypto has no streaming digest API. Read in slices to keep file access responsive, |
| 22 | // then hash only modest files so hashing cannot become an unbounded pre-upload cost. |
| 23 | async function sha256(file, maxBytes) { |
| 24 | if (!globalThis.crypto || !globalThis.crypto.subtle || file.size > (maxBytes || 32 * 1024 * 1024)) return null; |
| 25 | var sampleSize = Math.min(file.size, 1024 * 1024); |
| 26 | var t0 = typeof performance !== 'undefined' ? performance.now() : Date.now(); |
| 27 | var sample = await file.slice(0, sampleSize).arrayBuffer(); |
| 28 | await globalThis.crypto.subtle.digest('SHA-256', sample); |
| 29 | var measuredMs = Math.max(1, (typeof performance !== 'undefined' ? performance.now() : Date.now()) - t0); |
| 30 | var uploadMbps = typeof navigator !== 'undefined' && navigator.connection && Number(navigator.connection.downlink) > 0 |
| 31 | ? Number(navigator.connection.downlink) : 10; |
| 32 | var estimatedHashMs = (file.size / Math.max(1, sampleSize)) * measuredMs + 4; |
| 33 | var estimatedUploadMs = 600 + (file.size / (uploadMbps * 125000)) * 1000; |
| 34 | if (estimatedHashMs > estimatedUploadMs) return null; |
| 35 | var chunks = [], size = 0, step = 1024 * 1024; |
| 36 | for (var at = 0; at < file.size; at += step) { |
| 37 | var part = await file.slice(at, Math.min(file.size, at + step)).arrayBuffer(); |
| 38 | chunks.push(part); size += part.byteLength; |
| 39 | } |
| 40 | var all = new Uint8Array(size), offset = 0; |
| 41 | chunks.forEach(function (part) { all.set(new Uint8Array(part), offset); offset += part.byteLength; }); |
| 42 | var digest = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', all)); |
| 43 | return Array.from(digest, function (b) { return b.toString(16).padStart(2, '0'); }).join(''); |
| 44 | } |
| 45 | // Seconds are smoothed by measured bytes per second, not the last network burst. |
| 46 | function rate(previous, deltaBytes, deltaSeconds, alpha) { |
| 47 | if (deltaBytes <= 0 || deltaSeconds <= 0) return previous || 0; |
| 48 | var sample = deltaBytes / deltaSeconds; |
| 49 | return previous > 0 ? (alpha || 0.25) * sample + (1 - (alpha || 0.25)) * previous : sample; |
| 50 | } |
| 51 | // Line icons (constants, never data): upload, a file, close. |
| 52 | var ICON_UP = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V4m0 0-4.5 4.5M12 4l4.5 4.5"/><path d="M4 14v3a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-3"/></svg>'; |
| 53 | var ICON_FILE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>'; |
| 54 | var ICON_X = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>'; |
| 55 | // "2026-11-10T08:14:00Z" → "10 Nov 2026": the day a person would say, the same in every locale's tests. |
| 56 | var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; |
| 57 | function day(value) { |
| 58 | var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value || '')); |
| 59 | return m && MONTHS[Number(m[2]) - 1] ? Number(m[3]) + ' ' + MONTHS[Number(m[2]) - 1] + ' ' + m[1] : (value ? String(value) : 'an earlier day'); |
| 60 | } |
| 61 | function node(tag, className, text) { |
| 62 | var el = document.createElement(tag); |
| 63 | if (className) el.className = className; |
| 64 | if (text != null) el.textContent = text; |
| 65 | return el; |
| 66 | } |
| 67 | function progress(el, options) { |
| 68 | options = options || {}; |
| 69 | el.classList.add('ov-progress'); |
| 70 | if (options.compact) el.classList.add('ov-progress--row'); |
| 71 | var label = node('span', 'ov-progress__label', 'Waiting'); |
| 72 | var percent = node('strong', 'ov-progress__percent', '0%'); |
| 73 | var heading = node('div', 'ov-progress__heading'); |
| 74 | heading.append(label, percent); |
| 75 | var track = node('div', 'ov-progress__track'); |
| 76 | track.setAttribute('role', 'progressbar'); |
| 77 | track.setAttribute('aria-valuemin', '0'); |
| 78 | track.setAttribute('aria-valuemax', '100'); |
| 79 | var fill = node('div', 'ov-progress__fill'); |
| 80 | track.append(fill); |
| 81 | var detail = node('p', 'ov-progress__detail', 'Ready when you are'); |
| 82 | var retry = node('button', 'ov-progress__retry', 'Retry'); |
| 83 | retry.type = 'button'; retry.hidden = true; |
| 84 | retry.addEventListener('click', function () { if (options.onRetry) options.onRetry(); }); |
| 85 | el.replaceChildren(heading, track, detail, retry); |
| 86 | var current = { state: 'waiting', done: 0, total: 0, rate: 0, at: 0, lastBytes: 0, message: '' }; |
| 87 | function render() { |
| 88 | var state = current.state; |
| 89 | var pct = current.total ? Math.min(100, Math.floor(current.done / current.total * 100)) : 0; |
| 90 | el.dataset.state = state; |
| 91 | label.textContent = ({ waiting: 'Waiting', sending: 'Uploading', paused: 'Paused: no connection', processing: 'Processing: making it playable', done: 'Uploaded', failed: 'Upload failed' })[state] || state; |
| 92 | if (state === 'done') percent.innerHTML = '<svg class="ov-progress__check" viewBox="0 0 24 24" role="img" aria-label="Done"><path d="M4 12l5 5L20 6"/></svg>'; |
| 93 | else percent.textContent = pct + '%'; |
| 94 | fill.style.width = (state === 'done' ? 100 : pct) + '%'; |
| 95 | track.setAttribute('aria-valuenow', String(pct)); |
| 96 | track.setAttribute('aria-valuetext', label.textContent + ', ' + pct + '%'); |
| 97 | var meta = current.total ? size(current.done) + ' of ' + size(current.total) : ''; |
| 98 | if (state === 'sending') detail.textContent = [meta, current.rate ? size(current.rate) + '/s' : '', timeLeft((current.total - current.done) / current.rate)].filter(Boolean).join(' · '); |
| 99 | else if (state === 'paused') detail.textContent = 'Your upload resumes when the connection returns.'; |
| 100 | else if (state === 'processing') detail.textContent = current.message || 'Finishing your file'; |
| 101 | else if (state === 'failed') detail.textContent = current.message || 'The upload did not go through.'; |
| 102 | else if (state === 'done') detail.textContent = current.message || 'Your file is ready.'; |
| 103 | else detail.textContent = meta || 'Ready when you are'; |
| 104 | retry.hidden = state !== 'failed'; |
| 105 | } |
| 106 | function update(next) { |
| 107 | next = next || {}; |
| 108 | var now = Date.now(); |
| 109 | var done = Math.max(0, Number(next.done == null ? current.done : next.done) || 0); |
| 110 | var total = Math.max(0, Number(next.total == null ? current.total : next.total) || 0); |
| 111 | if (done > current.lastBytes && current.at) current.rate = rate(current.rate, done - current.lastBytes, (now - current.at) / 1000); |
| 112 | if (done !== current.lastBytes) { current.lastBytes = done; current.at = now; } |
| 113 | current.done = done; current.total = total; |
| 114 | if (next.state) current.state = next.state; |
| 115 | if (next.message !== undefined) current.message = next.message; |
| 116 | render(); |
| 117 | } |
| 118 | var timer = typeof window === 'undefined' ? null : window.setInterval(function () { |
| 119 | if (current.state !== 'sending') return; |
| 120 | if (Date.now() - current.at > 5000) current.rate *= 0.65; |
| 121 | render(); |
| 122 | }, 1000); |
| 123 | render(); |
| 124 | return { update: update, destroy: function () { if (timer) clearInterval(timer); } }; |
| 125 | } |
| 126 | async function preflight(file, lookup, row, callbacks, maxBytes) { |
| 127 | if (typeof lookup !== 'function') return null; |
| 128 | var hash = await sha256(file, maxBytes); |
| 129 | if (!hash) return null; |
| 130 | var existing = await lookup(file, hash); |
| 131 | if (!existing) return null; |
| 132 | var target = existing.object || existing; |
| 133 | var actions = node('div', 'ov-upload__duplicate-actions'); |
| 134 | var note = node('p', 'ov-upload__duplicate-note', 'You already uploaded this on ' + day(target.date || target.created_at) + '.'); |
| 135 | var open = node('a', 'ovk-btn ovk-btn--quiet', 'Open it'); open.href = target.address || target.url || '#'; |
| 136 | var use = node('button', 'ovk-btn ovk-btn--quiet', 'Use that one'); use.type = 'button'; |
| 137 | use.addEventListener('click', function () { if (callbacks && callbacks.onUse) callbacks.onUse(target); }); |
| 138 | var again = node('button', 'ovk-btn ovk-btn--quiet', 'Upload again'); again.type = 'button'; |
| 139 | again.addEventListener('click', function () { actions.remove(); if (callbacks && callbacks.onAgain) callbacks.onAgain(); }); |
| 140 | actions.append(note, open, use, again); row.append(actions); |
| 141 | row.dispatchEvent(new CustomEvent('ov-upload:duplicate', { detail: { file: file, existing: target } })); |
| 142 | if (callbacks && callbacks.onDuplicate) callbacks.onDuplicate(target); |
| 143 | return target; |
| 144 | } |
| 145 | function picker(root, options) { |
| 146 | options = options || {}; |
| 147 | var input = node('input', 'ov-upload__input'); |
| 148 | input.type = 'file'; input.accept = options.accept || ''; input.multiple = !!options.multiple; |
| 149 | input.setAttribute('aria-label', options.label || 'Choose a file'); |
| 150 | var card = node('button', 'ov-upload__picker'); |
| 151 | card.type = 'button'; |
| 152 | card.innerHTML = '<span class="ov-upload__icon" aria-hidden="true">' + ICON_UP + '</span><strong></strong><span class="ov-upload__drop">or drop ' + (options.multiple ? 'them' : 'it') + ' here</span>'; |
| 153 | card.querySelector('strong').textContent = options.label || 'Choose a file'; |
| 154 | card.addEventListener('click', function () { input.click(); }); |
| 155 | var rows = node('div', 'ov-upload__rows'); |
| 156 | root.classList.add('ov-upload'); root.append(input, card, rows); |
| 157 | function add(files) { |
| 158 | Array.prototype.forEach.call(options.multiple ? files : Array.prototype.slice.call(files, 0, 1), function (file) { |
| 159 | var row = node('div', 'ov-upload__row'); |
| 160 | var preview = node('div', 'ov-upload__preview'); preview.innerHTML = ICON_FILE; |
| 161 | if (file.type.indexOf('image/') === 0 || file.type.indexOf('video/') === 0) { |
| 162 | var url = URL.createObjectURL(file); |
| 163 | var media = node(file.type.indexOf('image/') === 0 ? 'img' : 'video'); |
| 164 | media.src = url; media.muted = true; media.preload = 'metadata'; media.setAttribute('aria-hidden', 'true'); |
| 165 | media.addEventListener('loadeddata', function () { if (media.tagName === 'VIDEO') media.currentTime = Math.min(0.1, media.duration || 0); }, { once: true }); |
| 166 | row.addEventListener('remove', function () { URL.revokeObjectURL(url); }); |
| 167 | preview.replaceChildren(media); |
| 168 | } |
| 169 | var text = node('div', 'ov-upload__file'); |
| 170 | text.append(node('strong', '', file.name), node('small', '', size(file.size))); |
| 171 | var remove = node('button', 'ov-upload__remove'); remove.type = 'button'; remove.innerHTML = ICON_X; |
| 172 | remove.setAttribute('aria-label', 'Remove ' + file.name); remove.title = 'Remove'; |
| 173 | remove.addEventListener('click', function () { row.dispatchEvent(new Event('remove')); row.remove(); if (options.onRemove) options.onRemove(file); }); |
| 174 | row.append(preview, text, remove); rows.append(row); |
| 175 | if (options.onFile) options.onFile(file, row); |
| 176 | }); |
| 177 | } |
| 178 | input.addEventListener('change', function () { add(input.files); input.value = ''; }); |
| 179 | root.addEventListener('dragover', function (e) { e.preventDefault(); root.classList.add('is-dragging'); }); |
| 180 | root.addEventListener('dragleave', function () { root.classList.remove('is-dragging'); }); |
| 181 | root.addEventListener('drop', function (e) { e.preventDefault(); root.classList.remove('is-dragging'); if (e.dataTransfer && e.dataTransfer.files) add(e.dataTransfer.files); }); |
| 182 | return { input: input, add: add, rows: rows }; |
| 183 | } |
| 184 | function queue(root, options) { |
| 185 | options = options || {}; |
| 186 | if (typeof options.upload !== 'function') throw new TypeError('OVUpload.queue: upload(file, {onProgress, signal}) is required'); |
| 187 | var pickRoot = node('div'); |
| 188 | var totalRoot = node('div', 'ov-upload__total'); |
| 189 | totalRoot.hidden = true; |
| 190 | root.append(pickRoot); |
| 191 | var items = [], active = 0; |
| 192 | var total = progress(totalRoot); |
| 193 | function refresh() { |
| 194 | var bytes = items.reduce(function (n, item) { return n + item.file.size; }, 0); |
| 195 | var done = items.reduce(function (n, item) { return n + item.done; }, 0); |
| 196 | var state = items.some(function (item) { return item.state === 'sending'; }) ? 'sending' |
| 197 | : items.some(function (item) { return item.state === 'failed'; }) ? 'failed' |
| 198 | : items.length && items.every(function (item) { return item.state === 'done' || item.state === 'duplicate'; }) ? 'done' : 'waiting'; |
| 199 | total.update({ done: done, total: bytes, state: state, message: state === 'failed' ? 'A file needs a retry.' : '' }); |
| 200 | totalRoot.hidden = items.length < 2; |
| 201 | } |
| 202 | function pump() { |
| 203 | while (active < (options.concurrency || 3)) { |
| 204 | var item = items.find(function (x) { return x.state === 'waiting'; }); |
| 205 | if (!item) break; |
| 206 | item.state = 'checking'; active++; |
| 207 | item.meter.update({ state: 'sending', done: item.done, total: item.file.size }); refresh(); |
| 208 | (function (entry) { |
| 209 | entry.controller = new AbortController(); |
| 210 | Promise.resolve().then(async function () { |
| 211 | if (options.checkDuplicate && !entry.keepBoth) { |
| 212 | var hash = await sha256(entry.file, options.hashLimitBytes); |
| 213 | if (hash) { |
| 214 | var existing = await options.checkDuplicate(entry.file, hash); |
| 215 | entry.sha256 = hash; |
| 216 | if (existing) { |
| 217 | existing = existing.object || existing; |
| 218 | entry.state = 'duplicate'; entry.duplicate = existing; |
| 219 | entry.meter.update({ state: 'done', done: entry.file.size, total: entry.file.size, message: 'You already uploaded this on ' + day(existing.date || existing.created_at) + '.' }); |
| 220 | entry.duplicateActions = duplicateActions(entry, existing); |
| 221 | if (options.onDuplicate) options.onDuplicate(entry.file, existing); |
| 222 | root.dispatchEvent(new CustomEvent('ov-upload:duplicate', { detail: { file: entry.file, existing: existing } })); |
| 223 | refresh(); return { __duplicate: true }; |
| 224 | } |
| 225 | } |
| 226 | } |
| 227 | return options.upload(entry.file, { |
| 228 | signal: entry.controller.signal, |
| 229 | keepBoth: !!entry.keepBoth, |
| 230 | sha256: entry.sha256 || null, |
| 231 | onProgress: function (value) { |
| 232 | entry.done = Math.max(0, Math.min(entry.file.size, Number(value.uploadedBytes == null ? value.done : value.uploadedBytes) || 0)); |
| 233 | entry.meter.update({ state: 'sending', done: entry.done, total: entry.file.size }); refresh(); |
| 234 | } |
| 235 | }); }).then(function (result) { |
| 236 | if (entry.removed) return; |
| 237 | if (result && result.__duplicate) return; |
| 238 | entry.done = entry.file.size; entry.state = 'done'; |
| 239 | entry.meter.update({ state: 'done', done: entry.done, total: entry.file.size }); |
| 240 | if (options.onDone) options.onDone(entry.file, result); |
| 241 | }).catch(function (error) { |
| 242 | if (entry.removed) return; |
| 243 | entry.state = 'failed'; |
| 244 | entry.meter.update({ state: 'failed', message: error && error.message || 'The upload did not go through.' }); |
| 245 | }).finally(function () { active--; refresh(); pump(); }); |
| 246 | })(item); |
| 247 | } |
| 248 | } |
| 249 | function duplicateActions(entry, existing) { |
| 250 | var actions = node('div', 'ov-upload__duplicate-actions'); |
| 251 | var open = node('a', 'ovk-btn ovk-btn--quiet', 'Open it'); open.href = existing.url || existing.address || '#'; |
| 252 | open.addEventListener('click', function () { if (options.onOpenDuplicate) options.onOpenDuplicate(entry.file, existing); }); |
| 253 | var use = node('button', 'ovk-btn ovk-btn--quiet', 'Use that one'); use.type = 'button'; |
| 254 | use.addEventListener('click', function () { entry.done = entry.file.size; entry.state = 'done'; entry.meter.update({ state: 'done', done: entry.done, total: entry.file.size, message: 'Using the picture you already uploaded.' }); refresh(); if (options.onDone) options.onDone(entry.file, existing); root.dispatchEvent(new CustomEvent('ov-upload:done', { detail: { file: entry.file, result: existing, duplicate: true } })); }); |
| 255 | var again = node('button', 'ovk-btn ovk-btn--quiet', 'Upload again'); again.type = 'button'; |
| 256 | again.addEventListener('click', function () { entry.keepBoth = true; entry.state = 'waiting'; entry.meter.update({ state: 'waiting', done: 0, total: entry.file.size, message: '' }); actions.remove(); entry.duplicate = null; pump(); }); |
| 257 | actions.append(open, use, again); entry.row.append(actions); return actions; |
| 258 | } |
| 259 | var pick = picker(pickRoot, { |
| 260 | label: options.label, accept: options.accept, multiple: options.multiple !== false, |
| 261 | onFile: function (file, row) { |
| 262 | var meterRoot = node('div', 'ov-upload__meter'); row.append(meterRoot); row.classList.add('has-meter'); |
| 263 | var item = { file: file, row: row, meterRoot: meterRoot, done: 0, state: 'waiting' }; |
| 264 | item.meter = progress(meterRoot, { compact: true, onRetry: function () { item.state = 'waiting'; pump(); } }); |
| 265 | items.push(item); refresh(); pump(); |
| 266 | }, |
| 267 | onRemove: function (file) { |
| 268 | var item = items.find(function (x) { return x.file === file; }); |
| 269 | if (!item) return; |
| 270 | item.removed = true; |
| 271 | if (item.controller) item.controller.abort(); |
| 272 | item.meter.destroy(); item.meterRoot.remove(); |
| 273 | items.splice(items.indexOf(item), 1); refresh(); |
| 274 | } |
| 275 | }); |
| 276 | pick.rows.before(totalRoot); // the total sits between the picker and the files |
| 277 | return { picker: pick, total: total, items: items }; |
| 278 | } |
| 279 | return { progress: progress, picker: picker, queue: queue, preflight: preflight, rate: rate, timeLeft: timeLeft, size: size, sha256: sha256 }; |
| 280 | }); |
| 281 |