openvibers/openvibe.shared ov-upload.js

ov-upload.js

main 19.6 KB · 281 lines · javascript

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 'use strict';
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