openvibers/openvibe.mediahub Commits

Video upload: the shared picker and progress, and Your videos (#358)

OP OpenVibers committed 4 days ago · aa00064fae · 14 files, +362 −71

* Rebuild Video upload around shared picker and progress

* Attach Video upload phone and desktop screenshots

* Your videos: app rows, resumable stopped uploads and a kit delete sheet

* Recapture Video's upload and Your videos shots on the released kit

* Upload design: the video on the left and its details on the right; the meter appears once a video is chosen

Changes

ddiff --git a/docs/screenshots/upload/upload-video-1280-dark.png b/docs/screenshots/upload/upload-video-1280-dark.png
nnew file mode 100644
iindex 0000000..41def00
BBinary files /dev/null and b/docs/screenshots/upload/upload-video-1280-dark.png differ
ddiff --git a/docs/screenshots/upload/upload-video-1280-light.png b/docs/screenshots/upload/upload-video-1280-light.png
nnew file mode 100644
iindex 0000000..3e10446
BBinary files /dev/null and b/docs/screenshots/upload/upload-video-1280-light.png differ
ddiff --git a/docs/screenshots/upload/upload-video-390-dark.png b/docs/screenshots/upload/upload-video-390-dark.png
nnew file mode 100644
iindex 0000000..1146e8e
BBinary files /dev/null and b/docs/screenshots/upload/upload-video-390-dark.png differ
ddiff --git a/docs/screenshots/upload/upload-video-390-light.png b/docs/screenshots/upload/upload-video-390-light.png
nnew file mode 100644
iindex 0000000..9c22a25
BBinary files /dev/null and b/docs/screenshots/upload/upload-video-390-light.png differ
ddiff --git a/docs/screenshots/upload/your-videos-1280-dark.png b/docs/screenshots/upload/your-videos-1280-dark.png
nnew file mode 100644
iindex 0000000..9c5b56c
BBinary files /dev/null and b/docs/screenshots/upload/your-videos-1280-dark.png differ
ddiff --git a/docs/screenshots/upload/your-videos-1280-light.png b/docs/screenshots/upload/your-videos-1280-light.png
nnew file mode 100644
iindex 0000000..205b5ff
BBinary files /dev/null and b/docs/screenshots/upload/your-videos-1280-light.png differ
ddiff --git a/docs/screenshots/upload/your-videos-390-dark.png b/docs/screenshots/upload/your-videos-390-dark.png
nnew file mode 100644
iindex 0000000..3039ce0
BBinary files /dev/null and b/docs/screenshots/upload/your-videos-390-dark.png differ
ddiff --git a/docs/screenshots/upload/your-videos-390-light.png b/docs/screenshots/upload/your-videos-390-light.png
nnew file mode 100644
iindex 0000000..1d4d96a
BBinary files /dev/null and b/docs/screenshots/upload/your-videos-390-light.png differ
ddiff --git a/public/css/video.css b/public/css/video.css
iindex 9577de1..6ec1afd 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -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; }
ddiff --git a/public/js/my-videos.js b/public/js/my-videos.js
nnew file mode 100644
iindex 0000000..b025824
---- /dev/null
++++ b/public/js/my-videos.js
@@@ -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');
++ }
++ });
++})();
ddiff --git a/public/js/video-upload.js b/public/js/video-upload.js
iindex 14cb188..84ff94f 100644
---- a/public/js/video-upload.js
++++ b/public/js/video-upload.js
@@@ -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();
});
})();
ddiff --git a/server/video/layout.js b/server/video/layout.js
iindex 2bb9d6d..bbef528 100644
---- a/server/video/layout.js
++++ b/server/video/layout.js
@@@ -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>`
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex e374176..394f9ba 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -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>`,
});
});
ddiff --git a/test/video-uploads.test.js b/test/video-uploads.test.js
iindex f09e94c..500d1cf 100644
---- a/test/video-uploads.test.js
++++ b/test/video-uploads.test.js
@@@ -28,7 +28,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
const 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 = tru
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 () => {
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 = 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, 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 = tru
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 });
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 () => {