openvibers/openvibe.mediahub Commits
Video upload: the shared picker and progress, and Your videos (#358)
Changes
nnew file mode 100644BBinary files /dev/null and b/docs/screenshots/upload/upload-video-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/upload/upload-video-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/upload/upload-video-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/upload/upload-video-390-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/upload/your-videos-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/upload/your-videos-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/upload/your-videos-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/upload/your-videos-390-light.png differ@@@ -480,8 +480,27 @@ a.ovv-channel-name:hover { text-decoration: underline; }.ovv-cutting p { margin: 0; max-width: 40ch; }.ovv-cutting i { color: var(--accent-light, var(--ovv-accent)); margin-right: 6px; }/* Uploads (/upload, /u/:id, /me/videos) */--.ovv-upload { display: grid; gap: 12px; max-width: 640px; }--.ovv-upload progress { width: 100%; height: 10px; }++/* Upload (Opus's design pass, 11-09): the video on the left and its details on the right from 900 px; one column on a++ phone. The meter shows only once a video is chosen. */++.ovv-upload { display: grid; gap: 18px; max-width: 1040px; }++.ovv-upload-pick, .ovv-upload-details { display: grid; gap: 12px; align-content: start; min-width: 0; }++.ovv-upload ov-progress[hidden] { display: none; }++@media (min-width: 900px) {++ .ovv-upload { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; align-items: start; }++ .ovv-upload-pick { position: sticky; top: calc(var(--ovnav-h, 56px) + 16px); }++}++.ovv-visibility { display: grid; gap: 8px; padding: 0; border: 0; }++.ovv-visibility legend { margin-bottom: 8px; font-weight: 700; }++.ovv-visibility label { display: block; cursor: pointer; }++.ovv-visibility input { position: absolute; opacity: 0; }++.ovv-visibility label span { display: grid; gap: 3px; min-height: 58px; padding: 11px 14px; border: 1px solid var(--ovv-line-strong); border-radius: 12px; background: var(--ovv-card); }++.ovv-visibility label small { color: var(--ovv-dim); }++.ovv-visibility input:checked + span { border: 2px solid var(--ovv-accent); padding: 10px 13px; background: rgba(111, 91, 230, .13); }++.ovv-visibility input:focus-visible + span { outline: 3px solid var(--ovv-accent); outline-offset: 2px; }++.ovv-link-button { border: 0; padding: 0; background: none; color: var(--ovv-accent); font: inherit; text-decoration: underline; cursor: pointer; }++.ovv-rules { max-width: min(460px, calc(100vw - 28px)); padding: 24px; border: 1px solid var(--ovv-line-strong); border-radius: 18px; background: var(--ovv-card); color: inherit; box-shadow: 0 20px 70px rgba(0,0,0,.4); }++.ovv-rules::backdrop { background: rgba(5, 8, 23, .7); }++.ovv-rules p { line-height: 1.55; }.ovv-upload-status { margin: 0; min-height: 1.4em; }.ovv-processing { display: grid; place-items: center; min-height: 220px; padding: 20px; text-align: center; }.ovv-description p { white-space: pre-line; margin: 0; }@@@ -496,6 +515,42 @@ a.ovv-channel-name:hover { text-decoration: underline; }.ovv-edited-from { margin: 0 0 10px; }.ovv-table { width: 100%; border-collapse: collapse; }.ovv-table th, .ovv-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--ovv-line); }++++/* Your videos (/me/videos): the person's uploads as app rows: a picture, the title and its state, then one set of++ actions. A stopped upload carries the shared ov-progress meter and a Resume that reopens /upload. */++.ovv-my-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }++.ovv-my-head .ovv-lede { max-width: 54ch; }++.ovv-mylist { list-style: none; margin: 18px 0 0; padding: 0; display: grid; gap: 10px; }++.ovv-myrow { display: grid; grid-template-columns: 148px minmax(0, 1fr); gap: 14px; padding: 10px; border: 1px solid var(--ovv-line); border-radius: 16px; background: var(--ovv-card); }++.ovv-mythumb { display: grid; place-items: center; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 11px; color: var(--accent-light, var(--ovv-accent)); background: color-mix(in srgb, var(--ovv-accent) 12%, transparent); font-size: 1.6rem; text-decoration: none; }++.ovv-mythumb img { width: 100%; height: 100%; object-fit: cover; }++.ovv-mybody { display: grid; align-content: center; gap: 5px; min-width: 0; }++.ovv-mytitleline { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; min-width: 0; }++.ovv-mytitle { overflow-wrap: anywhere; color: inherit !important; font-weight: 700; text-decoration: none; }++.ovv-mytitle:hover, .ovv-mytitle:focus-visible { text-decoration: underline; }++.ovv-mystate { flex: none; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: var(--ovv-dim); font-size: .72rem; font-weight: 700; }++.ovv-myrow[data-state=ready] .ovv-mystate { background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); color: var(--success, #22c55e); }++.ovv-myrow[data-state=uploading] .ovv-mystate, .ovv-myrow[data-state=processing] .ovv-mystate { background: color-mix(in srgb, var(--ovv-accent) 16%, transparent); color: var(--accent-light, var(--ovv-accent)); }++.ovv-myrow[data-state=hidden] .ovv-mystate, .ovv-myrow[data-state=failed] .ovv-mystate { background: color-mix(in srgb, var(--danger, #ef4444) 12%, transparent); color: var(--danger-text, var(--danger, #ef4444)); }++.ovv-mymeta { margin: 0; color: var(--ovv-dim); font-size: .82rem; }++.ovv-myprogress { max-width: 440px; margin-top: 2px; }++.ovv-myprocess { display: flex; align-items: center; gap: 7px; margin: 0; color: var(--ovv-dim); font-size: .82rem; }++.ovv-pulse { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--ovv-accent); animation: ovv-pulse 1.4s ease-in-out infinite; }++@keyframes ovv-pulse { 50% { opacity: .35; transform: scale(.8); } }++.ovv-myactions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 3px; }++.ovv-my-empty { display: grid; justify-items: center; gap: 8px; margin-top: 18px; padding: 38px 20px; border: 1px dashed var(--ovv-line-strong); border-radius: 16px; text-align: center; }++.ovv-my-empty > i { color: var(--ovv-accent); font-size: 1.8rem; }++.ovv-my-empty h2, .ovv-my-empty p { margin: 0; }++.ovv-my-empty p { max-width: 46ch; color: var(--ovv-dim); }++.ovv-sheet-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; }++@media (max-width: 560px) {++ .ovv-myrow { grid-template-columns: 104px minmax(0, 1fr); gap: 10px; }++ .ovv-mytitleline { display: grid; gap: 3px; }++ .ovv-mystate { justify-self: start; }++}++@media (prefers-reduced-motion: reduce) { .ovv-pulse { animation: none; } }++html[data-ov-motion=reduced] .ovv-pulse { animation: none; }++/* Recording (/record) */.ovv-record { display: grid; gap: 12px; max-width: 760px; }.ovv-record-source { border: 0; padding: 0; margin: 0; display: flex; gap: 18px; flex-wrap: wrap; }nnew file mode 100644@@@ -0,0 +1,32 @@++/* Video's /me/videos: the shared progress meter on an upload that stopped part-way, and a kit sheet for Delete.++ * Nothing here asks the browser for a confirm: the sheet is the site's own <dialog>, and its form posts as before. */++(function () {++ 'use strict';++ var kit = window.OVUpload;++ Array.prototype.forEach.call(document.querySelectorAll('[data-ov-upload-progress]'), function (el) {++ if (!kit || !kit.progress) return;++ var meter = kit.progress(el, { compact: true });++ meter.update({++ state: el.getAttribute('data-state') || 'waiting',++ done: Number(el.getAttribute('data-done')) || 0,++ total: Number(el.getAttribute('data-total')) || 0++ });++ meter.destroy(); // the page is a snapshot: the row is not sending here++ });++ var sheet = document.querySelector('[data-delete-sheet]');++ if (!sheet) return;++ var form = sheet.querySelector('[data-delete-form]');++ var message = sheet.querySelector('[data-delete-message]');++ document.addEventListener('click', function (event) {++ var button = event.target.closest && event.target.closest('[data-delete-video]');++ if (button) {++ if (form) form.action = '/u/' + encodeURIComponent(button.getAttribute('data-delete-video')) + '/delete';++ if (message) message.textContent = '“' + (button.getAttribute('data-delete-title') || 'This video') + '” and its file are removed from OpenVibe.Media. This cannot be undone.';++ if (typeof sheet.showModal === 'function') sheet.showModal(); else sheet.setAttribute('open', '');++ return;++ }++ if (event.target.closest && event.target.closest('[data-delete-cancel]')) {++ if (typeof sheet.close === 'function') sheet.close(); else sheet.removeAttribute('open');++ }++ });++})();@@@ -1,72 +1,179 @@--/* OpenVibe.Video — /upload: sends the chosen video in 8 MB parts (POST /api/v1/videos, one PUT per part in order, then-- * complete), so a big file uploads in one go and the person watches the progress. A failed part is tried again three-- * times before the upload stops. Every request is same-origin: the session cookie and Sec-Fetch-Site are the browser's.-- * Nothing about the file but its size, type and name (to tell MOV from MP4) is sent before its bytes. */++/* Video's same-origin VOD adapter for the shared picker and progress component. */(function () {'use strict';var form = document.querySelector('form[data-video-upload]');-- if (!form || !window.fetch) return;-- var file = form.querySelector('input[type=file]');-- var bar = form.querySelector('progress');++ if (!form || !window.OVUpload) return;++ var kit = window.OVUpload;var status = form.querySelector('[role=status]');var button = form.querySelector('button[type=submit]');---- function say(t) { if (status) status.textContent = t; }-- function busy(on) { button.disabled = on; Array.prototype.forEach.call(form.elements, function (el) { if (el !== button) el.disabled = on; }); }-- function request(url, options) {-- return fetch(url, options).then(function (res) {-- if (res.ok) return res.json().catch(function () { return null; });-- return res.json().then(function (p) { throw new Error((p && p.detail) || ('The upload was refused (' + res.status + ').')); },-- function () { throw new Error('The upload was refused (' + res.status + ').'); });++ var sheet = document.querySelector('[data-upload-rules-sheet]');++ var state = { file: null, upload: null, sent: 0, sending: false, saved: false, complete: null, cancelled: false };++ var meterEl = form.querySelector('[data-ov-upload-progress]');++ var meter = kit.progress(meterEl, { onRetry: function () { if (state.file) send(); } });++ var picker = kit.picker(form.querySelector('[data-ov-upload-picker]'), {++ label: 'Choose a video', accept: 'video/mp4,video/quicktime,video/x-m4v,video/webm,video/x-matroska,.mp4,.mov,.m4v,.webm,.mkv',++ onFile: choose, onRemove: remove++ });++ form.querySelector('[data-upload-rules]').addEventListener('click', function () { sheet.showModal(); });++ function say(message) { status.textContent = message; }++ function titleOf(file) { return file.name.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim().slice(0, 120) || 'Untitled video'; }++ function request(path, options) {++ return fetch(path, options).then(function (response) {++ if (response.ok) return response.status === 204 ? null : response.json();++ return response.json().catch(function () { return {}; }).then(function (body) {++ var error = new Error(body.detail || 'The upload could not continue (' + response.status + ').');++ error.status = response.status; throw error;++ });});}-- function withRetry(fn, tries) {-- return fn().catch(function (err) {-- if (tries <= 1 || /refused \((4\d\d)\)|Send part|already finished/.test(err.message)) throw err;-- return new Promise(function (r) { setTimeout(r, 1500); }).then(function () { return withRetry(fn, tries - 1); });++ function json(method, path, body) { return request(path, { method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); }++ function db() {++ return new Promise(function (resolve, reject) {++ if (!window.indexedDB) { resolve(null); return; }++ var open = indexedDB.open('ov-video-upload', 1);++ open.onupgradeneeded = function () { open.result.createObjectStore('pending'); };++ open.onsuccess = function () { resolve(open.result); };++ open.onerror = function () { reject(open.error); };});}---- form.addEventListener('submit', function (event) {-- event.preventDefault();-- var f = file.files && file.files[0];-- if (!f) { say('Choose a video first.'); return; }-- var title = form.elements.title.value;-- var meta = { title: title, description: form.elements.description.value, visibility: form.elements.visibility.value, size: f.size, content_type: f.type || '', name: f.name };-- busy(true);-- bar.hidden = false; bar.max = f.size; bar.value = 0;-- say('Starting…');-- var sent = 0;-- var id = null;-- request('/api/v1/videos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(meta) }).then(function (out) {-- var up = out.upload;-- id = up.id;-- var chain = Promise.resolve();-- for (var n = 1; n <= up.parts_expected; n++) {-- chain = chain.then(function (part) {-- return function () {-- var start = (part - 1) * up.part_size;-- var slice = f.slice(start, Math.min(start + up.part_size, f.size));-- return withRetry(function () {-- return request('/api/v1/videos/' + up.id + '/parts/' + part, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream' }, body: slice });-- }, 3).then(function () {-- sent += slice.size;-- bar.value = sent;-- say('Uploading… ' + Math.round((sent / f.size) * 100) + '%');-- });-- };-- }(n));++ function stored(value) {++ return db().then(function (database) {++ if (!database) return;++ return new Promise(function (resolve, reject) {++ var tx = database.transaction('pending', 'readwrite');++ if (value) tx.objectStore('pending').put(value, 'video'); else tx.objectStore('pending').delete('video');++ tx.oncomplete = resolve; tx.onerror = function () { reject(tx.error); };++ });++ }).catch(function () { /* Storage quota can refuse a large File; this tab still works. */ });++ }++ function load() {++ return db().then(function (database) {++ if (!database) return null;++ return new Promise(function (resolve, reject) {++ var tx = database.transaction('pending', 'readonly');++ var get = tx.objectStore('pending').get('video');++ get.onsuccess = function () { resolve(get.result); };++ get.onerror = function () { reject(get.error); };++ });++ }).catch(function () { return null; });++ }++ function snapshot() { return stored(state.file && state.upload ? { file: state.file, upload: state.upload, title: form.elements.title.value, description: form.elements.description.value, visibility: form.elements.visibility.value, saved: state.saved } : null); }++ function choose(file) {++ if (state.file) { say('Remove this video before choosing another.'); return; }++ state.file = file; state.cancelled = false; state.saved = false; state.complete = null;++ meterEl.hidden = false;++ form.elements.title.value = titleOf(file);++ button.disabled = false;++ meter.update({ state: 'waiting', done: 0, total: file.size, message: '' });++ start();++ }++ function remove(file) {++ if (file !== state.file) return;++ state.cancelled = true;++ var id = state.upload && state.upload.id;++ state.file = null; state.upload = null; state.sent = 0; state.sending = false; state.complete = null;++ meterEl.hidden = true;++ button.disabled = true; meter.update({ state: 'waiting', done: 0, total: 0, message: '' }); say('Video removed.');++ stored(null);++ if (id) request('/api/v1/videos/' + encodeURIComponent(id), { method: 'DELETE' }).catch(function () {});++ }++ function start() {++ var file = state.file;++ if (!file) return;++ say('Starting your upload…');++ json('POST', '/api/v1/videos', { title: form.elements.title.value, description: '', visibility: 'private', size: file.size, content_type: file.type || '', name: file.name }).then(function (out) {++ if (state.cancelled || file !== state.file) { request('/api/v1/videos/' + out.upload.id, { method: 'DELETE' }).catch(function () {}); return; }++ state.upload = out.upload; snapshot(); send();++ }).catch(fail);++ }++ function putPart(id, number, blob, onProgress) {++ return new Promise(function (resolve, reject) {++ var xhr = new XMLHttpRequest();++ xhr.open('PUT', '/api/v1/videos/' + encodeURIComponent(id) + '/parts/' + number);++ xhr.setRequestHeader('Content-Type', 'application/octet-stream');++ xhr.upload.onprogress = function (event) { if (event.lengthComputable) onProgress(event.loaded); };++ xhr.onload = function () {++ if (xhr.status >= 200 && xhr.status < 300) { resolve(); return; }++ var detail = '';++ try { detail = JSON.parse(xhr.responseText).detail || ''; } catch (_) { /* network response */ }++ var err = new Error(detail || 'Part ' + number + ' was refused (' + xhr.status + ').');++ err.status = xhr.status; reject(err);++ };++ xhr.onerror = function () { reject(new Error('The connection dropped. Your video will resume.')); };++ xhr.send(blob);++ });++ }++ function waitOnline() {++ if (navigator.onLine !== false) return Promise.resolve();++ meter.update({ state: 'paused' }); say('Paused: no connection');++ return new Promise(function (resolve) { window.addEventListener('online', resolve, { once: true }); });++ }++ function retryPart(id, number, blob, base, attempt) {++ return waitOnline().then(function () {++ meter.update({ state: 'sending' });++ return putPart(id, number, blob, function (loaded) { meter.update({ done: base + loaded, total: state.file.size, state: 'sending' }); });++ }).catch(function (error) {++ if (state.cancelled || error.status && error.status < 500 || attempt >= 5) throw error;++ return new Promise(function (resolve) { setTimeout(resolve, Math.min(12000, 600 * Math.pow(2, attempt))); }).then(function () {++ return retryPart(id, number, blob, base, attempt + 1);++ });++ });++ }++ function send() {++ if (state.sending || !state.file || !state.upload) return;++ state.sending = true;++ var file = state.file, upload = state.upload;++ var id = encodeURIComponent(upload.id);++ request('/api/v1/videos/' + id + '/status').then(function (out) {++ if (out.upload.size !== file.size) throw new Error('Choose the same file to resume this upload.');++ if (out.upload.status !== 'uploading') { state.complete = { video: { url: '/u/' + id } }; return; }++ var n = out.upload.parts_received + 1;++ state.sent = out.upload.parts_received * upload.part_size;++ meter.update({ state: 'sending', done: state.sent, total: file.size });++ function next() {++ if (state.cancelled) return;++ if (n > upload.parts_expected) return;++ var base = (n - 1) * upload.part_size;++ var blob = file.slice(base, Math.min(base + upload.part_size, file.size));++ return retryPart(id, n, blob, base, 0).then(function () {++ state.sent = base + blob.size; n++;++ meter.update({ state: 'sending', done: state.sent, total: file.size });++ snapshot(); return next();++ });}-- return chain;++ return next().then(function () {++ if (state.cancelled) return;++ meter.update({ state: 'processing', done: file.size, total: file.size });++ return request('/api/v1/videos/' + id + '/complete', { method: 'POST' }).then(function (done) { state.complete = done; });++ });}).then(function () {-- say('Finishing…');-- return request('/api/v1/videos/' + id + '/complete', { method: 'POST' });-- }).then(function (done) {-- say('Uploaded. OpenVibe.Media is processing it; opening its page…');-- window.location.assign(done.video.url);-- }).catch(function (err) {-- busy(false);-- say((err && err.message) || 'The upload did not go through.');-- });++ state.sending = false;++ if (state.cancelled || !state.complete) return;++ if (state.saved) stored(null); else snapshot();++ meter.update({ state: 'done', done: file.size, total: file.size, message: 'Your video is processing.' });++ say(state.saved ? 'Opening your video…' : 'Upload complete. Save details to continue.');++ if (state.saved) window.location.assign(state.complete.video.url);++ }).catch(function (err) { state.sending = false; fail(err); });++ }++ function fail(err) { meter.update({ state: 'failed', message: err && err.message || 'The upload did not go through.' }); say('Upload paused. You can retry.'); }++ form.addEventListener('submit', function (event) {++ event.preventDefault();++ if (!state.upload || !state.file) { say('Choose a video first.'); return; }++ button.disabled = true;++ json('PATCH', '/api/v1/videos/' + encodeURIComponent(state.upload.id), { title: form.elements.title.value, description: form.elements.description.value, visibility: form.elements.visibility.value }).then(function () {++ state.saved = true; snapshot(); button.disabled = false; button.textContent = 'Details saved';++ if (state.complete) { stored(null); window.location.assign(state.complete.video.url); }++ else say('Details saved. Your video is still uploading.');++ }).catch(function (err) { button.disabled = false; say(err.message); });++ });++ window.addEventListener('beforeunload', function (event) { if (state.sending) { event.preventDefault(); event.returnValue = ''; } });++ load().then(function (pending) {++ if (state.file || !pending || !pending.file || !pending.upload) return;++ state.file = pending.file; state.upload = pending.upload; state.saved = !!pending.saved;++ form.elements.title.value = pending.title; form.elements.description.value = pending.description;++ form.elements.visibility.value = pending.visibility;++ button.disabled = false;++ picker.add([pending.file]);++ say('Resuming your video…'); send();});})();@@@ -71,6 +71,7 @@ ${appIcon.headTags({ site: 'media-hub' })}<link rel="stylesheet" href="${asset('css/app.css')}"><link rel="stylesheet" href="${asset('css/video.css')}">${(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`).join('\n')}++${(o.sharedScripts || []).map((name) => `<script src="${ovServe.url(name)}" defer></script>`).join('\n')}${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">\n`).join('')}${o.player ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n` : ''}${o.path === '/record' ? `<script src="${CAM_KIT_URL}" integrity="${CAM_KIT_SRI}" crossorigin="anonymous" defer></script>\n` : ''}${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;const account = signedIn? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${encodeURIComponent(pathNow)}">Sign out</a>`@@@ -63,6 +63,32 @@ function createUploadApi({ ctx, config }) {} catch (err) { return problem(res, err); }});++ r.get('/:id/status', async (req, res) => {++ if (!guard(req, res)) return undefined;++ const row = await own(req, res);++ if (!row) return undefined;++ return res.json({ upload: { id: row.id, status: row.status, part_size: row.part_size, parts_expected: row.parts_expected, parts_received: row.parts_received, size: Number(row.size_bytes), title: row.title, description: row.description, visibility: row.visibility } });++ });++++ r.patch('/:id', express.json({ limit: '16kb' }), async (req, res) => {++ if (!guard(req, res)) return undefined;++ const row = await own(req, res);++ if (!row) return undefined;++ try {++ const b = req.body || {};++ const updated = await up.update(row, { title: b.title, description: b.description, visibility: b.visibility });++ return res.json({ upload: { id: updated.id, title: updated.title, visibility: updated.visibility } });++ } catch (err) { return problem(res, err); }++ });++++ r.delete('/:id', async (req, res) => {++ if (!guard(req, res)) return undefined;++ const row = await own(req, res);++ if (!row) return undefined;++ await up.remove(row);++ return res.status(204).end();++ });++r.put('/:id/parts/:n', express.raw({ type: () => true, limit: `${Math.ceil(up.PART_SIZE / 1024) + 64}kb` }), async (req, res) => {if (!guard(req, res)) return undefined;const row = await own(req, res);@@@ -141,6 +167,35 @@ function mountUploadPages(r, h) {: row.status === 'uploading' ? `Upload stopped at ${Math.round((row.parts_received / row.parts_expected) * 100)}%`: row.status === 'processing' ? 'Processing' : row.status === 'failed' ? 'Failed' : visibilityLabel(row.visibility));++ // Your videos: one card per upload with the same words the watch page uses, its picture when Media has made one,++ // and the shared progress meter when the upload stopped part-way (Resume opens /upload, which reads IndexedDB).++ const uploadState = (row) => (row.state === 'hidden' ? 'hidden'++ : row.status === 'uploading' ? 'uploading' : row.status === 'processing' ? 'processing' : row.status === 'failed' ? 'failed' : 'ready');++ function myVideoRow(row) {++ const state = uploadState(row);++ const watch = row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`;++ const views = Number(row.views) || 0;++ const pct = row.parts_expected ? Math.round((Number(row.parts_received) / Number(row.parts_expected)) * 100) : 0;++ const meta = [visibilityLabel(row.visibility), row.duration_seconds ? durationLabel(row.duration_seconds) : null,++ `${views} view${views === 1 ? '' : 's'}`, dateLabel(row.created_at)].filter(Boolean).join(' · ');++ const done = Number(row.parts_received) * Number(row.part_size);++ const total = Number(row.size_bytes);++ return html`<li class="ovv-myrow" data-state="${state}">++<a class="ovv-mythumb" href="${watch}" tabindex="-1" aria-hidden="true">${row.thumbnail_url ? html`<img src="${row.thumbnail_url}" alt="" loading="lazy">` : html`<i class="fa-solid fa-film"></i>`}</a>++<div class="ovv-mybody">++<div class="ovv-mytitleline"><a class="ovv-mytitle" href="${watch}">${row.title}</a><span class="ovv-mystate">${statusText(row)}</span></div>++<p class="ovv-mymeta">${meta}</p>++${state === 'uploading' ? html`<div class="ovv-myprogress" data-ov-upload-progress data-state="waiting" data-done="${done}" data-total="${total}">Upload stopped at ${pct}%</div>` : ''}++${state === 'processing' ? html`<p class="ovv-myprocess"><span class="ovv-pulse" aria-hidden="true"></span> Making it playable. You can close this page.</p>` : ''}++<div class="ovv-myactions">++${state === 'uploading' ? html`<a class="ovv-btn ovv-btn--small ovv-btn--primary" href="/upload">Resume upload</a>` : ''}++${state === 'ready' ? html`<a class="ovv-btn ovv-btn--small" href="${watch}">Watch</a> <a class="ovv-btn ovv-btn--small" href="${watch}#h-owner">Edit</a>` : ''}++${state === 'processing' || state === 'hidden' || state === 'failed' ? html`<a class="ovv-btn ovv-btn--small" href="${watch}">Open</a>` : ''}++<button class="ovv-btn ovv-btn--small ovv-btn--danger" type="button" data-delete-video="${row.id}" data-delete-title="${row.title}">Delete</button>++</div>++</div></li>`;++ }++const CUT_STATUS = { queued: 'Waiting to start', measuring: 'Finding the pauses', rendering: 'Making the copy' };const CUT_LABEL = { pauses: 'Pauses cut', tight: 'Tight cut', highlights: 'Highlights reel' };function cutLine(c) {@@@ -172,19 +227,27 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}r.get('/upload', mustSignIn('/upload', 'Uploads belong to your OpenVibe account: sign in to upload a video.'), async (req, res) => page(req, res, {title: 'Upload a video',crumbs: [{ label: 'Home', href: '/' }, { label: 'Upload' }],++ styles: ['ov-upload.css'],++ sharedScripts: ['ov-upload.js'],scripts: ['js/video-upload.js'],body: html`<h1>Upload a video</h1><form class="ovv-upload ovv-panel" data-video-upload method="post" action="/upload">--<label class="ovv-field"><span>Video file</span><input name="file" type="file" accept="video/mp4,video/quicktime,video/x-m4v,video/webm,video/x-matroska,.mp4,.mov,.m4v,.webm,.mkv" required></label>++<div class="ovv-upload-pick"><div data-ov-upload-picker></div>++<ov-progress data-ov-upload-progress hidden></ov-progress></div>++<div class="ovv-upload-details"><label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" required></label><label class="ovv-field"><span>Description</span><textarea name="description" rows="4" maxlength="5000"></textarea></label>--<label class="ovv-field"><span>Who can see it</span><select name="visibility">${visibilityOptions('public')}</select></label>--<p class="ovv-fine">Public is listed on your channel and searchable; unlisted opens only by its link; private only for you. MP4, MOV, WebM or MKV, up to ${Math.round(cfg.maxBytes / GB)} GB a video and ${Math.round(cfg.quotaBytes / GB)} GB in all, ${cfg.perDay} uploads a day. Upload only what you made or may share: reported videos are hidden while staff look.</p>--<progress hidden value="0" max="1"></progress>++<fieldset class="ovv-visibility"><legend>Who can see it?</legend>++<label><input type="radio" name="visibility" value="public" checked><span><b>Public</b><small>Anyone can find and watch it.</small></span></label>++<label><input type="radio" name="visibility" value="unlisted"><span><b>Unlisted</b><small>Only people with the link.</small></span></label>++<label><input type="radio" name="visibility" value="private"><span><b>Only you</b><small>Your private video.</small></span></label></fieldset>++<p class="ovv-fine">Upload only what you may share. <button type="button" class="ovv-link-button" data-upload-rules>Rules and limits</button></p><p class="ovv-upload-status" role="status" aria-live="polite"></p>--<button class="ovv-btn ovv-btn--primary" type="submit">Upload</button>++<button class="ovv-btn ovv-btn--primary" type="submit" disabled>Save details</button>++</div><noscript><p class="ovv-fine">Uploading needs JavaScript: a video goes up in parts so a big file and a dropped connection both work.</p></noscript></form>++<dialog class="ovv-rules" data-upload-rules-sheet aria-label="Upload rules"><h2>Before you upload</h2><p>MP4, MOV, WebM or MKV, up to ${Math.round(cfg.maxBytes / GB)} GB each and ${Math.round(cfg.quotaBytes / GB)} GB total. You may upload ${cfg.perDay} videos a day. Upload only what you made or have permission to share. Reported videos are hidden while staff review them.</p><form method="dialog"><button class="ovv-btn ovv-btn--primary">Got it</button></form></dialog><p><a href="/record">Record from your camera or screen instead</a> · <a href="/me/videos">Your videos</a></p>`,}));@@@ -223,11 +286,21 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}return page(req, res, {title: 'Your videos',crumbs: [{ label: 'Home', href: '/' }, { label: 'Your videos' }],++ styles: ['ov-upload.css'],++ sharedScripts: ['ov-upload.js'],++ scripts: ['js/my-videos.js'],body: html`<h1>Your videos</h1>--<p><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a></p>--${fresh.length ? html`<table class="ovv-table"><thead><tr><th scope="col">Video</th><th scope="col">State</th><th scope="col">Length</th><th scope="col">Views</th><th scope="col">Uploaded</th></tr></thead><tbody>--${fresh.map((x) => html`<tr><td><a href="${x.watch_id ? `/watch?v=${x.watch_id}` : `/u/${x.id}`}">${x.title}</a></td><td>${statusText(x)}</td><td>${x.duration_seconds ? durationLabel(x.duration_seconds) : ''}</td><td>${Number(x.views) || 0}</td><td>${dateLabel(x.created_at) || ''}</td></tr>`)}--</tbody></table>` : html`<p class="muted">Nothing uploaded yet.</p>`}`,++<div class="ovv-my-head"><p class="ovv-lede">${fresh.length++ ? `${fresh.length} video${fresh.length === 1 ? '' : 's'}, newest first. A processing video finishes in the background, so you can close the page.`++ : 'Everything you upload to OpenVibe.Video appears here, with its state and who can see it.'}</p>++<a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a></div>++${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : html`<div class="ovv-my-empty"><i class="fa-solid fa-film" aria-hidden="true"></i><h2>No videos yet</h2><p>Choose a video and the upload starts while you fill in the title.</p><a class="ovv-btn ovv-btn--primary" href="/upload">Upload your first video</a></div>`}++<dialog class="ovv-rules" data-delete-sheet aria-label="Delete this video">++<h2>Delete this video?</h2>++<p data-delete-message>Its file is removed from OpenVibe.Media too. This cannot be undone.</p>++<form method="post" data-delete-form><input type="hidden" name="confirm" value="1">++<p class="ovv-sheet-actions"><button class="ovv-btn ovv-btn--danger" type="submit">Delete for good</button> <button class="ovv-btn" type="button" data-delete-cancel>Keep it</button></p></form>++</dialog>`,});});@@@ -28,7 +28,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = truconst u = new URL(base + path);const h = { Host: 'openvibe.video' };if (cookie) h.cookie = cookie;-- if (same && method !== 'GET') h['sec-fetch-site'] = 'same-origin';++ if (same) h['sec-fetch-site'] = 'same-origin';let data;if (form) { data = Buffer.from(new URLSearchParams(form).toString()); h['content-type'] = 'application/x-www-form-urlencoded'; }if (json) { data = Buffer.from(JSON.stringify(json)); h['content-type'] = 'application/json'; }@@@ -72,11 +72,30 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = truassert.strictEqual(t.media.vods.size, 0, 'no VOD was created');});++ await check('upload page uses the shared picker, progress and a rules sheet', async () => {++ const page = await vreq(t.base, '/upload', { cookie: kimCookie });++ assert.strictEqual(page.status, 200, page.text.slice(0, 180));++ assert.ok(page.text.includes('/shared/ov-upload.js') && page.text.includes('/shared/ov-upload.css'));++ assert.ok(page.text.includes('<ov-progress') && page.text.includes('data-ov-upload-picker'));++ assert.ok(page.text.includes('data-upload-rules-sheet'));++ assert.ok(!page.text.includes('type="file"') && !page.text.includes('<select name="visibility"'));++ });++await check('a MOV goes up in 8 MB parts, in order, to one Media VOD as MP4, and completes in the background', async () => {const start = await vreq(t.base, '/api/v1/videos', { method: 'POST', json: { title: 'Trip to the coast', description: 'Line one\nLine two', size: file.length, content_type: 'video/quicktime', name: 'trip.mov', visibility: 'public' }, cookie: kimCookie });assert.strictEqual(start.status, 201, start.text);const up = start.json().upload;id = up.id;++ const initial = await vreq(t.base, `/api/v1/videos/${id}/status`, { cookie: kimCookie });++ assert.strictEqual(initial.status, 200);++ assert.strictEqual(initial.json().upload.parts_received, 0);++ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/status`, { cookie: samCookie })).status, 404);++ const stopped = await vreq(t.base, '/me/videos', { cookie: kimCookie });++ assert.strictEqual(stopped.status, 200);++ assert.ok(stopped.text.includes('Upload stopped at 0%') && stopped.text.includes('Resume upload'), 'a stopped upload is resumable from Your videos');++ assert.ok(stopped.text.includes('data-ov-upload-progress') && stopped.text.includes('/shared/ov-upload.js'), 'the shared meter on its row');++ const details = await vreq(t.base, `/api/v1/videos/${id}`, { method: 'PATCH', cookie: kimCookie, json: { title: 'Trip to the coast', description: 'Line one\nLine two', visibility: 'public' } });++ assert.strictEqual(details.status, 200, details.text);assert.match(id, /^vup_[0-9A-Z]{26}$/);assert.deepStrictEqual([up.part_size, up.parts_expected], [8 * MB, 2]);const out = await vreq(t.base, `/api/v1/videos/${id}/parts/2`, { method: 'PUT', body: file.subarray(8 * MB), cookie: kimCookie });@@@ -84,6 +103,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = truassert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 100), cookie: kimCookie })).status, 422, 'a part is its full size');assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 8 * MB), cookie: samCookie })).status, 404, 'not sam\'s upload');assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 8 * MB), cookie: kimCookie })).status, 200);++ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/status`, { cookie: kimCookie })).json().upload.parts_received, 1, 'a reloaded page resumes at the next part');assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/2`, { method: 'PUT', body: file.subarray(8 * MB), cookie: kimCookie })).status, 200);const vod = [...t.media.vods.values()][0];assert.ok(vod.bytes.equals(file), 'Media has the file, byte for byte');@@@ -120,6 +140,9 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = truassert.ok((await vreq(t.base, '/sitemap.xml')).text.includes(`https://openvibe.video/watch?v=${wid}`), 'the sitemap');const mine = await vreq(t.base, '/me/videos', { cookie: kimCookie });assert.ok(mine.text.includes('Trip to the coast') && mine.text.includes('Public'));++ assert.ok(mine.text.includes('/shared/ov-upload.css') && mine.text.includes('js/my-videos.js'), 'Your videos uses the kit');++ assert.ok(mine.text.includes('data-delete-sheet') && mine.text.includes(`data-delete-video="${id}"`), 'Delete opens the kit sheet');++ assert.ok(!/window\.confirm|onclick="return confirm/.test(mine.text), 'never a browser confirm');});await check('a ready upload goes in a playlist (kind upload, by its number): listed, played, and next in line', async () => {
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/upload/upload-video-1280-dark.png differ
Binary files /dev/null and b/docs/screenshots/upload/upload-video-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/upload/upload-video-1280-light.png differ
Binary files /dev/null and b/docs/screenshots/upload/upload-video-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/upload/upload-video-390-dark.png differ
Binary files /dev/null and b/docs/screenshots/upload/upload-video-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/upload/upload-video-390-light.png differ
Binary files /dev/null and b/docs/screenshots/upload/upload-video-390-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/upload/your-videos-1280-dark.png differ
Binary files /dev/null and b/docs/screenshots/upload/your-videos-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/upload/your-videos-1280-light.png differ
Binary files /dev/null and b/docs/screenshots/upload/your-videos-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/upload/your-videos-390-dark.png differ
Binary files /dev/null and b/docs/screenshots/upload/your-videos-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/upload/your-videos-390-light.png differ
Binary files /dev/null and b/docs/screenshots/upload/your-videos-390-light.png differ
@@ -480,8 +480,27 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -480,8 +480,27 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-cutting p { margin: 0; max-width: 40ch; }
.ovv-cutting p { margin: 0; max-width: 40ch; }
.ovv-cutting i { color: var(--accent-light, var(--ovv-accent)); margin-right: 6px; }
.ovv-cutting i { color: var(--accent-light, var(--ovv-accent)); margin-right: 6px; }
/* Uploads (/upload, /u/:id, /me/videos) */
/* Uploads (/upload, /u/:id, /me/videos) */
-.ovv-upload { display: grid; gap: 12px; max-width: 640px; }
-.ovv-upload progress { width: 100%; height: 10px; }
+/* Upload (Opus's design pass, 11-09): the video on the left and its details on the right from 900 px; one column on a
+ phone. The meter shows only once a video is chosen. */
+.ovv-upload { display: grid; gap: 18px; max-width: 1040px; }
+.ovv-upload-pick, .ovv-upload-details { display: grid; gap: 12px; align-content: start; min-width: 0; }
+.ovv-upload ov-progress[hidden] { display: none; }
+@media (min-width: 900px) {
+ .ovv-upload { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; align-items: start; }
+ .ovv-upload-pick { position: sticky; top: calc(var(--ovnav-h, 56px) + 16px); }
+}
+.ovv-visibility { display: grid; gap: 8px; padding: 0; border: 0; }
+.ovv-visibility legend { margin-bottom: 8px; font-weight: 700; }
+.ovv-visibility label { display: block; cursor: pointer; }
+.ovv-visibility input { position: absolute; opacity: 0; }
+.ovv-visibility label span { display: grid; gap: 3px; min-height: 58px; padding: 11px 14px; border: 1px solid var(--ovv-line-strong); border-radius: 12px; background: var(--ovv-card); }
+.ovv-visibility label small { color: var(--ovv-dim); }
+.ovv-visibility input:checked + span { border: 2px solid var(--ovv-accent); padding: 10px 13px; background: rgba(111, 91, 230, .13); }
+.ovv-visibility input:focus-visible + span { outline: 3px solid var(--ovv-accent); outline-offset: 2px; }
+.ovv-link-button { border: 0; padding: 0; background: none; color: var(--ovv-accent); font: inherit; text-decoration: underline; cursor: pointer; }
+.ovv-rules { max-width: min(460px, calc(100vw - 28px)); padding: 24px; border: 1px solid var(--ovv-line-strong); border-radius: 18px; background: var(--ovv-card); color: inherit; box-shadow: 0 20px 70px rgba(0,0,0,.4); }
+.ovv-rules::backdrop { background: rgba(5, 8, 23, .7); }
+.ovv-rules p { line-height: 1.55; }
.ovv-upload-status { margin: 0; min-height: 1.4em; }
.ovv-upload-status { margin: 0; min-height: 1.4em; }
.ovv-processing { display: grid; place-items: center; min-height: 220px; padding: 20px; text-align: center; }
.ovv-processing { display: grid; place-items: center; min-height: 220px; padding: 20px; text-align: center; }
.ovv-description p { white-space: pre-line; margin: 0; }
.ovv-description p { white-space: pre-line; margin: 0; }
@@ -496,6 +515,42 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -496,6 +515,42 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-edited-from { margin: 0 0 10px; }
.ovv-edited-from { margin: 0 0 10px; }
.ovv-table { width: 100%; border-collapse: collapse; }
.ovv-table { width: 100%; border-collapse: collapse; }
.ovv-table th, .ovv-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--ovv-line); }
.ovv-table th, .ovv-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--ovv-line); }
+
+/* Your videos (/me/videos): the person's uploads as app rows: a picture, the title and its state, then one set of
+ actions. A stopped upload carries the shared ov-progress meter and a Resume that reopens /upload. */
+.ovv-my-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
+.ovv-my-head .ovv-lede { max-width: 54ch; }
+.ovv-mylist { list-style: none; margin: 18px 0 0; padding: 0; display: grid; gap: 10px; }
+.ovv-myrow { display: grid; grid-template-columns: 148px minmax(0, 1fr); gap: 14px; padding: 10px; border: 1px solid var(--ovv-line); border-radius: 16px; background: var(--ovv-card); }
+.ovv-mythumb { display: grid; place-items: center; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 11px; color: var(--accent-light, var(--ovv-accent)); background: color-mix(in srgb, var(--ovv-accent) 12%, transparent); font-size: 1.6rem; text-decoration: none; }
+.ovv-mythumb img { width: 100%; height: 100%; object-fit: cover; }
+.ovv-mybody { display: grid; align-content: center; gap: 5px; min-width: 0; }
+.ovv-mytitleline { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; min-width: 0; }
+.ovv-mytitle { overflow-wrap: anywhere; color: inherit !important; font-weight: 700; text-decoration: none; }
+.ovv-mytitle:hover, .ovv-mytitle:focus-visible { text-decoration: underline; }
+.ovv-mystate { flex: none; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: var(--ovv-dim); font-size: .72rem; font-weight: 700; }
+.ovv-myrow[data-state=ready] .ovv-mystate { background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); color: var(--success, #22c55e); }
+.ovv-myrow[data-state=uploading] .ovv-mystate, .ovv-myrow[data-state=processing] .ovv-mystate { background: color-mix(in srgb, var(--ovv-accent) 16%, transparent); color: var(--accent-light, var(--ovv-accent)); }
+.ovv-myrow[data-state=hidden] .ovv-mystate, .ovv-myrow[data-state=failed] .ovv-mystate { background: color-mix(in srgb, var(--danger, #ef4444) 12%, transparent); color: var(--danger-text, var(--danger, #ef4444)); }
+.ovv-mymeta { margin: 0; color: var(--ovv-dim); font-size: .82rem; }
+.ovv-myprogress { max-width: 440px; margin-top: 2px; }
+.ovv-myprocess { display: flex; align-items: center; gap: 7px; margin: 0; color: var(--ovv-dim); font-size: .82rem; }
+.ovv-pulse { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--ovv-accent); animation: ovv-pulse 1.4s ease-in-out infinite; }
+@keyframes ovv-pulse { 50% { opacity: .35; transform: scale(.8); } }
+.ovv-myactions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 3px; }
+.ovv-my-empty { display: grid; justify-items: center; gap: 8px; margin-top: 18px; padding: 38px 20px; border: 1px dashed var(--ovv-line-strong); border-radius: 16px; text-align: center; }
+.ovv-my-empty > i { color: var(--ovv-accent); font-size: 1.8rem; }
+.ovv-my-empty h2, .ovv-my-empty p { margin: 0; }
+.ovv-my-empty p { max-width: 46ch; color: var(--ovv-dim); }
+.ovv-sheet-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; }
+@media (max-width: 560px) {
+ .ovv-myrow { grid-template-columns: 104px minmax(0, 1fr); gap: 10px; }
+ .ovv-mytitleline { display: grid; gap: 3px; }
+ .ovv-mystate { justify-self: start; }
+}
+@media (prefers-reduced-motion: reduce) { .ovv-pulse { animation: none; } }
+html[data-ov-motion=reduced] .ovv-pulse { animation: none; }
+
/* Recording (/record) */
/* Recording (/record) */
.ovv-record { display: grid; gap: 12px; max-width: 760px; }
.ovv-record { display: grid; gap: 12px; max-width: 760px; }
.ovv-record-source { border: 0; padding: 0; margin: 0; display: flex; gap: 18px; flex-wrap: wrap; }
.ovv-record-source { border: 0; padding: 0; margin: 0; display: flex; gap: 18px; flex-wrap: wrap; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+/* Video's /me/videos: the shared progress meter on an upload that stopped part-way, and a kit sheet for Delete.
+ * Nothing here asks the browser for a confirm: the sheet is the site's own <dialog>, and its form posts as before. */
+(function () {
+ 'use strict';
+ var kit = window.OVUpload;
+ Array.prototype.forEach.call(document.querySelectorAll('[data-ov-upload-progress]'), function (el) {
+ if (!kit || !kit.progress) return;
+ var meter = kit.progress(el, { compact: true });
+ meter.update({
+ state: el.getAttribute('data-state') || 'waiting',
+ done: Number(el.getAttribute('data-done')) || 0,
+ total: Number(el.getAttribute('data-total')) || 0
+ });
+ meter.destroy(); // the page is a snapshot: the row is not sending here
+ });
+ var sheet = document.querySelector('[data-delete-sheet]');
+ if (!sheet) return;
+ var form = sheet.querySelector('[data-delete-form]');
+ var message = sheet.querySelector('[data-delete-message]');
+ document.addEventListener('click', function (event) {
+ var button = event.target.closest && event.target.closest('[data-delete-video]');
+ if (button) {
+ if (form) form.action = '/u/' + encodeURIComponent(button.getAttribute('data-delete-video')) + '/delete';
+ if (message) message.textContent = '“' + (button.getAttribute('data-delete-title') || 'This video') + '” and its file are removed from OpenVibe.Media. This cannot be undone.';
+ if (typeof sheet.showModal === 'function') sheet.showModal(); else sheet.setAttribute('open', '');
+ return;
+ }
+ if (event.target.closest && event.target.closest('[data-delete-cancel]')) {
+ if (typeof sheet.close === 'function') sheet.close(); else sheet.removeAttribute('open');
+ }
+ });
+})();
@@ -1,72 +1,179 @@
@@ -1,72 +1,179 @@
-/* OpenVibe.Video — /upload: sends the chosen video in 8 MB parts (POST /api/v1/videos, one PUT per part in order, then
- * complete), so a big file uploads in one go and the person watches the progress. A failed part is tried again three
- * times before the upload stops. Every request is same-origin: the session cookie and Sec-Fetch-Site are the browser's.
- * Nothing about the file but its size, type and name (to tell MOV from MP4) is sent before its bytes. */
+/* Video's same-origin VOD adapter for the shared picker and progress component. */
(function () {
(function () {
'use strict';
'use strict';
var form = document.querySelector('form[data-video-upload]');
var form = document.querySelector('form[data-video-upload]');
- if (!form || !window.fetch) return;
- var file = form.querySelector('input[type=file]');
- var bar = form.querySelector('progress');
+ if (!form || !window.OVUpload) return;
+ var kit = window.OVUpload;
var status = form.querySelector('[role=status]');
var status = form.querySelector('[role=status]');
var button = form.querySelector('button[type=submit]');
var button = form.querySelector('button[type=submit]');
-
- function say(t) { if (status) status.textContent = t; }
- function busy(on) { button.disabled = on; Array.prototype.forEach.call(form.elements, function (el) { if (el !== button) el.disabled = on; }); }
- function request(url, options) {
- return fetch(url, options).then(function (res) {
- if (res.ok) return res.json().catch(function () { return null; });
- return res.json().then(function (p) { throw new Error((p && p.detail) || ('The upload was refused (' + res.status + ').')); },
- function () { throw new Error('The upload was refused (' + res.status + ').'); });
+ var sheet = document.querySelector('[data-upload-rules-sheet]');
+ var state = { file: null, upload: null, sent: 0, sending: false, saved: false, complete: null, cancelled: false };
+ var meterEl = form.querySelector('[data-ov-upload-progress]');
+ var meter = kit.progress(meterEl, { onRetry: function () { if (state.file) send(); } });
+ var picker = kit.picker(form.querySelector('[data-ov-upload-picker]'), {
+ label: 'Choose a video', accept: 'video/mp4,video/quicktime,video/x-m4v,video/webm,video/x-matroska,.mp4,.mov,.m4v,.webm,.mkv',
+ onFile: choose, onRemove: remove
+ });
+ form.querySelector('[data-upload-rules]').addEventListener('click', function () { sheet.showModal(); });
+ function say(message) { status.textContent = message; }
+ function titleOf(file) { return file.name.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim().slice(0, 120) || 'Untitled video'; }
+ function request(path, options) {
+ return fetch(path, options).then(function (response) {
+ if (response.ok) return response.status === 204 ? null : response.json();
+ return response.json().catch(function () { return {}; }).then(function (body) {
+ var error = new Error(body.detail || 'The upload could not continue (' + response.status + ').');
+ error.status = response.status; throw error;
+ });
});
});
}
}
- function withRetry(fn, tries) {
- return fn().catch(function (err) {
- if (tries <= 1 || /refused \((4\d\d)\)|Send part|already finished/.test(err.message)) throw err;
- return new Promise(function (r) { setTimeout(r, 1500); }).then(function () { return withRetry(fn, tries - 1); });
+ function json(method, path, body) { return request(path, { method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); }
+ function db() {
+ return new Promise(function (resolve, reject) {
+ if (!window.indexedDB) { resolve(null); return; }
+ var open = indexedDB.open('ov-video-upload', 1);
+ open.onupgradeneeded = function () { open.result.createObjectStore('pending'); };
+ open.onsuccess = function () { resolve(open.result); };
+ open.onerror = function () { reject(open.error); };
});
});
}
}
-
- form.addEventListener('submit', function (event) {
- event.preventDefault();
- var f = file.files && file.files[0];
- if (!f) { say('Choose a video first.'); return; }
- var title = form.elements.title.value;
- var meta = { title: title, description: form.elements.description.value, visibility: form.elements.visibility.value, size: f.size, content_type: f.type || '', name: f.name };
- busy(true);
- bar.hidden = false; bar.max = f.size; bar.value = 0;
- say('Starting…');
- var sent = 0;
- var id = null;
- request('/api/v1/videos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(meta) }).then(function (out) {
- var up = out.upload;
- id = up.id;
- var chain = Promise.resolve();
- for (var n = 1; n <= up.parts_expected; n++) {
- chain = chain.then(function (part) {
- return function () {
- var start = (part - 1) * up.part_size;
- var slice = f.slice(start, Math.min(start + up.part_size, f.size));
- return withRetry(function () {
- return request('/api/v1/videos/' + up.id + '/parts/' + part, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream' }, body: slice });
- }, 3).then(function () {
- sent += slice.size;
- bar.value = sent;
- say('Uploading… ' + Math.round((sent / f.size) * 100) + '%');
- });
- };
- }(n));
+ function stored(value) {
+ return db().then(function (database) {
+ if (!database) return;
+ return new Promise(function (resolve, reject) {
+ var tx = database.transaction('pending', 'readwrite');
+ if (value) tx.objectStore('pending').put(value, 'video'); else tx.objectStore('pending').delete('video');
+ tx.oncomplete = resolve; tx.onerror = function () { reject(tx.error); };
+ });
+ }).catch(function () { /* Storage quota can refuse a large File; this tab still works. */ });
+ }
+ function load() {
+ return db().then(function (database) {
+ if (!database) return null;
+ return new Promise(function (resolve, reject) {
+ var tx = database.transaction('pending', 'readonly');
+ var get = tx.objectStore('pending').get('video');
+ get.onsuccess = function () { resolve(get.result); };
+ get.onerror = function () { reject(get.error); };
+ });
+ }).catch(function () { return null; });
+ }
+ function snapshot() { return stored(state.file && state.upload ? { file: state.file, upload: state.upload, title: form.elements.title.value, description: form.elements.description.value, visibility: form.elements.visibility.value, saved: state.saved } : null); }
+ function choose(file) {
+ if (state.file) { say('Remove this video before choosing another.'); return; }
+ state.file = file; state.cancelled = false; state.saved = false; state.complete = null;
+ meterEl.hidden = false;
+ form.elements.title.value = titleOf(file);
+ button.disabled = false;
+ meter.update({ state: 'waiting', done: 0, total: file.size, message: '' });
+ start();
+ }
+ function remove(file) {
+ if (file !== state.file) return;
+ state.cancelled = true;
+ var id = state.upload && state.upload.id;
+ state.file = null; state.upload = null; state.sent = 0; state.sending = false; state.complete = null;
+ meterEl.hidden = true;
+ button.disabled = true; meter.update({ state: 'waiting', done: 0, total: 0, message: '' }); say('Video removed.');
+ stored(null);
+ if (id) request('/api/v1/videos/' + encodeURIComponent(id), { method: 'DELETE' }).catch(function () {});
+ }
+ function start() {
+ var file = state.file;
+ if (!file) return;
+ say('Starting your upload…');
+ json('POST', '/api/v1/videos', { title: form.elements.title.value, description: '', visibility: 'private', size: file.size, content_type: file.type || '', name: file.name }).then(function (out) {
+ if (state.cancelled || file !== state.file) { request('/api/v1/videos/' + out.upload.id, { method: 'DELETE' }).catch(function () {}); return; }
+ state.upload = out.upload; snapshot(); send();
+ }).catch(fail);
+ }
+ function putPart(id, number, blob, onProgress) {
+ return new Promise(function (resolve, reject) {
+ var xhr = new XMLHttpRequest();
+ xhr.open('PUT', '/api/v1/videos/' + encodeURIComponent(id) + '/parts/' + number);
+ xhr.setRequestHeader('Content-Type', 'application/octet-stream');
+ xhr.upload.onprogress = function (event) { if (event.lengthComputable) onProgress(event.loaded); };
+ xhr.onload = function () {
+ if (xhr.status >= 200 && xhr.status < 300) { resolve(); return; }
+ var detail = '';
+ try { detail = JSON.parse(xhr.responseText).detail || ''; } catch (_) { /* network response */ }
+ var err = new Error(detail || 'Part ' + number + ' was refused (' + xhr.status + ').');
+ err.status = xhr.status; reject(err);
+ };
+ xhr.onerror = function () { reject(new Error('The connection dropped. Your video will resume.')); };
+ xhr.send(blob);
+ });
+ }
+ function waitOnline() {
+ if (navigator.onLine !== false) return Promise.resolve();
+ meter.update({ state: 'paused' }); say('Paused: no connection');
+ return new Promise(function (resolve) { window.addEventListener('online', resolve, { once: true }); });
+ }
+ function retryPart(id, number, blob, base, attempt) {
+ return waitOnline().then(function () {
+ meter.update({ state: 'sending' });
+ return putPart(id, number, blob, function (loaded) { meter.update({ done: base + loaded, total: state.file.size, state: 'sending' }); });
+ }).catch(function (error) {
+ if (state.cancelled || error.status && error.status < 500 || attempt >= 5) throw error;
+ return new Promise(function (resolve) { setTimeout(resolve, Math.min(12000, 600 * Math.pow(2, attempt))); }).then(function () {
+ return retryPart(id, number, blob, base, attempt + 1);
+ });
+ });
+ }
+ function send() {
+ if (state.sending || !state.file || !state.upload) return;
+ state.sending = true;
+ var file = state.file, upload = state.upload;
+ var id = encodeURIComponent(upload.id);
+ request('/api/v1/videos/' + id + '/status').then(function (out) {
+ if (out.upload.size !== file.size) throw new Error('Choose the same file to resume this upload.');
+ if (out.upload.status !== 'uploading') { state.complete = { video: { url: '/u/' + id } }; return; }
+ var n = out.upload.parts_received + 1;
+ state.sent = out.upload.parts_received * upload.part_size;
+ meter.update({ state: 'sending', done: state.sent, total: file.size });
+ function next() {
+ if (state.cancelled) return;
+ if (n > upload.parts_expected) return;
+ var base = (n - 1) * upload.part_size;
+ var blob = file.slice(base, Math.min(base + upload.part_size, file.size));
+ return retryPart(id, n, blob, base, 0).then(function () {
+ state.sent = base + blob.size; n++;
+ meter.update({ state: 'sending', done: state.sent, total: file.size });
+ snapshot(); return next();
+ });
}
}
- return chain;
+ return next().then(function () {
+ if (state.cancelled) return;
+ meter.update({ state: 'processing', done: file.size, total: file.size });
+ return request('/api/v1/videos/' + id + '/complete', { method: 'POST' }).then(function (done) { state.complete = done; });
+ });
}).then(function () {
}).then(function () {
- say('Finishing…');
- return request('/api/v1/videos/' + id + '/complete', { method: 'POST' });
- }).then(function (done) {
- say('Uploaded. OpenVibe.Media is processing it; opening its page…');
- window.location.assign(done.video.url);
- }).catch(function (err) {
- busy(false);
- say((err && err.message) || 'The upload did not go through.');
- });
+ state.sending = false;
+ if (state.cancelled || !state.complete) return;
+ if (state.saved) stored(null); else snapshot();
+ meter.update({ state: 'done', done: file.size, total: file.size, message: 'Your video is processing.' });
+ say(state.saved ? 'Opening your video…' : 'Upload complete. Save details to continue.');
+ if (state.saved) window.location.assign(state.complete.video.url);
+ }).catch(function (err) { state.sending = false; fail(err); });
+ }
+ function fail(err) { meter.update({ state: 'failed', message: err && err.message || 'The upload did not go through.' }); say('Upload paused. You can retry.'); }
+ form.addEventListener('submit', function (event) {
+ event.preventDefault();
+ if (!state.upload || !state.file) { say('Choose a video first.'); return; }
+ button.disabled = true;
+ json('PATCH', '/api/v1/videos/' + encodeURIComponent(state.upload.id), { title: form.elements.title.value, description: form.elements.description.value, visibility: form.elements.visibility.value }).then(function () {
+ state.saved = true; snapshot(); button.disabled = false; button.textContent = 'Details saved';
+ if (state.complete) { stored(null); window.location.assign(state.complete.video.url); }
+ else say('Details saved. Your video is still uploading.');
+ }).catch(function (err) { button.disabled = false; say(err.message); });
+ });
+ window.addEventListener('beforeunload', function (event) { if (state.sending) { event.preventDefault(); event.returnValue = ''; } });
+ load().then(function (pending) {
+ if (state.file || !pending || !pending.file || !pending.upload) return;
+ state.file = pending.file; state.upload = pending.upload; state.saved = !!pending.saved;
+ form.elements.title.value = pending.title; form.elements.description.value = pending.description;
+ form.elements.visibility.value = pending.visibility;
+ button.disabled = false;
+ picker.add([pending.file]);
+ say('Resuming your video…'); send();
});
});
})();
})();
@@ -71,6 +71,7 @@ ${appIcon.headTags({ site: 'media-hub' })}
@@ -71,6 +71,7 @@ ${appIcon.headTags({ site: 'media-hub' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/video.css')}">
<link rel="stylesheet" href="${asset('css/video.css')}">
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`).join('\n')}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`).join('\n')}
+${(o.sharedScripts || []).map((name) => `<script src="${ovServe.url(name)}" defer></script>`).join('\n')}
${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">\n`).join('')}${o.player ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n` : ''}${o.path === '/record' ? `<script src="${CAM_KIT_URL}" integrity="${CAM_KIT_SRI}" crossorigin="anonymous" defer></script>\n` : ''}${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;
${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">\n`).join('')}${o.player ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n` : ''}${o.path === '/record' ? `<script src="${CAM_KIT_URL}" integrity="${CAM_KIT_SRI}" crossorigin="anonymous" defer></script>\n` : ''}${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;
const account = signedIn
const account = signedIn
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${encodeURIComponent(pathNow)}">Sign out</a>`
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${encodeURIComponent(pathNow)}">Sign out</a>`
@@ -63,6 +63,32 @@ function createUploadApi({ ctx, config }) {
@@ -63,6 +63,32 @@ function createUploadApi({ ctx, config }) {
} catch (err) { return problem(res, err); }
} catch (err) { return problem(res, err); }
});
});
+ r.get('/:id/status', async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ const row = await own(req, res);
+ if (!row) return undefined;
+ return res.json({ upload: { id: row.id, status: row.status, part_size: row.part_size, parts_expected: row.parts_expected, parts_received: row.parts_received, size: Number(row.size_bytes), title: row.title, description: row.description, visibility: row.visibility } });
+ });
+
+ r.patch('/:id', express.json({ limit: '16kb' }), async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ const row = await own(req, res);
+ if (!row) return undefined;
+ try {
+ const b = req.body || {};
+ const updated = await up.update(row, { title: b.title, description: b.description, visibility: b.visibility });
+ return res.json({ upload: { id: updated.id, title: updated.title, visibility: updated.visibility } });
+ } catch (err) { return problem(res, err); }
+ });
+
+ r.delete('/:id', async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ const row = await own(req, res);
+ if (!row) return undefined;
+ await up.remove(row);
+ return res.status(204).end();
+ });
+
r.put('/:id/parts/:n', express.raw({ type: () => true, limit: `${Math.ceil(up.PART_SIZE / 1024) + 64}kb` }), async (req, res) => {
r.put('/:id/parts/:n', express.raw({ type: () => true, limit: `${Math.ceil(up.PART_SIZE / 1024) + 64}kb` }), async (req, res) => {
if (!guard(req, res)) return undefined;
if (!guard(req, res)) return undefined;
const row = await own(req, res);
const row = await own(req, res);
@@ -141,6 +167,35 @@ function mountUploadPages(r, h) {
@@ -141,6 +167,35 @@ function mountUploadPages(r, h) {
: row.status === 'uploading' ? `Upload stopped at ${Math.round((row.parts_received / row.parts_expected) * 100)}%`
: row.status === 'uploading' ? `Upload stopped at ${Math.round((row.parts_received / row.parts_expected) * 100)}%`
: row.status === 'processing' ? 'Processing' : row.status === 'failed' ? 'Failed' : visibilityLabel(row.visibility));
: row.status === 'processing' ? 'Processing' : row.status === 'failed' ? 'Failed' : visibilityLabel(row.visibility));
+ // Your videos: one card per upload with the same words the watch page uses, its picture when Media has made one,
+ // and the shared progress meter when the upload stopped part-way (Resume opens /upload, which reads IndexedDB).
+ const uploadState = (row) => (row.state === 'hidden' ? 'hidden'
+ : row.status === 'uploading' ? 'uploading' : row.status === 'processing' ? 'processing' : row.status === 'failed' ? 'failed' : 'ready');
+ function myVideoRow(row) {
+ const state = uploadState(row);
+ const watch = row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`;
+ const views = Number(row.views) || 0;
+ const pct = row.parts_expected ? Math.round((Number(row.parts_received) / Number(row.parts_expected)) * 100) : 0;
+ const meta = [visibilityLabel(row.visibility), row.duration_seconds ? durationLabel(row.duration_seconds) : null,
+ `${views} view${views === 1 ? '' : 's'}`, dateLabel(row.created_at)].filter(Boolean).join(' · ');
+ const done = Number(row.parts_received) * Number(row.part_size);
+ const total = Number(row.size_bytes);
+ return html`<li class="ovv-myrow" data-state="${state}">
+<a class="ovv-mythumb" href="${watch}" tabindex="-1" aria-hidden="true">${row.thumbnail_url ? html`<img src="${row.thumbnail_url}" alt="" loading="lazy">` : html`<i class="fa-solid fa-film"></i>`}</a>
+<div class="ovv-mybody">
+<div class="ovv-mytitleline"><a class="ovv-mytitle" href="${watch}">${row.title}</a><span class="ovv-mystate">${statusText(row)}</span></div>
+<p class="ovv-mymeta">${meta}</p>
+${state === 'uploading' ? html`<div class="ovv-myprogress" data-ov-upload-progress data-state="waiting" data-done="${done}" data-total="${total}">Upload stopped at ${pct}%</div>` : ''}
+${state === 'processing' ? html`<p class="ovv-myprocess"><span class="ovv-pulse" aria-hidden="true"></span> Making it playable. You can close this page.</p>` : ''}
+<div class="ovv-myactions">
+${state === 'uploading' ? html`<a class="ovv-btn ovv-btn--small ovv-btn--primary" href="/upload">Resume upload</a>` : ''}
+${state === 'ready' ? html`<a class="ovv-btn ovv-btn--small" href="${watch}">Watch</a> <a class="ovv-btn ovv-btn--small" href="${watch}#h-owner">Edit</a>` : ''}
+${state === 'processing' || state === 'hidden' || state === 'failed' ? html`<a class="ovv-btn ovv-btn--small" href="${watch}">Open</a>` : ''}
+<button class="ovv-btn ovv-btn--small ovv-btn--danger" type="button" data-delete-video="${row.id}" data-delete-title="${row.title}">Delete</button>
+</div>
+</div></li>`;
+ }
+
const CUT_STATUS = { queued: 'Waiting to start', measuring: 'Finding the pauses', rendering: 'Making the copy' };
const CUT_STATUS = { queued: 'Waiting to start', measuring: 'Finding the pauses', rendering: 'Making the copy' };
const CUT_LABEL = { pauses: 'Pauses cut', tight: 'Tight cut', highlights: 'Highlights reel' };
const CUT_LABEL = { pauses: 'Pauses cut', tight: 'Tight cut', highlights: 'Highlights reel' };
function cutLine(c) {
function cutLine(c) {
@@ -172,19 +227,27 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
@@ -172,19 +227,27 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
r.get('/upload', mustSignIn('/upload', 'Uploads belong to your OpenVibe account: sign in to upload a video.'), async (req, res) => page(req, res, {
r.get('/upload', mustSignIn('/upload', 'Uploads belong to your OpenVibe account: sign in to upload a video.'), async (req, res) => page(req, res, {
title: 'Upload a video',
title: 'Upload a video',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Upload' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Upload' }],
+ styles: ['ov-upload.css'],
+ sharedScripts: ['ov-upload.js'],
scripts: ['js/video-upload.js'],
scripts: ['js/video-upload.js'],
body: html`<h1>Upload a video</h1>
body: html`<h1>Upload a video</h1>
<form class="ovv-upload ovv-panel" data-video-upload method="post" action="/upload">
<form class="ovv-upload ovv-panel" data-video-upload method="post" action="/upload">
-<label class="ovv-field"><span>Video file</span><input name="file" type="file" accept="video/mp4,video/quicktime,video/x-m4v,video/webm,video/x-matroska,.mp4,.mov,.m4v,.webm,.mkv" required></label>
+<div class="ovv-upload-pick"><div data-ov-upload-picker></div>
+<ov-progress data-ov-upload-progress hidden></ov-progress></div>
+<div class="ovv-upload-details">
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" required></label>
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" required></label>
<label class="ovv-field"><span>Description</span><textarea name="description" rows="4" maxlength="5000"></textarea></label>
<label class="ovv-field"><span>Description</span><textarea name="description" rows="4" maxlength="5000"></textarea></label>
-<label class="ovv-field"><span>Who can see it</span><select name="visibility">${visibilityOptions('public')}</select></label>
-<p class="ovv-fine">Public is listed on your channel and searchable; unlisted opens only by its link; private only for you. MP4, MOV, WebM or MKV, up to ${Math.round(cfg.maxBytes / GB)} GB a video and ${Math.round(cfg.quotaBytes / GB)} GB in all, ${cfg.perDay} uploads a day. Upload only what you made or may share: reported videos are hidden while staff look.</p>
-<progress hidden value="0" max="1"></progress>
+<fieldset class="ovv-visibility"><legend>Who can see it?</legend>
+<label><input type="radio" name="visibility" value="public" checked><span><b>Public</b><small>Anyone can find and watch it.</small></span></label>
+<label><input type="radio" name="visibility" value="unlisted"><span><b>Unlisted</b><small>Only people with the link.</small></span></label>
+<label><input type="radio" name="visibility" value="private"><span><b>Only you</b><small>Your private video.</small></span></label></fieldset>
+<p class="ovv-fine">Upload only what you may share. <button type="button" class="ovv-link-button" data-upload-rules>Rules and limits</button></p>
<p class="ovv-upload-status" role="status" aria-live="polite"></p>
<p class="ovv-upload-status" role="status" aria-live="polite"></p>
-<button class="ovv-btn ovv-btn--primary" type="submit">Upload</button>
+<button class="ovv-btn ovv-btn--primary" type="submit" disabled>Save details</button>
+</div>
<noscript><p class="ovv-fine">Uploading needs JavaScript: a video goes up in parts so a big file and a dropped connection both work.</p></noscript>
<noscript><p class="ovv-fine">Uploading needs JavaScript: a video goes up in parts so a big file and a dropped connection both work.</p></noscript>
</form>
</form>
+<dialog class="ovv-rules" data-upload-rules-sheet aria-label="Upload rules"><h2>Before you upload</h2><p>MP4, MOV, WebM or MKV, up to ${Math.round(cfg.maxBytes / GB)} GB each and ${Math.round(cfg.quotaBytes / GB)} GB total. You may upload ${cfg.perDay} videos a day. Upload only what you made or have permission to share. Reported videos are hidden while staff review them.</p><form method="dialog"><button class="ovv-btn ovv-btn--primary">Got it</button></form></dialog>
<p><a href="/record">Record from your camera or screen instead</a> · <a href="/me/videos">Your videos</a></p>`,
<p><a href="/record">Record from your camera or screen instead</a> · <a href="/me/videos">Your videos</a></p>`,
}));
}));
@@ -223,11 +286,21 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
@@ -223,11 +286,21 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
return page(req, res, {
return page(req, res, {
title: 'Your videos',
title: 'Your videos',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your videos' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your videos' }],
+ styles: ['ov-upload.css'],
+ sharedScripts: ['ov-upload.js'],
+ scripts: ['js/my-videos.js'],
body: html`<h1>Your videos</h1>
body: html`<h1>Your videos</h1>
-<p><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a></p>
-${fresh.length ? html`<table class="ovv-table"><thead><tr><th scope="col">Video</th><th scope="col">State</th><th scope="col">Length</th><th scope="col">Views</th><th scope="col">Uploaded</th></tr></thead><tbody>
-${fresh.map((x) => html`<tr><td><a href="${x.watch_id ? `/watch?v=${x.watch_id}` : `/u/${x.id}`}">${x.title}</a></td><td>${statusText(x)}</td><td>${x.duration_seconds ? durationLabel(x.duration_seconds) : ''}</td><td>${Number(x.views) || 0}</td><td>${dateLabel(x.created_at) || ''}</td></tr>`)}
-</tbody></table>` : html`<p class="muted">Nothing uploaded yet.</p>`}`,
+<div class="ovv-my-head"><p class="ovv-lede">${fresh.length
+ ? `${fresh.length} video${fresh.length === 1 ? '' : 's'}, newest first. A processing video finishes in the background, so you can close the page.`
+ : 'Everything you upload to OpenVibe.Video appears here, with its state and who can see it.'}</p>
+<a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a></div>
+${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : html`<div class="ovv-my-empty"><i class="fa-solid fa-film" aria-hidden="true"></i><h2>No videos yet</h2><p>Choose a video and the upload starts while you fill in the title.</p><a class="ovv-btn ovv-btn--primary" href="/upload">Upload your first video</a></div>`}
+<dialog class="ovv-rules" data-delete-sheet aria-label="Delete this video">
+<h2>Delete this video?</h2>
+<p data-delete-message>Its file is removed from OpenVibe.Media too. This cannot be undone.</p>
+<form method="post" data-delete-form><input type="hidden" name="confirm" value="1">
+<p class="ovv-sheet-actions"><button class="ovv-btn ovv-btn--danger" type="submit">Delete for good</button> <button class="ovv-btn" type="button" data-delete-cancel>Keep it</button></p></form>
+</dialog>`,
});
});
});
});
@@ -28,7 +28,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
@@ -28,7 +28,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
const u = new URL(base + path);
const u = new URL(base + path);
const h = { Host: 'openvibe.video' };
const h = { Host: 'openvibe.video' };
if (cookie) h.cookie = cookie;
if (cookie) h.cookie = cookie;
- if (same && method !== 'GET') h['sec-fetch-site'] = 'same-origin';
+ if (same) h['sec-fetch-site'] = 'same-origin';
let data;
let data;
if (form) { data = Buffer.from(new URLSearchParams(form).toString()); h['content-type'] = 'application/x-www-form-urlencoded'; }
if (form) { data = Buffer.from(new URLSearchParams(form).toString()); h['content-type'] = 'application/x-www-form-urlencoded'; }
if (json) { data = Buffer.from(JSON.stringify(json)); h['content-type'] = 'application/json'; }
if (json) { data = Buffer.from(JSON.stringify(json)); h['content-type'] = 'application/json'; }
@@ -72,11 +72,30 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
@@ -72,11 +72,30 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
assert.strictEqual(t.media.vods.size, 0, 'no VOD was created');
assert.strictEqual(t.media.vods.size, 0, 'no VOD was created');
});
});
+ await check('upload page uses the shared picker, progress and a rules sheet', async () => {
+ const page = await vreq(t.base, '/upload', { cookie: kimCookie });
+ assert.strictEqual(page.status, 200, page.text.slice(0, 180));
+ assert.ok(page.text.includes('/shared/ov-upload.js') && page.text.includes('/shared/ov-upload.css'));
+ assert.ok(page.text.includes('<ov-progress') && page.text.includes('data-ov-upload-picker'));
+ assert.ok(page.text.includes('data-upload-rules-sheet'));
+ assert.ok(!page.text.includes('type="file"') && !page.text.includes('<select name="visibility"'));
+ });
+
await check('a MOV goes up in 8 MB parts, in order, to one Media VOD as MP4, and completes in the background', async () => {
await check('a MOV goes up in 8 MB parts, in order, to one Media VOD as MP4, and completes in the background', async () => {
const start = await vreq(t.base, '/api/v1/videos', { method: 'POST', json: { title: 'Trip to the coast', description: 'Line one\nLine two', size: file.length, content_type: 'video/quicktime', name: 'trip.mov', visibility: 'public' }, cookie: kimCookie });
const start = await vreq(t.base, '/api/v1/videos', { method: 'POST', json: { title: 'Trip to the coast', description: 'Line one\nLine two', size: file.length, content_type: 'video/quicktime', name: 'trip.mov', visibility: 'public' }, cookie: kimCookie });
assert.strictEqual(start.status, 201, start.text);
assert.strictEqual(start.status, 201, start.text);
const up = start.json().upload;
const up = start.json().upload;
id = up.id;
id = up.id;
+ const initial = await vreq(t.base, `/api/v1/videos/${id}/status`, { cookie: kimCookie });
+ assert.strictEqual(initial.status, 200);
+ assert.strictEqual(initial.json().upload.parts_received, 0);
+ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/status`, { cookie: samCookie })).status, 404);
+ const stopped = await vreq(t.base, '/me/videos', { cookie: kimCookie });
+ assert.strictEqual(stopped.status, 200);
+ assert.ok(stopped.text.includes('Upload stopped at 0%') && stopped.text.includes('Resume upload'), 'a stopped upload is resumable from Your videos');
+ assert.ok(stopped.text.includes('data-ov-upload-progress') && stopped.text.includes('/shared/ov-upload.js'), 'the shared meter on its row');
+ const details = await vreq(t.base, `/api/v1/videos/${id}`, { method: 'PATCH', cookie: kimCookie, json: { title: 'Trip to the coast', description: 'Line one\nLine two', visibility: 'public' } });
+ assert.strictEqual(details.status, 200, details.text);
assert.match(id, /^vup_[0-9A-Z]{26}$/);
assert.match(id, /^vup_[0-9A-Z]{26}$/);
assert.deepStrictEqual([up.part_size, up.parts_expected], [8 * MB, 2]);
assert.deepStrictEqual([up.part_size, up.parts_expected], [8 * MB, 2]);
const out = await vreq(t.base, `/api/v1/videos/${id}/parts/2`, { method: 'PUT', body: file.subarray(8 * MB), cookie: kimCookie });
const out = await vreq(t.base, `/api/v1/videos/${id}/parts/2`, { method: 'PUT', body: file.subarray(8 * MB), cookie: kimCookie });
@@ -84,6 +103,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
@@ -84,6 +103,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 100), cookie: kimCookie })).status, 422, 'a part is its full size');
assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 100), cookie: kimCookie })).status, 422, 'a part is its full size');
assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 8 * MB), cookie: samCookie })).status, 404, 'not sam\'s upload');
assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 8 * MB), cookie: samCookie })).status, 404, 'not sam\'s upload');
assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 8 * MB), cookie: kimCookie })).status, 200);
assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/1`, { method: 'PUT', body: file.subarray(0, 8 * MB), cookie: kimCookie })).status, 200);
+ assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/status`, { cookie: kimCookie })).json().upload.parts_received, 1, 'a reloaded page resumes at the next part');
assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/2`, { method: 'PUT', body: file.subarray(8 * MB), cookie: kimCookie })).status, 200);
assert.strictEqual((await vreq(t.base, `/api/v1/videos/${id}/parts/2`, { method: 'PUT', body: file.subarray(8 * MB), cookie: kimCookie })).status, 200);
const vod = [...t.media.vods.values()][0];
const vod = [...t.media.vods.values()][0];
assert.ok(vod.bytes.equals(file), 'Media has the file, byte for byte');
assert.ok(vod.bytes.equals(file), 'Media has the file, byte for byte');
@@ -120,6 +140,9 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
@@ -120,6 +140,9 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
assert.ok((await vreq(t.base, '/sitemap.xml')).text.includes(`https://openvibe.video/watch?v=${wid}`), 'the sitemap');
assert.ok((await vreq(t.base, '/sitemap.xml')).text.includes(`https://openvibe.video/watch?v=${wid}`), 'the sitemap');
const mine = await vreq(t.base, '/me/videos', { cookie: kimCookie });
const mine = await vreq(t.base, '/me/videos', { cookie: kimCookie });
assert.ok(mine.text.includes('Trip to the coast') && mine.text.includes('Public'));
assert.ok(mine.text.includes('Trip to the coast') && mine.text.includes('Public'));
+ assert.ok(mine.text.includes('/shared/ov-upload.css') && mine.text.includes('js/my-videos.js'), 'Your videos uses the kit');
+ assert.ok(mine.text.includes('data-delete-sheet') && mine.text.includes(`data-delete-video="${id}"`), 'Delete opens the kit sheet');
+ assert.ok(!/window\.confirm|onclick="return confirm/.test(mine.text), 'never a browser confirm');
});
});
await check('a ready upload goes in a playlist (kind upload, by its number): listed, played, and next in line', async () => {
await check('a ready upload goes in a playlist (kind upload, by its number): listed, played, and next in line', async () => {