/* Shared OpenVibe upload UI. Sites supply their upload(file, {onProgress}) transport. */
(function (root, factory) {
var api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root && root.document) root.OVUpload = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
function size(bytes) {
if (!Number.isFinite(bytes)) return '0 B';
if (bytes < 1024) return Math.round(bytes) + ' B';
var unit = Math.min(3, Math.floor(Math.log(bytes) / Math.log(1024)));
return (bytes / Math.pow(1024, unit)).toFixed(1) + ' ' + ['B', 'KB', 'MB', 'GB'][unit];
}
function timeLeft(seconds) {
if (!Number.isFinite(seconds)) return 'Estimating time left';
if (seconds < 5) return 'Almost done';
if (seconds < 60) return 'About ' + Math.ceil(seconds / 5) * 5 + ' sec left';
if (seconds < 3600) return 'About ' + Math.ceil(seconds / 60) + ' min left';
return 'About ' + Math.ceil(seconds / 3600) + ' hr left';
}
// SubtleCrypto has no streaming digest API. Read in slices to keep file access responsive,
// then hash only modest files so hashing cannot become an unbounded pre-upload cost.
async function sha256(file, maxBytes) {
if (!globalThis.crypto || !globalThis.crypto.subtle || file.size > (maxBytes || 32 * 1024 * 1024)) return null;
var sampleSize = Math.min(file.size, 1024 * 1024);
var t0 = typeof performance !== 'undefined' ? performance.now() : Date.now();
var sample = await file.slice(0, sampleSize).arrayBuffer();
await globalThis.crypto.subtle.digest('SHA-256', sample);
var measuredMs = Math.max(1, (typeof performance !== 'undefined' ? performance.now() : Date.now()) - t0);
var uploadMbps = typeof navigator !== 'undefined' && navigator.connection && Number(navigator.connection.downlink) > 0
? Number(navigator.connection.downlink) : 10;
var estimatedHashMs = (file.size / Math.max(1, sampleSize)) * measuredMs + 4;
var estimatedUploadMs = 600 + (file.size / (uploadMbps * 125000)) * 1000;
if (estimatedHashMs > estimatedUploadMs) return null;
var chunks = [], size = 0, step = 1024 * 1024;
for (var at = 0; at < file.size; at += step) {
var part = await file.slice(at, Math.min(file.size, at + step)).arrayBuffer();
chunks.push(part); size += part.byteLength;
}
var all = new Uint8Array(size), offset = 0;
chunks.forEach(function (part) { all.set(new Uint8Array(part), offset); offset += part.byteLength; });
var digest = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', all));
return Array.from(digest, function (b) { return b.toString(16).padStart(2, '0'); }).join('');
}
// Seconds are smoothed by measured bytes per second, not the last network burst.
function rate(previous, deltaBytes, deltaSeconds, alpha) {
if (deltaBytes <= 0 || deltaSeconds <= 0) return previous || 0;
var sample = deltaBytes / deltaSeconds;
return previous > 0 ? (alpha || 0.25) * sample + (1 - (alpha || 0.25)) * previous : sample;
}
// Line icons (constants, never data): upload, a file, close.
var ICON_UP = '';
var ICON_FILE = '';
var ICON_X = '';
// "2026-11-10T08:14:00Z" → "10 Nov 2026": the day a person would say, the same in every locale's tests.
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function day(value) {
var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value || ''));
return m && MONTHS[Number(m[2]) - 1] ? Number(m[3]) + ' ' + MONTHS[Number(m[2]) - 1] + ' ' + m[1] : (value ? String(value) : 'an earlier day');
}
function node(tag, className, text) {
var el = document.createElement(tag);
if (className) el.className = className;
if (text != null) el.textContent = text;
return el;
}
function progress(el, options) {
options = options || {};
el.classList.add('ov-progress');
if (options.compact) el.classList.add('ov-progress--row');
var label = node('span', 'ov-progress__label', 'Waiting');
var percent = node('strong', 'ov-progress__percent', '0%');
var heading = node('div', 'ov-progress__heading');
heading.append(label, percent);
var track = node('div', 'ov-progress__track');
track.setAttribute('role', 'progressbar');
track.setAttribute('aria-valuemin', '0');
track.setAttribute('aria-valuemax', '100');
var fill = node('div', 'ov-progress__fill');
track.append(fill);
var detail = node('p', 'ov-progress__detail', 'Ready when you are');
var retry = node('button', 'ov-progress__retry', 'Retry');
retry.type = 'button'; retry.hidden = true;
retry.addEventListener('click', function () { if (options.onRetry) options.onRetry(); });
el.replaceChildren(heading, track, detail, retry);
var current = { state: 'waiting', done: 0, total: 0, rate: 0, at: 0, lastBytes: 0, message: '' };
function render() {
var state = current.state;
var pct = current.total ? Math.min(100, Math.floor(current.done / current.total * 100)) : 0;
el.dataset.state = state;
label.textContent = ({ waiting: 'Waiting', sending: 'Uploading', paused: 'Paused: no connection', processing: 'Processing: making it playable', done: 'Uploaded', failed: 'Upload failed' })[state] || state;
if (state === 'done') percent.innerHTML = '';
else percent.textContent = pct + '%';
fill.style.width = (state === 'done' ? 100 : pct) + '%';
track.setAttribute('aria-valuenow', String(pct));
track.setAttribute('aria-valuetext', label.textContent + ', ' + pct + '%');
var meta = current.total ? size(current.done) + ' of ' + size(current.total) : '';
if (state === 'sending') detail.textContent = [meta, current.rate ? size(current.rate) + '/s' : '', timeLeft((current.total - current.done) / current.rate)].filter(Boolean).join(' · ');
else if (state === 'paused') detail.textContent = 'Your upload resumes when the connection returns.';
else if (state === 'processing') detail.textContent = current.message || 'Finishing your file';
else if (state === 'failed') detail.textContent = current.message || 'The upload did not go through.';
else if (state === 'done') detail.textContent = current.message || 'Your file is ready.';
else detail.textContent = meta || 'Ready when you are';
retry.hidden = state !== 'failed';
}
function update(next) {
next = next || {};
var now = Date.now();
var done = Math.max(0, Number(next.done == null ? current.done : next.done) || 0);
var total = Math.max(0, Number(next.total == null ? current.total : next.total) || 0);
if (done > current.lastBytes && current.at) current.rate = rate(current.rate, done - current.lastBytes, (now - current.at) / 1000);
if (done !== current.lastBytes) { current.lastBytes = done; current.at = now; }
current.done = done; current.total = total;
if (next.state) current.state = next.state;
if (next.message !== undefined) current.message = next.message;
render();
}
var timer = typeof window === 'undefined' ? null : window.setInterval(function () {
if (current.state !== 'sending') return;
if (Date.now() - current.at > 5000) current.rate *= 0.65;
render();
}, 1000);
render();
return { update: update, destroy: function () { if (timer) clearInterval(timer); } };
}
async function preflight(file, lookup, row, callbacks, maxBytes) {
if (typeof lookup !== 'function') return null;
var hash = await sha256(file, maxBytes);
if (!hash) return null;
var existing = await lookup(file, hash);
if (!existing) return null;
var target = existing.object || existing;
var actions = node('div', 'ov-upload__duplicate-actions');
var note = node('p', 'ov-upload__duplicate-note', 'You already uploaded this on ' + day(target.date || target.created_at) + '.');
var open = node('a', 'ovk-btn ovk-btn--quiet', 'Open it'); open.href = target.address || target.url || '#';
var use = node('button', 'ovk-btn ovk-btn--quiet', 'Use that one'); use.type = 'button';
use.addEventListener('click', function () { if (callbacks && callbacks.onUse) callbacks.onUse(target); });
var again = node('button', 'ovk-btn ovk-btn--quiet', 'Upload again'); again.type = 'button';
again.addEventListener('click', function () { actions.remove(); if (callbacks && callbacks.onAgain) callbacks.onAgain(); });
actions.append(note, open, use, again); row.append(actions);
row.dispatchEvent(new CustomEvent('ov-upload:duplicate', { detail: { file: file, existing: target } }));
if (callbacks && callbacks.onDuplicate) callbacks.onDuplicate(target);
return target;
}
function picker(root, options) {
options = options || {};
var input = node('input', 'ov-upload__input');
input.type = 'file'; input.accept = options.accept || ''; input.multiple = !!options.multiple;
input.setAttribute('aria-label', options.label || 'Choose a file');
var card = node('button', 'ov-upload__picker');
card.type = 'button';
card.innerHTML = '' + ICON_UP + 'or drop ' + (options.multiple ? 'them' : 'it') + ' here';
card.querySelector('strong').textContent = options.label || 'Choose a file';
card.addEventListener('click', function () { input.click(); });
var rows = node('div', 'ov-upload__rows');
root.classList.add('ov-upload'); root.append(input, card, rows);
function add(files) {
Array.prototype.forEach.call(options.multiple ? files : Array.prototype.slice.call(files, 0, 1), function (file) {
var row = node('div', 'ov-upload__row');
var preview = node('div', 'ov-upload__preview'); preview.innerHTML = ICON_FILE;
if (file.type.indexOf('image/') === 0 || file.type.indexOf('video/') === 0) {
var url = URL.createObjectURL(file);
var media = node(file.type.indexOf('image/') === 0 ? 'img' : 'video');
media.src = url; media.muted = true; media.preload = 'metadata'; media.setAttribute('aria-hidden', 'true');
media.addEventListener('loadeddata', function () { if (media.tagName === 'VIDEO') media.currentTime = Math.min(0.1, media.duration || 0); }, { once: true });
row.addEventListener('remove', function () { URL.revokeObjectURL(url); });
preview.replaceChildren(media);
}
var text = node('div', 'ov-upload__file');
text.append(node('strong', '', file.name), node('small', '', size(file.size)));
var remove = node('button', 'ov-upload__remove'); remove.type = 'button'; remove.innerHTML = ICON_X;
remove.setAttribute('aria-label', 'Remove ' + file.name); remove.title = 'Remove';
remove.addEventListener('click', function () { row.dispatchEvent(new Event('remove')); row.remove(); if (options.onRemove) options.onRemove(file); });
row.append(preview, text, remove); rows.append(row);
if (options.onFile) options.onFile(file, row);
});
}
input.addEventListener('change', function () { add(input.files); input.value = ''; });
root.addEventListener('dragover', function (e) { e.preventDefault(); root.classList.add('is-dragging'); });
root.addEventListener('dragleave', function () { root.classList.remove('is-dragging'); });
root.addEventListener('drop', function (e) { e.preventDefault(); root.classList.remove('is-dragging'); if (e.dataTransfer && e.dataTransfer.files) add(e.dataTransfer.files); });
return { input: input, add: add, rows: rows };
}
function queue(root, options) {
options = options || {};
if (typeof options.upload !== 'function') throw new TypeError('OVUpload.queue: upload(file, {onProgress, signal}) is required');
var pickRoot = node('div');
var totalRoot = node('div', 'ov-upload__total');
totalRoot.hidden = true;
root.append(pickRoot);
var items = [], active = 0;
var total = progress(totalRoot);
function refresh() {
var bytes = items.reduce(function (n, item) { return n + item.file.size; }, 0);
var done = items.reduce(function (n, item) { return n + item.done; }, 0);
var state = items.some(function (item) { return item.state === 'sending'; }) ? 'sending'
: items.some(function (item) { return item.state === 'failed'; }) ? 'failed'
: items.length && items.every(function (item) { return item.state === 'done' || item.state === 'duplicate'; }) ? 'done' : 'waiting';
total.update({ done: done, total: bytes, state: state, message: state === 'failed' ? 'A file needs a retry.' : '' });
totalRoot.hidden = items.length < 2;
}
function pump() {
while (active < (options.concurrency || 3)) {
var item = items.find(function (x) { return x.state === 'waiting'; });
if (!item) break;
item.state = 'checking'; active++;
item.meter.update({ state: 'sending', done: item.done, total: item.file.size }); refresh();
(function (entry) {
entry.controller = new AbortController();
Promise.resolve().then(async function () {
if (options.checkDuplicate && !entry.keepBoth) {
var hash = await sha256(entry.file, options.hashLimitBytes);
if (hash) {
var existing = await options.checkDuplicate(entry.file, hash);
entry.sha256 = hash;
if (existing) {
existing = existing.object || existing;
entry.state = 'duplicate'; entry.duplicate = existing;
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) + '.' });
entry.duplicateActions = duplicateActions(entry, existing);
if (options.onDuplicate) options.onDuplicate(entry.file, existing);
root.dispatchEvent(new CustomEvent('ov-upload:duplicate', { detail: { file: entry.file, existing: existing } }));
refresh(); return { __duplicate: true };
}
}
}
return options.upload(entry.file, {
signal: entry.controller.signal,
keepBoth: !!entry.keepBoth,
sha256: entry.sha256 || null,
onProgress: function (value) {
entry.done = Math.max(0, Math.min(entry.file.size, Number(value.uploadedBytes == null ? value.done : value.uploadedBytes) || 0));
entry.meter.update({ state: 'sending', done: entry.done, total: entry.file.size }); refresh();
}
}); }).then(function (result) {
if (entry.removed) return;
if (result && result.__duplicate) return;
entry.done = entry.file.size; entry.state = 'done';
entry.meter.update({ state: 'done', done: entry.done, total: entry.file.size });
if (options.onDone) options.onDone(entry.file, result);
}).catch(function (error) {
if (entry.removed) return;
entry.state = 'failed';
entry.meter.update({ state: 'failed', message: error && error.message || 'The upload did not go through.' });
}).finally(function () { active--; refresh(); pump(); });
})(item);
}
}
function duplicateActions(entry, existing) {
var actions = node('div', 'ov-upload__duplicate-actions');
var open = node('a', 'ovk-btn ovk-btn--quiet', 'Open it'); open.href = existing.url || existing.address || '#';
open.addEventListener('click', function () { if (options.onOpenDuplicate) options.onOpenDuplicate(entry.file, existing); });
var use = node('button', 'ovk-btn ovk-btn--quiet', 'Use that one'); use.type = 'button';
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 } })); });
var again = node('button', 'ovk-btn ovk-btn--quiet', 'Upload again'); again.type = 'button';
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(); });
actions.append(open, use, again); entry.row.append(actions); return actions;
}
var pick = picker(pickRoot, {
label: options.label, accept: options.accept, multiple: options.multiple !== false,
onFile: function (file, row) {
var meterRoot = node('div', 'ov-upload__meter'); row.append(meterRoot); row.classList.add('has-meter');
var item = { file: file, row: row, meterRoot: meterRoot, done: 0, state: 'waiting' };
item.meter = progress(meterRoot, { compact: true, onRetry: function () { item.state = 'waiting'; pump(); } });
items.push(item); refresh(); pump();
},
onRemove: function (file) {
var item = items.find(function (x) { return x.file === file; });
if (!item) return;
item.removed = true;
if (item.controller) item.controller.abort();
item.meter.destroy(); item.meterRoot.remove();
items.splice(items.indexOf(item), 1); refresh();
}
});
pick.rows.before(totalRoot); // the total sits between the picker and the files
return { picker: pick, total: total, items: items };
}
return { progress: progress, picker: picker, queue: queue, preflight: preflight, rate: rate, timeLeft: timeLeft, size: size, sha256: sha256 };
});