openvibers/openvibe.mediahub Commits

Video app feed and Cam-powered Record page (#339)

OP OpenVibers committed 4 days ago · 1283996e59 · 11 files, +138 −200

* Make Video a feed and record through Cam kit

* Point Following filter to Live browse

* Link Following to Live Rundown filter

* Record: pin the Cam kit with real icons (new SRI)

Changes

ddiff --git a/README.md b/README.md
iindex 39a6942..fc6df3c 100644
---- a/README.md
++++ b/README.md
@@@ -234,11 +234,10 @@ pages read Live's public JSON API server-side, signed out (Live already applies
hides it at once, and three reports hide it until staff decide; the owner still sees it, labelled.
- **Account deletion:** erases the rows and deletes the VODs in Media.
- **Recording** (`/record`, `public/js/video-record.js`):
-- - Record from the camera or the screen (with the microphone) in the browser. It is never live.
-- - MediaRecorder hands over a piece every 4 seconds, and each piece goes up in order
-- (`POST /api/v1/videos/recordings`, then `PUT /api/v1/videos/:id/chunks/:n`, up to 9 MB each) to the same kind of
-- Media VOD.
-- - Stop completes it like an upload. Size and quota are checked as it grows, and a recording is private by default.
++ - Loads the SRI-pinned Cam kit from `openvibe.cam` on the Video page. The person records a camera or screen clip,
++ reviews, trims and chooses Use this. Nothing uploads until that choice.
++ - The returned `File` goes through the ordinary multipart upload form, with progress and retries. A recording is
++ private by default. The older `/api/v1/videos/recordings` chunk API remains available to its existing callers.
- **AI edit** (`server/video/ai-edit.js`, migration `0009_video_ai.sql`):
- Every few minutes, OpenVibe.AI's `media.analyze` runs on each finished upload or recording. It reads the video
through Media's signed URL (private ones too), runs locally (whisper.cpp, the local model) and never uses a paid
ddiff --git a/public/css/video.css b/public/css/video.css
iindex 9d28b97..9e21b83 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -659,6 +659,34 @@ html.ov-motion .ovv-stat.is-live .ovv-dot { animation: ovv-pulse 1.6s ease-out i
.ovv-card-body > .ovk-labels { margin: .3rem 0 0; }
.ovv-head > .ovk-labels { margin: .35rem 0 .1rem; }
++/* Video's app home: one feed, with touch-sized filters and the same cards on every width. */
++.ovv-app-home { min-width: 0; }
++.ovv-app-head { display: flex; align-items: center; gap: 16px; margin: 2px 0 20px; }
++.ovv-app-head h1 { margin: 0; font-size: clamp(20px, 2.6vw, 26px); line-height: 1.2; }
++.ovv-feed-signin { display: inline-flex; align-items: center; min-height: 44px; margin-left: auto; padding: 8px 18px; border: 1px solid var(--ovv-line); border-radius: 999px; color: inherit; font-weight: 700; text-decoration: none; }
++.ovv-feed-chips { display: flex; gap: 8px; overflow-x: auto; margin: 0 0 28px; padding: 0 0 5px; scrollbar-width: none; }
++.ovv-feed-chips::-webkit-scrollbar { display: none; }
++.ovv-feed-chips a { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 9px 18px; flex: none; border: 1px solid var(--ovv-line); border-radius: 999px; background: var(--ovv-card); color: inherit; text-decoration: none; font-size: 14px; font-weight: 700; }
++.ovv-feed-chips a[aria-current="page"] { border-color: var(--accent-strong, #2563eb); background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
++.ovv-feed-chips a:hover, .ovv-feed-signin:hover { border-color: var(--ovv-accent); }
++.ovv-app-feed h2, .ovv-continue h2 { margin: 0 0 14px; font-size: 19px; }
++.ovv-app-feed .ovv-grid { grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 26px 16px; }
++.ovv-card-body { display: flex; gap: 10px; }
++.ovv-card-copy { min-width: 0; }
++.ovv-card-avatar { display: block; flex: 0 0 36px; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; }
++.ovv-card-avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
++.ovv-continue { margin: 0 0 26px; }
++.ovv-continue-card { display: flex; align-items: center; gap: 14px; max-width: 480px; min-height: 90px; padding: 8px; border: 1px solid var(--ovv-line); border-radius: 14px; background: var(--ovv-card); color: inherit; text-decoration: none; }
++.ovv-continue-card img { width: 126px; aspect-ratio: 16 / 9; border-radius: 8px; object-fit: cover; }
++.ovv-continue-copy { display: grid; gap: 5px; min-width: 0; }.ovv-continue-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ovv-continue-copy > span { color: var(--ovv-dim); font-size: 13px; }
++.ovv-record-file { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
++@media (max-width: 599px) {
++ .ovv-page:has(.ovv-app-home) { padding: 16px 16px 90px; }
++ .ovv-app-head { margin-bottom: 16px; }.ovv-app-head h1 { font-size: 18px; }
++ .ovv-app-feed .ovv-grid { grid-template-columns: minmax(0, 1fr); gap: 24px; }
++ .ovv-app-feed .ovv-card-title { font-size: 16px; }
++}
++
/* The owner's Delete in the action row (beside Edit): a quiet button, a red one inside its question. */
.ovv-deleter > summary { color: var(--danger-text, var(--danger, #f87171)); }
.ovv-deleter .ovv-pop-panel form { margin-top: 10px; }
ddiff --git a/public/js/video-home.js b/public/js/video-home.js
nnew file mode 100644
iindex 0000000..be9eede
---- /dev/null
++++ b/public/js/video-home.js
@@@ -0,0 +1,19 @@
++/* The local continue-watching card. Video history is kept only in this browser. */
++(function () {
++ 'use strict';
++ var mount = document.getElementById('continue-watching');
++ if (!mount) return;
++ var saved;
++ try { saved = JSON.parse(localStorage.getItem('ovv-continue') || 'null'); } catch { return; }
++ if (!saved || typeof saved.href !== 'string' || !/^\/(watch|v|c|u)(\/|\?)/.test(saved.href) || !Number.isFinite(saved.at) || saved.at < 5 || Date.now() - saved.updated > 30 * 86400000) return;
++ var heading = document.createElement('h2'); heading.textContent = 'Continue watching';
++ var link = document.createElement('a'); link.className = 'ovv-continue-card';
++ var url = new URL(saved.href, location.origin); url.searchParams.set('t', String(saved.at)); link.href = url.pathname + url.search;
++ if (typeof saved.poster === 'string' && /^https:\/\//.test(saved.poster)) {
++ var img = document.createElement('img'); img.src = saved.poster; img.alt = ''; img.loading = 'lazy'; link.appendChild(img);
++ }
++ var copy = document.createElement('span'); copy.className = 'ovv-continue-copy';
++ var title = document.createElement('strong'); title.textContent = saved.title || 'Keep watching'; copy.appendChild(title);
++ var progress = document.createElement('span'); progress.textContent = Math.floor(saved.at / 60) + ':' + String(saved.at % 60).padStart(2, '0') + ' of ' + Math.floor(saved.duration / 60) + ':' + String(saved.duration % 60).padStart(2, '0'); copy.appendChild(progress);
++ link.appendChild(copy); mount.appendChild(heading); mount.appendChild(link); mount.hidden = false;
++})();
ddiff --git a/public/js/video-record.js b/public/js/video-record.js
iindex 5cda606..328dd85 100644
---- a/public/js/video-record.js
++++ b/public/js/video-record.js
@@@ -1,102 +1,24 @@
--/* OpenVibe.Video — /record: record the camera or the screen with MediaRecorder and send it as it records. Every few
-- * seconds the recorder hands over a piece, which goes up in order (PUT /api/v1/videos/:id/chunks/:n) to the video
-- * started with POST /api/v1/videos/recordings; Stop sends the last piece and completes it. Nothing is ever live, and
-- * nothing is kept in the browser beyond the pieces still waiting to go up. */
++/* Record on this page with OpenVibe.Cam, then give its File to Video's ordinary chunked upload form. */
(function () {
'use strict';
var form = document.querySelector('form[data-video-record]');
if (!form) return;
-- var preview = form.querySelector('.ovv-record-preview');
-- var clock = form.querySelector('.ovv-record-clock');
var status = form.querySelector('[role=status]');
-- var bPreview = form.querySelector('[data-record-preview]');
-- var bStart = form.querySelector('[data-record-start]');
-- var bStop = form.querySelector('[data-record-stop]');
-- function say(t) { status.textContent = t; }
-- if (!window.MediaRecorder || !navigator.mediaDevices || !window.fetch) { say('This browser cannot record video. Upload a file instead.'); bPreview.disabled = true; return; }
-- var TYPES = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'];
-- var mime = TYPES.filter(function (t) { return MediaRecorder.isTypeSupported(t); })[0] || '';
-- var stream = null, recorder = null, id = null, n = 0, queue = Promise.resolve(), failed = null, started = 0, tick = null, sentBytes = 0;
-- function source() { var c = form.querySelector('input[name=source]:checked'); return c ? c.value : 'camera'; }
-- function stopTracks() { if (stream) stream.getTracks().forEach(function (t) { t.stop(); }); stream = null; }
-- function request(url, opts) {
-- return fetch(url, opts).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) || ('Refused (' + res.status + ').')); }, function () { throw new Error('Refused (' + res.status + ').'); });
++ var fileInput = form.querySelector('input[name=file]');
++ var buttons = form.querySelectorAll('[data-record-open]');
++ function say(text) { if (status) status.textContent = text; }
++ Array.prototype.forEach.call(buttons, function (button) {
++ button.addEventListener('click', async function () {
++ if (!window.OpenVibeCam || !window.DataTransfer) { say('The camera is unavailable in this browser. You can upload a file instead.'); return; }
++ button.disabled = true;
++ try {
++ var result = await window.OpenVibeCam.open({ mode: button.getAttribute('data-record-open'), maxSeconds: 600 });
++ if (!result) { say('Recording cancelled.'); return; }
++ var transfer = new DataTransfer(); transfer.items.add(result.file); fileInput.files = transfer.files;
++ say('Uploading your clip…');
++ form.requestSubmit();
++ } catch (err) { say(err.message || 'The camera could not open.'); }
++ finally { button.disabled = false; }
});
-- }
-- function send(blob) {
-- var k = ++n;
-- queue = queue.then(function () {
-- if (failed) return null;
-- var tries = 0;
-- var once = function () {
-- return request('/api/v1/videos/' + id + '/chunks/' + k, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream' }, body: blob }).then(function () {
-- sentBytes += blob.size;
-- say('Recording… ' + (sentBytes / 1048576).toFixed(1) + ' MB saved');
-- }, function (err) {
-- if (++tries < 3 && !/Refused \(4|already finished|Send piece|at most|pass/.test(err.message)) return new Promise(function (r) { setTimeout(r, 1500); }).then(once);
-- failed = err; throw err;
-- });
-- };
-- return once();
-- }).catch(function (err) {
-- say('Saving stopped: ' + err.message);
-- if (recorder && recorder.state !== 'inactive') recorder.stop();
-- });
-- }
-- function getStream() {
-- if (source() === 'screen') {
-- return navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }).then(function (screen) {
-- return navigator.mediaDevices.getUserMedia({ audio: true }).then(function (mic) {
-- mic.getAudioTracks().forEach(function (t) { screen.addTrack(t); });
-- return screen;
-- }, function () { return screen; });
-- });
-- }
-- return navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: true });
-- }
-- bPreview.addEventListener('click', function () {
-- stopTracks();
-- getStream().then(function (s) {
-- stream = s;
-- preview.srcObject = s; preview.hidden = false; preview.play().catch(function () {});
-- bStart.disabled = false;
-- bPreview.textContent = source() === 'screen' ? 'Choose another screen' : 'Restart the camera';
-- say('Ready. Press Start recording.');
-- }, function () { say(source() === 'screen' ? 'Screen sharing was not allowed.' : 'The camera or microphone was not allowed.'); });
-- });
-- form.addEventListener('change', function (e) { if (e.target.name === 'source') { bPreview.textContent = e.target.value === 'screen' ? 'Choose the screen' : 'Turn on the camera'; } });
-- bStart.addEventListener('click', function () {
-- if (!stream) return;
-- bStart.disabled = true; bPreview.disabled = true;
-- say('Starting…');
-- request('/api/v1/videos/recordings', { method: 'POST', headers: { 'Content-Type': 'application/json' },
-- body: JSON.stringify({ title: form.elements.title.value, visibility: form.elements.visibility.value, content_type: (mime || 'video/webm').split(';')[0] }) })
-- .then(function (out) {
-- id = out.upload.id;
-- recorder = new MediaRecorder(stream, mime ? { mimeType: mime, videoBitsPerSecond: 2500000 } : { videoBitsPerSecond: 2500000 });
-- recorder.ondataavailable = function (e) { if (e.data && e.data.size) send(e.data); };
-- recorder.onstop = function () {
-- stopTracks(); clearInterval(tick); clock.hidden = true;
-- queue.then(function () {
-- if (failed && n <= 1) throw failed;
-- say('Saving…');
-- return request('/api/v1/videos/' + id + '/complete', { method: 'POST' });
-- }).then(function (done) {
-- say('Saved. OpenVibe.Media is processing it; opening its page…');
-- window.location.assign(done.video.url);
-- }, function (err) { say('The recording could not be saved: ' + err.message); bPreview.disabled = false; });
-- };
-- // A stream that ends by itself (the screen share stopped) stops the recording too.
-- stream.getVideoTracks().forEach(function (t) { t.addEventListener('ended', function () { if (recorder.state !== 'inactive') recorder.stop(); }); });
-- recorder.start(4000);
-- started = Date.now(); clock.hidden = false;
-- tick = setInterval(function () { var s = Math.floor((Date.now() - started) / 1000); clock.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }, 500);
-- bStop.disabled = false;
-- say('Recording…');
-- }, function (err) { say(err.message); bStart.disabled = false; bPreview.disabled = false; });
});
-- bStop.addEventListener('click', function () { bStop.disabled = true; if (recorder && recorder.state !== 'inactive') recorder.stop(); });
-- window.addEventListener('beforeunload', function (e) { if (recorder && recorder.state === 'recording') { e.preventDefault(); e.returnValue = ''; } });
})();
ddiff --git a/public/js/video.js b/public/js/video.js
iindex 0a0d573..8363c2d 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -492,6 +492,19 @@
const video = document.querySelector('video[data-ovv-video]');
transcript(video);
if (!video) return;
++ // Keep one local resume point for the app home. The URL and time stay on this device.
++ let lastResumeWrite = 0;
++ video.addEventListener('timeupdate', function () {
++ if (Date.now() - lastResumeWrite < 5000 || !Number.isFinite(video.duration)) return;
++ lastResumeWrite = Date.now();
++ try {
++ if (video.currentTime < 5 || video.duration - video.currentTime < 5) {
++ if (video.duration - video.currentTime < 5) localStorage.removeItem('ovv-continue');
++ return;
++ }
++ localStorage.setItem('ovv-continue', JSON.stringify({ href: location.pathname + location.search, title: (document.querySelector('.ovv-head h1') || {}).textContent || document.title, poster: video.poster || '', at: Math.floor(video.currentTime), duration: Math.floor(video.duration), updated: Date.now() }));
++ } catch { /* private browsing may disable storage */ }
++ });
// "Make a clip": opening the Clip menu starts it where the video is (owner, 2026-10-27: "it should have the
// start/end time set based on the current timestamp the user is on rather than being at 0"): the start at the
ddiff --git a/server/app.js b/server/app.js
iindex ce92aa9..f2743cb 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -126,8 +126,8 @@ async function createApp(opts = {}) {
directives: {
defaultSrc: ["'self'"],
// The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.
-- scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
-- styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com'],
++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://openvibe.cam', 'https://static.cloudflareinsights.com'],
++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://openvibe.cam', 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
// The Video home's live-now cards show OpenVibe.Live's own stream thumbnails (its /api/thumbnails).
imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media', liveOrigin],
ddiff --git a/server/video/layout.js b/server/video/layout.js
iindex 7561abc..2288538 100644
---- a/server/video/layout.js
++++ b/server/video/layout.js
@@@ -33,6 +33,8 @@ function assetVersion(rel) {
return v;
}
const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
++// This hash pins the Cam PR's recorder until its next reviewed release.
++const CAM_KIT_SRI = 'sha384-kD5WfySp7D3rNJliBpLgJRrl4Geks+4EcgSaCIBGX+dZdecjmUVxEjzbcUw2Spp7';
// The pages with a Markdown twin (openvibe-shared/agent-layer): the home and the watch pages of a
// recording and a clip. The others (search, categories, an account's playlists) have none.
@@@ -66,7 +68,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.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.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="https://openvibe.cam/embed/cam-kit.js" 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>`
: html`<a href="/auth/login?next=${encodeURIComponent(pathNow)}">Sign in with OpenVibe</a>`;
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 2506fa0..17924d7 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -560,11 +560,11 @@ function card(o) {
const media = html`<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : cardPlaceholder(o, icon)}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag${o.live ? ' ovv-tag--live' : ''}">${o.badge}</span>` : ''}</a>`;
return html`<article class="ovv-card"${labelsUi.attrs(labels, { owner: o.owner || o.channel })}>
${labels.length ? html`<div class="ovv-card-cover" data-ov-labels-media>${media}</div>` : media}
--<div class="ovv-card-body">
++<div class="ovv-card-body">${o.channel && faceBase && /^[A-Za-z0-9_]{1,32}$/.test(o.channel) ? html`<a class="ovv-card-avatar" href="${o.channelHref}" tabindex="-1" aria-hidden="true"><img src="${faceBase}/avatar/${o.channel}?s=80" alt="" loading="lazy" width="36" height="36"></a>` : ''}<div class="ovv-card-copy">
<h3 class="ovv-card-title"><a href="${o.href}">${o.title}</a></h3>
${labelsUi.chips(labels)}
${meta([channel, o.extra || null, o.views ? viewsLabel(o.views) : null, o.date ? html`<time datetime="${o.iso}">${o.date}</time>` : null])}
--</div>
++</div></div>
</article>`;
}
@@@ -1095,46 +1095,28 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
{ q: 'Can I make a clip?', a: `Yes. Sign in, open a recording and cut a clip of up to ${CLIP_MAX_SECONDS} seconds. It gets its own watch page and shows on the channel.` },
];
r.get('/', async (req, res) => {
-- const [vods, clips, topVods, topClips, onAir, totals, shelf] = await Promise.all([
++ const [vods, clips, uploaded] = await Promise.all([
live.feed({ type: 'vods', limit: HOME_LIMIT + 6 }),
live.feed({ type: 'clips', limit: HOME_LIMIT }),
-- settle(live.feed({ type: 'vods', limit: HOME_LIMIT, sort: 'top', window: 'month' })),
-- settle(live.feed({ type: 'clips', limit: HOME_LIMIT, sort: 'top', window: 'month' })),
-- settle(live.liveNow()),
-- settle(live.totals()),
-- settle(shelves.shelves()),
++ ctx.videoUploads.latest(HOME_LIMIT),
]);
// A stream that ended seconds after it started (a test, a dropped connection) is not worth a tile on the front
// page; it stays on its channel.
const worthATile = (x) => !(Number(x.duration_seconds) > 0 && Number(x.duration_seconds) < MIN_HOME_SECONDS);
const vodItems = itemsOf(vods).filter(worthATile).slice(0, HOME_LIMIT);
const clipItems = itemsOf(clips);
-- const topItems = [...itemsOf(topVods).filter(worthATile), ...itemsOf(topClips)]
-- .filter((x) => Number(x.views) > 0)
-- .sort((a, b) => (Number(b.views) || 0) - (Number(a.views) || 0))
-- .slice(0, HOME_LIMIT);
-- const uploaded = await ctx.videoUploads.latest(HOME_LIMIT);
-- const onAirCards = liveCards(onAir);
-- const categories = (shelf && shelf.categories) || [];
-- const channels = ((shelf && shelf.channels) || []).filter((c) => c.count >= 2).slice(0, 12);
-- const counts = totals || {};
-- const onAirNames = liveNames(onAir);
-- const liveHref = onAirNames.length === 1 ? `${livePublic}/@${onAirNames[0]}` : `${livePublic}/`;
-- const stats = [
-- onAirCards.length ? { value: n(onAirCards.length), label: 'live now', href: liveHref, live: true,
-- title: onAirNames.length === 1 ? `Watch ${onAirNames[0]} live` : 'Everyone live on OpenVibe.Live' } : null,
-- counts.vods ? { value: n(counts.vods), label: counts.vods === 1 ? 'stream recording' : 'stream recordings', href: '#recordings' } : null,
-- counts.clips ? { value: n(counts.clips), label: counts.clips === 1 ? 'clip' : 'clips', href: '#clips' } : null,
-- shelf && shelf.channels.length ? { value: n(shelf.channels.length), label: shelf.channels.length === 1 ? 'channel' : 'channels', href: '#channels' } : null,
-- categories.length ? { value: n(categories.length), label: categories.length === 1 ? 'category' : 'categories', href: '#categories' } : null,
-- ].filter(Boolean);
-- const description = counts.vods && counts.clips
-- ? `Watch ${n(counts.vods)} stream recordings and ${n(counts.clips)} clips from OpenVibe.Live, or upload and record your own videos, with AI captions and chapters.`
-- : 'Watch stream recordings and clips from OpenVibe.Live, or upload and record your own videos, with AI captions and chapters.';
-- const faq = HOME_FAQ(config.videoUploads);
++ const description = 'Watch stream recordings, clips and uploads on OpenVibe.Video, or record your own video in the browser.';
const listed = vodItems.map((it) => ({ name: it.title || 'Stream recording', url: `${base}${it.watch_id ? `/watch?v=${it.watch_id}` : `/v/${it.id}`}` }));
++ const feedKind = ['streams', 'clips', 'uploads', 'following'].includes(req.query.feed) ? req.query.feed : 'all';
++ const feedEntries = [
++ ...vodItems.map((item) => ({ kind: 'streams', item })),
++ ...clipItems.map((item) => ({ kind: 'clips', item })),
++ ...uploaded.map((item) => ({ kind: 'upload', item })),
++ ].filter((entry) => feedKind === 'all' || entry.kind === feedKind)
++ .sort((a, b) => String(b.item.created_at || '').localeCompare(String(a.item.created_at || '')));
return page(req, res, {
index: true,
++ scripts: ['js/video-home.js'],
title: 'OpenVibe.Video: watch streams again and share your videos',
description,
jsonLd: [
@@@ -1142,33 +1124,16 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
seo.jsonLd.organization(),
seo.jsonLd.webPage({ name: 'OpenVibe.Video', url: `${base}/`, description, siteUrl: base }),
...(listed.length ? [seo.jsonLd.itemList('Latest stream recordings on OpenVibe.Video', listed, { url: `${base}/` })] : []),
-- seo.jsonLd.faq(faq),
],
-- body: html`<header class="ovv-hero"><p class="ovv-eyebrow"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> OpenVibe.Video</p>
--<h1>Watch streams again, and share your own videos</h1>
--<p class="ovv-lede">Recordings and clips of streams on OpenVibe.Live land here when the stream ends. Upload a video or record your camera or screen in the browser, and OpenVibe.AI adds captions, chapters and a summary. Everything you post plays on your channel at openvibe.video/@you.</p>
--<p class="ovv-hero-actions"><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a> <a class="ovv-btn" href="/record"><i class="fa-solid fa-video" aria-hidden="true"></i> Record in the browser</a> <a class="ovv-btn" href="${livePublic}/docs/go-live-in-your-browser"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Go live from your browser</a></p>
--${searchForm()}
--${stats.length ? html`<ul class="ovv-stats" role="list">${stats.map((s) => html`<li><a class="${s.live ? 'ovv-stat is-live' : 'ovv-stat'}" href="${s.href}"${s.title ? html` title="${s.title}"` : ''}><b>${s.value}</b><span>${s.live ? html`<i class="ovv-dot" aria-hidden="true"></i> ` : ''}${s.label}</span></a></li>`)}</ul>` : ''}</header>
--${onAirCards.length ? html`<section class="ovv-section ovv-row" aria-labelledby="h-live"><div class="ovv-section-head"><h2 id="h-live"><span class="ovv-dot" aria-hidden="true"></span> Live now on OpenVibe.Live</h2><a href="${livePublic}/">Everyone live</a></div>${grid(onAirCards)}</section>` : ''}
--${uploaded.length ? html`<section class="ovv-section ovv-row" aria-labelledby="h-uploads"><h2 id="h-uploads">Latest uploads</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}
--${topItems.length ? html`<section class="ovv-section ovv-row" aria-labelledby="h-top"><h2 id="h-top">Most watched this month</h2>${grid(topItems.map((it) => feedCard(it, { views: true })))}</section>` : ''}
--<section class="ovv-section ovv-row" id="recordings" aria-labelledby="h-vods"><h2 id="h-vods">Latest stream recordings</h2>${grid(vodItems.map(feedCard), 'No recordings yet.')}</section>
--${categories.length ? html`<section class="ovv-section" id="categories" aria-labelledby="h-cats"><h2 id="h-cats">Browse by category</h2><ul class="ovv-shelf" role="list">${categories.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="fa-solid ${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}
--<section class="ovv-section ovv-row" id="clips" aria-labelledby="h-clips"><h2 id="h-clips">Latest clips</h2>${grid(clipItems.map(feedCard), 'No clips yet.')}</section>
--${channels.length ? html`<section class="ovv-section" id="channels" aria-labelledby="h-channels"><h2 id="h-channels">Channels</h2><ul class="ovv-people" role="list">${channels.map((c) => html`<li><a class="ovv-person" href="/@${c.username}"><span class="ovv-avatar" aria-hidden="true">${c.username.slice(0, 1).toUpperCase()}<img src="${config.networkUrl}/avatar/${c.username}" alt="" loading="lazy" width="42" height="42"></span><span class="ovv-person-text"><span class="ovv-person-name">@${c.username}</span><span class="ovv-person-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></span></a></li>`)}</ul></section>` : ''}
--<section class="ovv-section" aria-labelledby="h-how"><details class="ovv-fold"><summary><h2 id="h-how">How it works</h2><span>stream or upload, AI captions, share</span></summary><ol class="ovv-steps">
--<li><h3>Stream or upload</h3><p>Go live on OpenVibe.Live from your browser and the recording comes here, or upload a file: MP4, MOV, WebM or MKV up to ${formatBytes(config.videoUploads.maxBytes)}.</p></li>
--<li><h3>Let AI do the busywork</h3><p>OpenVibe.AI writes captions, chapters and a summary. On an upload, the AI cut can make an edited copy with the pauses taken out.</p></li>
--<li><h3>Share it</h3><p>Every video gets a watch page and a place on your channel. Save videos to playlists, and cut clips of up to ${CLIP_MAX_SECONDS} seconds from any recording.</p></li>
--</ol></details></section>
--<section class="ovv-section" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2><div class="ovv-faq">${faq.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</div></section>
--<section class="ovv-section" aria-labelledby="h-more"><h2 id="h-more">More on OpenVibe</h2><ul class="ovv-more" role="list">
--<li><a href="${livePublic}/"><strong>OpenVibe.Live</strong><span>Go live from your browser, no OBS needed.</span></a></li>
--<li><a href="${config.pics.url}/"><strong>OpenVibe.Pics</strong><span>Share and embed pictures with a link.</span></a></li>
--<li><a href="${config.baseUrl}/"><strong>OpenVibe.Download</strong><span>Your files and backups, shared on your terms.</span></a></li>
--<li><a href="https://openvibe.community/"><strong>OpenVibe.Community</strong><span>Forums and pastes for talking it over.</span></a></li>
--</ul></section>`,
++ body: html`<div class="ovv-app-home">
++<header class="ovv-app-head"><h1>${signedIn(req) ? 'Your video feed' : 'Videos from OpenVibe'}</h1>${signedIn(req) ? '' : html`<a class="ovv-feed-signin" href="/auth/login?next=%2F">Sign in</a>`}</header>
++<nav class="ovv-feed-chips" aria-label="Video feed">
++${[['all', 'All'], ['streams', 'Streams'], ['clips', 'Clips'], ['uploads', 'Uploads'], ['following', 'Following']].map(([key, label]) => html`<a href="${key === 'all' ? '/' : `/?feed=${key}`}"${feedKind === key ? html` aria-current="page"` : ''}>${label}</a>`)}
++</nav>
++<section class="ovv-continue" id="continue-watching" aria-label="Continue watching" hidden></section>
++<section class="ovv-app-feed" aria-labelledby="feed-title"><h2 id="feed-title">${{ all: 'For you', streams: 'Streams', clips: 'Clips', uploads: 'Uploads', following: 'Following' }[feedKind]}</h2>
++${feedKind === 'following' ? html`<p class="muted">See updates from followed channels on <a href="${livePublic}/rundown?following=1">OpenVibe.Live</a>.</p>` : grid(feedEntries.map((entry) => entry.kind === 'upload' ? uploadCard(entry.item) : feedCard(entry.item, { views: true })), 'No videos here yet.')}
++</section></div>`,
});
});
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex 10918bc..e374176 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -191,19 +191,19 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
r.get('/record', mustSignIn('/record', 'Recordings belong to your OpenVibe account: sign in to record a video.'), async (req, res) => page(req, res, {
title: 'Record a video',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Record' }],
-- scripts: ['js/video-record.js'],
++ scripts: ['js/video-upload.js', 'js/video-record.js'],
body: html`<h1>Record a video</h1>
--<p class="ovv-lede">Record from your camera or your screen, right here. It is never live: each few seconds go up to your OpenVibe.Video account as you record, and when you stop it becomes a video you can keep private, share by link or publish.</p>
--<form class="ovv-record ovv-panel" data-video-record>
--<fieldset class="ovv-record-source"><legend>Record</legend>
--<label class="ovv-radio"><input type="radio" name="source" value="camera" checked> Camera and microphone</label>
--<label class="ovv-radio"><input type="radio" name="source" value="screen"> Screen, with your microphone</label></fieldset>
++<p class="ovv-lede">Open the camera here, then review and trim your clip before saving it to Video.</p>
++<form class="ovv-record ovv-panel" data-video-upload data-video-record>
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" placeholder="Untitled recording"></label>
<label class="ovv-field"><span>Who can see it</span><select name="visibility">${visibilityOptions('private')}</select></label>
--<div class="ovv-record-stage"><video class="ovv-record-preview" muted playsinline hidden></video><p class="ovv-record-clock" aria-live="off" hidden>0:00</p></div>
++<input name="file" type="file" accept="video/webm,video/mp4" class="ovv-record-file" aria-label="Recorded file" tabindex="-1" required>
++<textarea name="description" hidden></textarea>
++<progress hidden value="0" max="1"></progress>
<p class="ovv-upload-status" role="status" aria-live="polite"></p>
--<p class="ovv-record-actions"><button class="ovv-btn" type="button" data-record-preview>Turn on the camera</button> <button class="ovv-btn ovv-btn--primary" type="button" data-record-start disabled>Start recording</button> <button class="ovv-btn ovv-btn--danger" type="button" data-record-stop disabled>Stop and save</button></p>
--<p class="ovv-fine">Up to ${Math.round(cfg.maxBytes / GB)} GB a video. Keep this tab open while recording; a few seconds are lost at most if the connection drops. Recording needs a browser that records video (Chrome, Edge, Firefox, Safari 14.1+) and JavaScript.</p>
++<p class="ovv-record-actions"><button class="ovv-btn ovv-btn--primary" type="button" data-record-open="video">Open camera</button> <button class="ovv-btn" type="button" data-record-open="screen">Record screen</button></p>
++<button type="submit" hidden>Save recording</button>
++<p class="ovv-fine">Cam records on this page. You can retake or trim before Use this; only then does the file upload. Keep this tab open while it uploads. Up to ${Math.round(cfg.maxBytes / GB)} GB a video.</p>
</form>
<p><a href="/upload">Upload a file instead</a> · <a href="/me/videos">Your videos</a></p>`,
}));
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex 729a1e3..a6b2441 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -318,12 +318,12 @@ function countOccurrences(haystack, needle) {
assert.ok(j.release && j.service, JSON.stringify(j).slice(0, 200));
});
-- await check('the video home shows both latest grids and links the watch pages', async () => {
++ await check('the video home feeds recordings and clips with their watch links', async () => {
const r = await getVideo('/');
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('OpenVibe.Video'));
-- assert.ok(r.text.includes('Latest stream recordings'));
-- assert.ok(r.text.includes('Latest clips'));
++ assert.ok(r.text.includes('id="feed-title">For you'));
++ assert.ok(r.text.includes('class="ovv-app-feed"'));
assert.ok(r.text.includes('/v/101'));
const ld = graphOf(r.text);
assert.ok(ld.some((n) => n['@type'] === 'WebSite' && n.name === 'OpenVibe.Video'), 'the home says what the site is (JSON-LD)');
@@@ -507,39 +507,25 @@ function countOccurrences(haystack, needle) {
assert.strictEqual(r.text, 'WEBVTT\n\n00:00:10.000 --> 00:00:30.000\nIntro &amp; setup\n\n00:00:30.000 --> 00:00:45.000\nThe &lt;main&gt; part\n');
});
-- await check('the video home: live now, most watched, real counts, categories, channels, how it works and questions', async () => {
++ await check('the video home is a card feed with filters and one compact signed-out line', async () => {
const r = await getVideo('/');
assert.strictEqual(r.status, 200);
assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
-- // Live now: the stream links its Live channel, with Live's own thumbnail; the adult one and a foreign picture are not shown.
-- assert.ok(r.text.includes('Live now on OpenVibe.Live') && r.text.includes('href="https://openvibe.live/@onair"'), 'the live stream links its channel on Live');
-- assert.ok(r.text.includes('src="https://openvibe.live/api/thumbnails/stream-1-123.jpg"'), "Live's own stream thumbnail");
-- assert.ok(r.text.includes('7 watching') && r.text.includes('Robots'), 'viewers and the category');
-- assert.ok(!r.text.includes('ADULT_STREAM_XYZ'), 'a stream marked adult is left out');
-- assert.ok(!r.text.includes('evil.example'), 'a thumbnail from anywhere else is dropped');
-- // Most watched: clips and recordings together, by views, with the count.
-- const top = r.text.slice(r.text.indexOf('id="h-top"'), r.text.indexOf('id="h-vods"'));
-- assert.ok(top.indexOf('/c/201') !== -1 && top.indexOf('/c/201') < top.indexOf('/v/101'), 'the clip with more views comes first');
-- assert.ok(top.includes('250 views') && top.includes('100 views'), 'view counts');
-- // The numbers are Live's and Search's own.
-- assert.ok(/href="#recordings"><b>812<\/b>/.test(r.text) && /href="#clips"><b>399<\/b>/.test(r.text), 'recordings and clips counted by Live, each a link to its section');
-- assert.ok(/class="ovv-stat is-live" href="https:\/\/openvibe\.live\/"/.test(r.text), 'with two streams live, the live now tile opens everyone live');
-- assert.ok(r.text.includes('src="https://openvibe.media/live/@elsewhere/frame.jpg?w=640"'), "a stream without Live's own thumbnail shows Media's live frame");
-- assert.ok(r.text.includes('id="recordings"') && r.text.includes('id="clips"') && r.text.includes('id="categories"'), 'the sections the stats link to');
-- assert.ok(r.text.includes('Watch 812 stream recordings and 399 clips'), 'the description carries the counts');
-- // Categories and channels from Search's facets: only known categories, only real usernames with two or more.
-- assert.ok(r.text.includes('href="/category/gaming"') && r.text.includes('5 recordings'), 'a category with its count');
-- assert.ok(r.text.includes('href="/category/music"') && r.text.includes('1 recording<'), 'one recording, singular');
-- assert.ok(!r.text.includes('not-a-category'), 'an unknown category is not shown');
-- assert.ok(r.text.includes('href="/@streamer"') && r.text.includes('3 recordings'), 'a channel with its count');
-- assert.ok(!r.text.includes('bad name!') && !r.text.includes('href="/@once"'), 'a bad name and a one-recording channel are left out');
++ for (const label of ['All', 'Streams', 'Clips', 'Uploads', 'Following']) assert.ok(r.text.includes('>' + label + '</a>'), label);
++ assert.ok(r.text.includes('class="ovv-app-home"') && r.text.includes('class="ovv-app-feed"'));
++ assert.ok(r.text.includes('href="/auth/login?next=%2F"'), 'a sign-in chip');
++ assert.ok(!r.text.includes('class="ovv-hero"') && !r.text.includes('ovv-hero-actions'), 'no marketing hero');
++ assert.ok(!r.text.includes('ovv-search-form'), 'search is in the navbar');
++ assert.ok(r.text.includes('/v/101') && r.text.includes('/c/201'), 'recordings and clips share the feed');
++ assert.ok(r.text.includes('ovv-card-avatar'), 'cards show the channel face');
const ld = graphOf(r.text);
-- const faq = ld.find((x) => x['@type'] === 'FAQPage');
-- assert.ok(faq && faq.mainEntity.length >= 5, 'FAQPage structured data');
-- const shown = [...r.text.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
-- assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'every question in the structured data is on the page');
-- assert.ok(ld.some((x) => x['@type'] === 'ItemList' && x.itemListElement.some((it) => it.url === 'https://openvibe.video/v/101')), 'the latest recordings as an ItemList');
-- assert.ok(r.text.includes('up to 2 GB') && !r.text.includes('2.0 GB'), 'the upload limit, as people write it');
++ assert.ok(ld.some((x) => x['@type'] === 'ItemList' && x.itemListElement.some((it) => it.url === 'https://openvibe.video/v/101')));
++ const clips = await getVideo('/?feed=clips');
++ assert.ok(clips.text.includes('aria-current="page">Clips</a>'));
++ const clipFeed = clips.text.slice(clips.text.indexOf('class="ovv-app-feed"'));
++ assert.ok(clipFeed.includes('/c/201') && !clipFeed.includes('href="/v/101"'));
++ const following = await getVideo('/?feed=following');
++ assert.ok(following.text.includes('href="https://openvibe.live/rundown?following=1"'));
});
await check('a category page lists its recordings from Search, with the thumbnails Live has', async () => {
ddiff --git a/test/video-uploads.test.js b/test/video-uploads.test.js
iindex 3a535af..aa3d838 100644
---- a/test/video-uploads.test.js
++++ b/test/video-uploads.test.js
@@@ -196,6 +196,10 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
assert.strictEqual((await vreq(t.base, `/api/v1/videos/${rid}/chunks/4`, { method: 'PUT', body: pieces[2], cookie: samCookie })).status, 409, 'closed once complete');
const rec = await vreq(t.base, '/record', { cookie: samCookie });
assert.ok(rec.text.includes('data-video-record') && rec.text.includes('js/video-record.js'), 'the recorder page');
++ assert.ok(rec.text.includes('https://openvibe.cam/embed/cam-kit.js') && rec.text.includes('integrity="sha384-'), 'Cam kit is pinned');
++ assert.ok(rec.text.includes('data-video-upload') && rec.text.includes('js/video-upload.js'), 'Cam returns a file to the existing upload path');
++ assert.ok(rec.text.includes('data-record-open="video"') && rec.text.includes('data-record-open="screen"'));
++ assert.ok(!rec.text.includes('ovv-record-preview'), 'the old recorder UI is gone');
assert.strictEqual((await vreq(t.base, '/record')).status, 401, 'signed out: sign in first');
});