openvibers/openvibe.mediahub Commits

Polish clip and recording watch pages (#342)

OP OpenVibers committed 4 days ago · ac74c093ed · 27 files, +289 −80

* Polish video watch pages across screen sizes

* Merge main into watch page polish

* Labels test: the chips still follow the title, now inside its title line

* Merge remote-tracking branch 'origin/main' into feat/video-watch-polish-1109

Changes

ddiff --git a/docs/screenshots/watch/clip-1280-dark-owner-menu.webp b/docs/screenshots/watch/clip-1280-dark-owner-menu.webp
nnew file mode 100644
iindex 0000000..22f96e2
BBinary files /dev/null and b/docs/screenshots/watch/clip-1280-dark-owner-menu.webp differ
ddiff --git a/docs/screenshots/watch/clip-1280-dark.webp b/docs/screenshots/watch/clip-1280-dark.webp
nnew file mode 100644
iindex 0000000..aeef12b
BBinary files /dev/null and b/docs/screenshots/watch/clip-1280-dark.webp differ
ddiff --git a/docs/screenshots/watch/clip-1280-light-owner-menu.webp b/docs/screenshots/watch/clip-1280-light-owner-menu.webp
nnew file mode 100644
iindex 0000000..205d7e8
BBinary files /dev/null and b/docs/screenshots/watch/clip-1280-light-owner-menu.webp differ
ddiff --git a/docs/screenshots/watch/clip-1280-light.webp b/docs/screenshots/watch/clip-1280-light.webp
nnew file mode 100644
iindex 0000000..1e7040f
BBinary files /dev/null and b/docs/screenshots/watch/clip-1280-light.webp differ
ddiff --git a/docs/screenshots/watch/clip-390-dark-owner-menu.webp b/docs/screenshots/watch/clip-390-dark-owner-menu.webp
nnew file mode 100644
iindex 0000000..211b3f5
BBinary files /dev/null and b/docs/screenshots/watch/clip-390-dark-owner-menu.webp differ
ddiff --git a/docs/screenshots/watch/clip-390-dark.webp b/docs/screenshots/watch/clip-390-dark.webp
nnew file mode 100644
iindex 0000000..8d932c2
BBinary files /dev/null and b/docs/screenshots/watch/clip-390-dark.webp differ
ddiff --git a/docs/screenshots/watch/clip-390-light-owner-menu.webp b/docs/screenshots/watch/clip-390-light-owner-menu.webp
nnew file mode 100644
iindex 0000000..f205fb8
BBinary files /dev/null and b/docs/screenshots/watch/clip-390-light-owner-menu.webp differ
ddiff --git a/docs/screenshots/watch/clip-390-light.webp b/docs/screenshots/watch/clip-390-light.webp
nnew file mode 100644
iindex 0000000..93bf5c7
BBinary files /dev/null and b/docs/screenshots/watch/clip-390-light.webp differ
ddiff --git a/docs/screenshots/watch/vod-1280-dark-owner-menu.webp b/docs/screenshots/watch/vod-1280-dark-owner-menu.webp
nnew file mode 100644
iindex 0000000..1a89a74
BBinary files /dev/null and b/docs/screenshots/watch/vod-1280-dark-owner-menu.webp differ
ddiff --git a/docs/screenshots/watch/vod-1280-dark.webp b/docs/screenshots/watch/vod-1280-dark.webp
nnew file mode 100644
iindex 0000000..d51dc51
BBinary files /dev/null and b/docs/screenshots/watch/vod-1280-dark.webp differ
ddiff --git a/docs/screenshots/watch/vod-1280-light-owner-menu.webp b/docs/screenshots/watch/vod-1280-light-owner-menu.webp
nnew file mode 100644
iindex 0000000..cc1e896
BBinary files /dev/null and b/docs/screenshots/watch/vod-1280-light-owner-menu.webp differ
ddiff --git a/docs/screenshots/watch/vod-1280-light.webp b/docs/screenshots/watch/vod-1280-light.webp
nnew file mode 100644
iindex 0000000..c48b067
BBinary files /dev/null and b/docs/screenshots/watch/vod-1280-light.webp differ
ddiff --git a/docs/screenshots/watch/vod-390-dark-owner-menu.webp b/docs/screenshots/watch/vod-390-dark-owner-menu.webp
nnew file mode 100644
iindex 0000000..b7e3be6
BBinary files /dev/null and b/docs/screenshots/watch/vod-390-dark-owner-menu.webp differ
ddiff --git a/docs/screenshots/watch/vod-390-dark.webp b/docs/screenshots/watch/vod-390-dark.webp
nnew file mode 100644
iindex 0000000..b63a796
BBinary files /dev/null and b/docs/screenshots/watch/vod-390-dark.webp differ
ddiff --git a/docs/screenshots/watch/vod-390-light-owner-menu.webp b/docs/screenshots/watch/vod-390-light-owner-menu.webp
nnew file mode 100644
iindex 0000000..f82a50a
BBinary files /dev/null and b/docs/screenshots/watch/vod-390-light-owner-menu.webp differ
ddiff --git a/docs/screenshots/watch/vod-390-light.webp b/docs/screenshots/watch/vod-390-light.webp
nnew file mode 100644
iindex 0000000..4b8a1a6
BBinary files /dev/null and b/docs/screenshots/watch/vod-390-light.webp differ
ddiff --git a/migrations/0027_video_likes.sql b/migrations/0027_video_likes.sql
nnew file mode 100644
iindex 0000000..bb108d8
---- /dev/null
++++ b/migrations/0027_video_likes.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++-- A person's likes on Live recordings and clips, shown on openvibe.video.
++CREATE TABLE video_likes (
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('vod', 'clip')),
++ item_id bigint NOT NULL,
++ owner text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL,
++ PRIMARY KEY (kind, item_id, owner)
++);
++CREATE INDEX video_likes_owner ON video_likes (owner);
ddiff --git a/public/css/video.css b/public/css/video.css
iindex 9e21b83..9577de1 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -536,7 +536,7 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@media (hover: none) { .ovv-pip-bar { opacity: 1; } }
/* ── The owner's tools (2026-10-27: "I don't see any way to edit things … I just had to scroll way down") ──
-- Edit is first in the action row; the panel opens right under the player and stays out of the way until then (a
++ Edit is in More; the panel opens right under the player and stays out of the way until then (a
link to #manage without JavaScript still shows it). Who can see it is three choices, and the meta line says which. */
.ovv-btn--edit { border-color: color-mix(in srgb, var(--ovv-accent) 55%, var(--ovv-line-strong)); color: var(--text-primary, #e6edf7); }
.ovv-btn--edit i { color: var(--ovv-accent); }
@@@ -687,7 +687,90 @@ html.ov-motion .ovv-stat.is-live .ovv-dot { animation: ovv-pulse 1.6s ease-out i
.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. */
++/* The owner's Delete in More: 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; }
.ovv-deleter .ovv-pop-panel .ovv-btn--danger { width: 100%; justify-content: center; }
++
++/* Watch page: compact title, channel and one family of actions. */
++.ovv-watch .ovv-head { min-width: 0; }
++.ovv-watch .ovv-stage, .ovv-watch .ovv-player-wrap { min-width: 0; max-width: 100%; }
++.ovv-watch .ovv-title-line { display: flex; align-items: baseline; gap: 9px; min-width: 0; margin-bottom: 14px; }
++.ovv-watch .ovv-kind { flex: none; color: var(--ovv-faint); font-size: .76rem; font-weight: 700; letter-spacing: .02em; }
++.ovv-watch .ovv-title { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; margin: 0; font-size: clamp(1.25rem, 2vw, 1.65rem); line-height: 1.3; letter-spacing: -.02em; }
++.ovv-watch .ovv-head-row { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 16px; min-width: 0; }
++.ovv-watch .ovv-head .ovv-byline { flex: 0 0 auto; gap: 9px; min-width: 0; }
++.ovv-watch .ovv-byline-text { flex: 0 1 auto; min-width: 0; }
++.ovv-watch .ovv-avatar { width: 40px; height: 40px; }
++.ovv-watch .ovv-follow { min-height: 40px; margin-left: 6px; padding: 0 16px; border: 0; border-radius: 999px; background: var(--live-red, #ef4444); color: #fff !important; }
++.ovv-watch .ovv-follow:hover { filter: brightness(1.08); background: var(--live-red, #ef4444); }
++.ovv-watch .ovv-actions-shell { position: relative; flex: 1 1 100%; min-width: 0; }
++.ovv-watch .ovv-actions { display: flex; flex-wrap: nowrap; align-items: center; width: max-content; gap: 8px; }
++.ovv-watch .ovv-actions > * { flex: none; }
++.ovv-watch .ovv-actions .ovv-btn, .ovv-watch .ovv-actions details > summary.ovv-btn { box-sizing: border-box; display: inline-flex; flex-direction: row; align-items: center; justify-content: center; gap: 8px; width: auto; min-height: 44px; padding: 0 16px; border-radius: 999px; font-size: .84rem; line-height: 1; white-space: nowrap; cursor: pointer; transition: background .18s ease, border-color .18s ease, transform .18s ease, box-shadow .18s ease; }
++.ovv-watch .ovv-actions .ovv-btn:hover, .ovv-watch .ovv-actions details > summary.ovv-btn:hover { transform: translateY(-2px); box-shadow: 0 5px 14px rgba(0, 0, 0, .12); }
++.ovv-watch .ovv-actions .ovv-btn:active, .ovv-watch .ovv-actions details > summary.ovv-btn:active { transform: scale(.97); }
++.ovv-watch .ovv-actions .ovv-btn :is(i, svg) { width: 20px; height: 20px; margin: 0; font-size: 20px; line-height: 20px; }
++.ovv-watch .ovv-actions .ovv-btn--icon { width: 44px; min-width: 44px; padding: 0; }
++.ovv-watch .ovv-actions > .ovk-ask { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: auto; height: 44px; min-height: 44px; padding: 0 16px; border: 1px solid var(--ovv-line-strong); border-radius: 999px; background: var(--bg-tertiary, #16223a); color: var(--text-primary, #e6edf7); font: inherit; font-size: .84rem; font-weight: 700; line-height: 1; white-space: nowrap; cursor: pointer; transition: background .18s ease, transform .18s ease; }
++.ovv-watch .ovv-actions > .ovk-ask:hover { background: var(--bg-hover, #1c2a44); transform: translateY(-2px); }
++.ovv-watch .ovv-actions > .ovk-ask :is(.ovk-i, svg, i) { flex: none; width: 20px; height: 20px; font-size: 20px; }
++.ovv-watch .ovv-like[aria-pressed="true"] { color: var(--live-red, #ef4444) !important; border-color: color-mix(in srgb, var(--live-red, #ef4444) 35%, transparent); }
++.ovv-watch .ovv-like.ovv-like--pop i { animation: ovv-like-pop .32s ease-out; }
++@keyframes ovv-like-pop { 50% { transform: scale(1.32) rotate(-9deg); } }
++.ovv-watch .ovv-more-actions { display: grid; gap: 2px; }
++.ovv-watch .ovv-more-actions > :is(a, button), .ovv-watch .ovv-more-actions .ovv-deleter > summary { box-sizing: border-box; display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 10px 12px; border: 0; border-radius: 9px; background: transparent; color: inherit !important; font: inherit; font-size: .9rem; font-weight: 700; text-align: left; text-decoration: none; cursor: pointer; }
++.ovv-watch .ovv-more-actions > [hidden] { display: none; }
++.ovv-watch .ovv-more-actions > :is(a, button):hover, .ovv-watch .ovv-more-actions .ovv-deleter > summary:hover { background: var(--bg-hover, #1c2a44); }
++.ovv-watch .ovv-actions .ovv-more-actions > a.ovv-btn, .ovv-watch .ovv-actions .ovv-more-actions .ovv-deleter > summary.ovv-btn { display: flex; justify-content: flex-start; width: 100%; min-height: 44px; padding: 10px 12px; border: 0; border-radius: 9px; background: transparent; box-shadow: none; transform: none; }
++.ovv-watch .ovv-more-actions .ovv-pop-list { border-top: 1px solid var(--ovv-line); padding-top: 4px; }
++
++/* The description is server rendered in both states. Its summary is the short preview. */
++.ovv-watch .ovv-about { display: block; min-width: 0; border: 0; border-radius: 14px; padding: 12px 16px; background: color-mix(in srgb, var(--ovv-card) 88%, var(--text-primary, #e6edf7) 12%); }
++.ovv-watch .ovv-about > summary { display: block; list-style: none; cursor: pointer; }
++.ovv-watch .ovv-about > summary::-webkit-details-marker { display: none; }
++.ovv-watch .ovv-about-facts { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; font-size: .84rem; font-weight: 700; }
++.ovv-watch .ovv-about-facts .ovv-what { display: contents; margin: 0; color: inherit; font-size: inherit; }
++.ovv-watch .ovv-about-facts .ovv-what-kind { display: none; }
++.ovv-watch .ovv-about-preview { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; margin-top: 5px; font-size: .9rem; line-height: 1.5; white-space: pre-line; }
++.ovv-watch .ovv-about-more, .ovv-watch .ovv-about-toggle-less { display: block; margin-top: 3px; font-size: .83rem; font-weight: 700; }
++.ovv-watch .ovv-about-toggle-less { display: none; }
++.ovv-watch .ovv-about[open] .ovv-about-preview, .ovv-watch .ovv-about[open] .ovv-about-more { display: none; }
++.ovv-watch .ovv-about[open] .ovv-about-toggle-less { display: block; }
++.ovv-watch .ovv-about-rest { display: grid; gap: 10px; padding-top: 10px; }
++.ovv-watch .ovv-about-rest h3 { margin: 0; font-size: .88rem; }
++.ovv-watch .ovv-about-chapters { display: grid; gap: 4px; }
++.ovv-watch .ovv-about-rest .ovv-about-text { line-height: 1.55; }
++.ovv-watch .ovv-about-source .ovv-source { margin: 5px 0 0; font-size: .82rem; }
++.ovv-watch .ovv-about::details-content { height: 0; overflow: hidden; interpolate-size: allow-keywords; transition: content-visibility .25s allow-discrete, height .25s ease; }
++.ovv-watch .ovv-about[open]::details-content { height: auto; }
++.ovv-watch .ovv-about > summary:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 4px; border-radius: 6px; }
++@media (min-width: 1100px) { .ovv-watch .ovv-actions-shell { flex: 1 1 100%; overflow-x: auto; scrollbar-width: thin; } .ovv-watch--clip:not(:has(.ovv-col--side > *)) { grid-template-columns: minmax(0, 1040px); } }
++@media (min-width: 1600px) {
++ .ovv-page:has(.ovv-watch) { max-width: 1600px; }
++ .ovv-watch--clip:not(:has(.ovv-col--side > *)) { grid-template-columns: minmax(0, 1400px); }
++ .ovv-watch .ovv-head-row { flex-wrap: nowrap; }
++ .ovv-watch .ovv-actions-shell { flex: 1 1 auto; }
++}
++@media (max-width: 1099px) {
++ .ovv-watch .ovv-actions-shell { overflow-x: auto; scrollbar-width: none; }
++ .ovv-watch .ovv-actions-shell::-webkit-scrollbar { display: none; }
++ .ovv-watch .ovv-head-row { position: relative; }
++ .ovv-watch .ovv-head-row::after { content: ''; position: absolute; z-index: 1; right: 0; bottom: 0; width: 28px; height: 44px; pointer-events: none; background: linear-gradient(to right, transparent, var(--bg-primary, #0b1120)); }
++}
++@media (max-width: 1000px) {
++ .ovv-watch .ovv-about { order: 2; }
++ .ovv-watch .ovv-companion { order: 3; }
++ .ovv-watch .ovv-upnext { order: 4; }
++}
++@media (max-width: 600px) {
++ .ovv-watch .ovv-head-row { display: block; }
++ .ovv-watch .ovv-head .ovv-byline { display: flex; width: 100%; margin-bottom: 12px; }
++ .ovv-watch .ovv-follow { margin-left: auto; }
++ .ovv-watch .ovv-title-line { align-items: flex-start; }
++ .ovv-watch .ovv-kind { margin-top: 4px; }
++ .ovv-watch .ovv-actions-shell { width: 100%; }
++ .ovv-watch .ovv-actions .ovv-btn, .ovv-watch .ovv-actions details > summary.ovv-btn { min-height: 44px; padding: 0 16px; font-size: .84rem; }
++ .ovv-watch .ovv-actions .ovv-btn--icon { width: 44px; padding: 0; }
++}
++@media (prefers-reduced-motion: reduce) { .ovv-watch .ovv-actions .ovv-btn, .ovv-watch .ovv-actions details > summary.ovv-btn, .ovv-watch .ovv-about::details-content { transition: none; animation: none; } .ovv-watch .ovv-like.ovv-like--pop i { animation: none; } }
ddiff --git a/public/js/video.js b/public/js/video.js
iindex 8363c2d..b7b6851 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -132,6 +132,19 @@
if (!d.open) return;
all.forEach(function (o) { if (o !== d && !o.contains(d)) close(o, false); });
if (sheet(d)) return;
++ if (d.closest('.ovv-actions-shell')) {
++ const button = d.querySelector('summary').getBoundingClientRect();
++ const width = Math.min(panel.offsetWidth, window.innerWidth - 24);
++ const left = Math.max(12, Math.min(button.left, window.innerWidth - width - 12));
++ const below = window.innerHeight - button.bottom;
++ panel.style.position = 'fixed';
++ panel.style.left = left + 'px';
++ panel.style.right = 'auto';
++ panel.style.width = width + 'px';
++ panel.style.top = below >= panel.offsetHeight + 12 ? button.bottom + 8 + 'px' : 'auto';
++ panel.style.bottom = below >= panel.offsetHeight + 12 ? 'auto' : window.innerHeight - button.top + 8 + 'px';
++ return;
++ }
const vw = document.documentElement.clientWidth;
const r = panel.getBoundingClientRect();
if (r.right > vw - 12) panel.style.transform = 'translateX(' + Math.round(vw - 12 - r.right) + 'px)';
@@@ -160,6 +173,59 @@
});
}
++ function watchActions() {
++ document.querySelectorAll('[data-ovv-like]').forEach(function (button) {
++ const count = button.querySelector('.ovv-like-count');
++ button.addEventListener('click', async function () {
++ if (button.disabled) return;
++ const ref = button.getAttribute('data-ovv-like').split(':');
++ button.disabled = true;
++ try {
++ const response = await fetch('/watch/like', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ kind: ref[0], id: ref[1] }) });
++ if (response.status === 401) { location.href = '/auth/login?next=' + encodeURIComponent(location.pathname + location.search); return; }
++ if (!response.ok) throw new Error('Like failed');
++ const state = await response.json();
++ button.setAttribute('aria-pressed', String(state.liked));
++ if (count) count.textContent = String(state.count);
++ if (state.liked) {
++ button.classList.remove('ovv-like--pop');
++ void button.offsetWidth;
++ button.classList.add('ovv-like--pop');
++ }
++ } catch (_) { button.title = 'Could not save the like. Try again.'; }
++ finally { button.disabled = false; }
++ });
++ });
++ document.querySelectorAll('[data-ovv-copy-time]').forEach(function (button) {
++ button.addEventListener('click', async function () {
++ const video = document.querySelector('.ovv-player');
++ const url = new URL(button.getAttribute('data-ovv-copy-time'), location.origin);
++ const seconds = video && Number.isFinite(video.currentTime) ? Math.floor(video.currentTime) : 0;
++ if (seconds > 0) url.searchParams.set('t', String(seconds));
++ try {
++ await navigator.clipboard.writeText(url.href);
++ const label = button.innerHTML;
++ button.textContent = 'Copied';
++ setTimeout(function () { button.innerHTML = label; }, 1800);
++ } catch (_) { location.href = url.href; }
++ });
++ });
++ const video = document.querySelector('video[data-ovv-video]');
++ const pip = document.querySelector('[data-ovv-pip]');
++ if (video && pip && document.pictureInPictureEnabled && typeof video.requestPictureInPicture === 'function') {
++ const ready = function () { if (video.readyState >= 1) pip.hidden = false; };
++ ready();
++ video.addEventListener('loadedmetadata', ready, { once: true });
++ pip.addEventListener('click', async function () {
++ try {
++ if (document.pictureInPictureElement === video) await document.exitPictureInPicture();
++ else await video.requestPictureInPicture();
++ pip.closest('details').open = false;
++ } catch (_) { pip.title = 'Picture in picture is unavailable for this video.'; }
++ });
++ }
++ }
++
// "Copy for YouTube": the chapters' text on the clipboard, where the browser lets a page write it.
function copyButtons() {
if (!navigator.clipboard || !window.fetch) return;
@@@ -485,6 +551,7 @@
}
menus();
++ watchActions();
copyButtons();
tabs();
openers();
ddiff --git a/server/account.js b/server/account.js
iindex 74f0a9f..8cf238b 100644
---- a/server/account.js
++++ b/server/account.js
@@@ -35,6 +35,7 @@ const TABLES = [
// A playlist's items have no owner column of their own; they are exported with extraExport below and erased
// by the playlists' ON DELETE CASCADE, so only the playlists table is listed here.
{ table: 'video_playlists', subject: 'owner', value: AS_REQUESTER, file: 'playlists.json' },
++ { table: 'video_likes', subject: 'owner', value: AS_REQUESTER, file: 'video_likes.json' },
// An upload that never finished is this service's own state, not something the person made: it is erased with
// the account but is not part of the export.
{ table: 'mh_uploads', subject: 'owner', value: AS_REQUESTER, file: null },
@@@ -81,7 +82,7 @@ function createAccount({ db, config, media, log = console, onMediaVodReady = nul
db,
service: 'media-hub',
tables: TABLES,
-- note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts and your playlists, your OpenVibe.Pics pictures and albums, and the videos you uploaded to OpenVibe.Video. The bytes of your files, pictures and videos live in OpenVibe.Media.',
++ note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts, playlists and video likes, your OpenVibe.Pics pictures and albums, and the videos you uploaded to OpenVibe.Video. The bytes of your files, pictures and videos live in OpenVibe.Media.',
extraExport: async (db, subject) => {
const rows = await db.many(
`SELECT i.playlist_id, i.kind, i.item_id, i.position, i.added_at
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 17924d7..be67034 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -684,6 +684,11 @@ function createVideoRoutes(ctx) {
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const ownerOf = (req) => `user:${req.viewer.subject}`;
++ async function likesOf(req, kind, id) {
++ const count = await ctx.s.db.one('SELECT COUNT(*)::int AS n FROM video_likes WHERE kind = $1 AND item_id = $2', [kind, Number(id)]);
++ const liked = signedIn(req) ? await ctx.s.db.maybe('SELECT 1 AS yes FROM video_likes WHERE kind = $1 AND item_id = $2 AND owner = $3', [kind, Number(id), ownerOf(req)]) : null;
++ return { count: Number(count.n) || 0, liked: Boolean(liked) };
++ }
const signInPrompt = (what, next) => html`<div class="notice signin-prompt"><p>${what}</p><p><a class="ovv-btn ovv-btn--primary" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></p></div>`;
const mustSignIn = (back, why) => (req, res, next) => (signedIn(req) ? next() : page(req, res, {
title: 'Sign in',
@@@ -748,8 +753,40 @@ function createVideoRoutes(ctx) {
const visibilityChoices = (current) => html`<fieldset class="ovv-vis"><legend>Who can see it</legend>${VISIBILITIES.map((v) => html`<label class="ovv-vis-opt"><input type="radio" name="visibility" value="${v}"${v === current ? ' checked' : ''}><span class="ovv-vis-card"><i class="${VISIBILITY_LOOK[v][0]}" aria-hidden="true"></i><b>${visibilityLabel(v)}</b><small>${VISIBILITY_LOOK[v][1]}</small></span></label>`)}</fieldset>`;
/** The owner's badge in the meta line: who can see it now. */
const visibilityBadge = (v) => html`<span class="ovv-vis-badge ovv-vis-badge--${VISIBILITIES.includes(v) ? v : 'public'}"><i class="${(VISIBILITY_LOOK[v] || VISIBILITY_LOOK.public)[0]}" aria-hidden="true"></i> ${visibilityLabel(v || 'public')}</span>`;
-- /** The owner's Edit button, first in the action row: opens the edit panel under the player. */
++ /** The owner's Edit link in More opens the panel under the player. */
const editButton = (what) => html`<a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage" title="Edit this ${what}: title, description, who can see it"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</a>`;
++ const titleLine = (kind, title) => html`<div class="ovv-title-line"><span class="ovv-kind">${kind}</span><h1 class="ovv-title">${title}</h1></div>`;
++ const likeButton = (state, kind, id) => html`<button class="ovv-btn ovv-like" type="button" data-ovv-like="${kind}:${id}" aria-pressed="${state.liked ? 'true' : 'false'}" title="Like this ${kind === 'clip' ? 'clip' : 'recording'}"><i class="fa-regular fa-heart" aria-hidden="true"></i> Like <span class="ovv-like-count">${state.count}</span></button>`;
++ const channelRow = (username, name, followers) => {
++ if (!username) return '';
++ const channel = `${livePublic}/@${username}`;
++ const count = Number(followers);
++ return html`<div class="ovv-byline"><a class="ovv-avatar" href="${channel}" aria-hidden="true" tabindex="-1">${String(name || username).slice(0, 1).toUpperCase()}${live.isUsername(String(username)) ? html`<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy">` : ''}</a><div class="ovv-byline-text"><a class="ovv-channel-name" href="${channel}">${name || username}</a>${Number.isSafeInteger(count) && count >= 0 ? html`<span class="ovv-meta">${count.toLocaleString('en-US')} ${count === 1 ? 'follower' : 'followers'}</span>` : ''}</div><a class="ovv-btn ovv-follow" href="${channel}" title="Follow ${name || username} on OpenVibe.Live"><i class="fa-solid fa-plus" aria-hidden="true"></i> Follow</a></div>`;
++ };
++ const watchMore = ({ path, manageable, kind, id, items }) => actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: html`<div class="ovv-more-actions"><button type="button" data-ovv-copy-time="${base}${path}"><i class="fa-regular fa-copy" aria-hidden="true"></i> Copy link at time</button><button type="button" data-ovv-pip hidden><i class="fa-regular fa-window-restore" aria-hidden="true"></i> Picture in picture</button><a href="mailto:[email protected]?subject=${encodeURIComponent(`Report ${kind} ${base}${path}`)}"><i class="fa-regular fa-flag" aria-hidden="true"></i> Report</a>${manageable ? html`${editButton(kind === 'clip' ? 'clip' : 'recording')}${deleteAction({ action: `/${kind === 'clip' ? 'c' : 'v'}/${id}`, what: kind === 'clip' ? 'clip' : 'recording' })}` : ''}${menuLinks(items)}</div>` });
++ const watchFacts = (views, date, duration, visibility) => {
++ const count = Math.max(0, Number(views) || 0);
++ const stamp = date instanceof Date ? date.getTime() : Date.parse(String(date || ''));
++ const days = Number.isFinite(stamp) ? Math.max(0, Math.floor((Date.now() - stamp) / 86400000)) : null;
++ const weeks = days == null ? 0 : Math.round(days / 7);
++ const months = days == null ? 0 : Math.round(days / 30);
++ const age = days == null ? '' : days === 0 ? 'today' : days < 7 ? `${days} ${days === 1 ? 'day' : 'days'} ago` : days < 35 ? `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago` : days < 365 ? `${months} ${months === 1 ? 'month' : 'months'} ago` : dayLabel(date);
++ const seconds = Math.max(0, Math.floor(Number(duration) || 0));
++ const length = seconds ? seconds < 60 ? `${seconds} s` : seconds < 3600 ? `${Math.round(seconds / 60)} min` : `${Math.floor(seconds / 3600)} h ${Math.round((seconds % 3600) / 60)} min` : '';
++ return html`<span>${count.toLocaleString('en-US')} ${count === 1 ? 'view' : 'views'}</span>${age ? html`<span>· ${age}</span>` : ''}${visibilityBadge(visibility)}${length ? html`<span>· ${length}</span>` : ''}`;
++ };
++ const watchAbout = ({ facts, description, source = '', madeBy = '', ai = '', parts = '', from = '', chapters: chapterRows = [], path = '', tags = [] }) => html`<details class="ovv-about" id="about">
++<summary><div class="ovv-about-facts">${facts}</div>
++<span class="ovv-about-preview">${description || 'More about this video'}</span>
++${source ? html`<div class="ovv-about-source">${source}</div>` : ''}
++<span class="ovv-about-more">…more</span><span class="ovv-about-toggle-less">Show less</span></summary>
++<div class="ovv-about-rest">${description && description !== ai ? html`<p class="ovv-about-text">${description}</p>` : ''}
++${madeBy ? html`<p class="ovv-about-from">Made by ${madeBy}</p>` : ''}${parts}${from}
++${chapterRows.length ? html`<div class="ovv-about-chapters"><h3>Chapters</h3>${chapterRows.map((m) => html`<a href="${path}${path.includes('?') ? '&' : '?'}t=${m.offset_seconds}">${clock(m.offset_seconds)} ${m.description}</a>`)}</div>` : ''}
++${tags.length ? html`<p class="ovv-about-tags">${tags.map((tag) => html`<span>#${tag}</span>`)}</p>` : ''}
++${ai ? html`<p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span> ${ai}</p>` : ''}
++ </div></details>`;
++
/**
* "Clip" in the action row: a finished recording, any signed-in viewer who can see it (Live's rule, as on Live). A
* small form under the button; signed out, the button signs in first and comes back.
@@@ -908,6 +945,20 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON
// parser, so the two never read each other's bodies.
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
++ r.post('/watch/like', async (req, res) => {
++ if (!signedIn(req)) return res.status(401).json({ error: 'Sign in to like this video.' });
++ if (!sameSite(req, res, '/')) return undefined;
++ const kind = String(req.body.kind || '');
++ const id = String(req.body.id || '');
++ if (!['vod', 'clip'].includes(kind) || !live.isId(id)) return res.status(400).json({ error: 'Invalid video.' });
++ const data = await (kind === 'vod' ? live.vod(id) : live.clip(id)) || await ownView(req, res, kind, id);
++ if (!data || !data[kind]) return res.status(404).json({ error: 'Video not found.' });
++ const owner = ownerOf(req);
++ const existing = await ctx.s.db.maybe('SELECT 1 AS yes FROM video_likes WHERE kind = $1 AND item_id = $2 AND owner = $3', [kind, Number(id), owner]);
++ if (existing) await ctx.s.db.exec('DELETE FROM video_likes WHERE kind = $1 AND item_id = $2 AND owner = $3', [kind, Number(id), owner]);
++ else await ctx.s.db.exec('INSERT INTO video_likes (kind, item_id, owner, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING', [kind, Number(id), owner, ctx.s.iso()]);
++ res.set('Cache-Control', 'no-store').json(await likesOf(req, kind, id));
++ });
// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, shareButton, renderVod, renderClip });
@@@ -1378,19 +1429,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
const unlisted = vod.visibility === 'unlisted';
const channelName = vod.display_name || vod.username || null;
-- const localChannel = vod.username ? `/@${vod.username}` : null;
const liveChannel = `${livePublic}/@${vod.username}`;
-- const metaParts = [];
-- // What this video is: a stream recording, the day it was streamed, how long it is, how many people watched it.
-- // The channel is in the byline under the title and the category in the About section, so nothing is said twice.
-- const what = whatItIs({
-- kind: 'recording',
-- streamedAt: streamDate(vod.stream_started_at),
-- publishedAt: vod.created_at,
-- durationSeconds: vod.duration_seconds,
-- views: Number(vod.view_count) || 0,
-- });
-- if (manageable) metaParts.push(visibilityBadge(vod.visibility));
++ const facts = watchFacts(vod.view_count, streamDate(vod.stream_started_at) || vod.created_at, vod.duration_seconds, vod.visibility);
const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const viewAttr = html` data-ovv-view="${livePublic}/api/views/vod/${id}"`;
@@@ -1414,6 +1454,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
if (threadUrl) moreItems.push({ href: threadUrl, icon: 'fa-regular fa-comments', label: 'Discuss on OpenVibe.Community' });
// Chat replay, chapters and the transcript, beside the player.
const side = companion({ kind: 'vod', id, watchId: vod.watch_id || null, chat: chatRows, chapters: mems, transcript: segs, long: Number(vod.duration_seconds) >= 3600 });
++ const likes = await likesOf(req, 'vod', id);
return page(req, res, {
index: !unlisted && !ownOnly && !thin,
noindex: unlisted || ownOnly || thin,
@@@ -1435,28 +1476,17 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}
<div class="ovv-player-wrap"${labelsUi.attrs(labelsUi.inForce(vod), { owner: vod.username, opened: true })}><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))}${avatarAttr(vod.username, config.networkUrl)}${shapeAttr} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video>${pipsTag(pips)}</div>
<div class="ovv-head">
--<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
++${titleLine('Stream recording', vod.title || 'Stream recording')}
${labelsUi.chips(labelsUi.inForce(vod))}
--${what}
<div class="ovv-head-row">
--<div class="ovv-byline">
--${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}" aria-hidden="true" tabindex="-1">${String(channelName).slice(0, 1).toUpperCase()}${live.isUsername(String(vod.username || '')) ? html`<img src="${config.networkUrl}/avatar/${vod.username}" alt="" loading="lazy">` : ''}</a>` : ''}
--<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
--${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
--</div>
--<div class="ovv-actions">${manageable ? html`${editButton('recording')}${deleteAction({ action: `/v/${id}`, what: 'recording' })}` : ''}${saveForm}${partyLink(watchPath, vod.title || 'Stream recording', vod)}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
++${channelRow(vod.username, channelName, vod.follower_count)}
++<div class="ovv-actions-shell"><div class="ovv-actions">${likeButton(likes, 'vod', id)}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${saveForm}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${partyLink(watchPath, vod.title || 'Stream recording', vod)}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${ownOnly ? '' : clipAction(req, id, vod)}${watchMore({ path: watchPath, manageable, kind: 'vod', id, items: moreItems })}</div></div>
</div>
${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
</div>
</div>
${manageable ? ownerPanel('vod', id, vod) : ''}
--<section class="ovv-about" aria-labelledby="h-about">
--<h2 id="h-about" class="ovv-about-title">About</h2>
--${vod.description ? html`<p class="ovv-about-text">${vod.description}</p>` : ''}
--${vod.ai_overview ? html`<p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span> ${vod.ai_overview}</p>` : ''}
--${partNav}
--${channelName ? html`<p class="ovv-about-from"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Streamed live on <a href="${liveChannel}">${channelName}'s OpenVibe.Live channel</a>${vod.stream_category ? html`, in ${String(vod.stream_category).charAt(0).toUpperCase()}${String(vod.stream_category).slice(1)}` : ''}.</p>` : ''}
--</section>
++${watchAbout({ facts, description: vod.description || about, ai: vod.ai_overview, parts: partNav, chapters: mems, path: watchPath, tags: Array.isArray(vod.tags) ? vod.tags : [], from: channelName ? html`<p class="ovv-about-from">Streamed live on <a href="${liveChannel}">${channelName}'s OpenVibe.Live channel</a>${vod.stream_category ? html`, in ${String(vod.stream_category).charAt(0).toUpperCase()}${String(vod.stream_category).slice(1)}` : ''}.</p>` : '' })}
${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}
</div>
<aside class="ovv-col ovv-col--side">
@@@ -1510,6 +1540,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
]);
let threadUrl = null;
try { threadUrl = httpsUrl(cm && cm.thread && cm.thread.url); } catch { threadUrl = null; }
++ const commentRows = cm && Array.isArray(cm.comments) ? cm.comments.filter((c) => c && !c.is_deleted && !c.deleted).slice(0, 20) : null;
const unlisted = clip.visibility === 'unlisted';
// AI clips stay out of search, as on Live: noindex and no structured data.
@@@ -1533,15 +1564,8 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording. OpenVibe.Live did not answer just now, so its title is not shown.</p>`
: !sourceVod
? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording that is not public any more.</p>`
-- : html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from <a href="${fullRecording}">${sourceTitle || 'the recording it came from'}</a>${sourceWho ? html`, ${sourceWho}'s stream recording` : ''}${clipAt ? html`. The clip starts at ${clipAt} in it` : ''}.</p>`;
-- const what = whatItIs({
-- kind: 'clip',
-- label: aiClip ? 'AI clip' : 'Clip',
-- streamedAt: sourceVod && sourceVod !== 'down' ? streamDate(sourceVod.stream_started_at) : null,
-- publishedAt: clip.created_at,
-- durationSeconds: clip.duration_seconds != null ? clip.duration_seconds : clip.duration,
-- views: Number(clip.view_count) || 0,
-- });
++ : html`<p class="ovv-source">Cut from <a href="${fullRecording}">${sourceTitle || 'the recording it came from'}</a>${clipAt ? html` at ${clipAt}` : ''}${sourceWho ? html` · ${sourceWho}'s stream` : ''}</p>`;
++ const facts = watchFacts(clip.view_count, clip.created_at, clip.duration_seconds != null ? clip.duration_seconds : clip.duration, clip.visibility);
const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const viewAttr = html` data-ovv-view="${livePublic}/api/views/clip/${id}"`;
@@@ -1552,6 +1576,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
if (threadUrl) moreItems.push({ href: threadUrl, icon: 'fa-regular fa-comments', label: 'Discuss on OpenVibe.Community' });
// A clip's transcript, when it has one, beside the player as on a recording.
const side = companion({ kind: 'clip', id, watchId: clip.watch_id || null, chat: null, chapters: [], transcript: segs, long: false });
++ const likes = await likesOf(req, 'clip', id);
return page(req, res, {
index: !unlisted && !ownOnly && !aiClip,
@@@ -1575,19 +1600,17 @@ ${savedNotice(req)}${req.query.made === '1' ? notice('Your clip was made. It is
${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv-reload="8"><p><i class="fa-solid fa-scissors" aria-hidden="true"></i> This clip is being cut. It plays here in a moment; the page checks again by itself.</p></div>`
: html`<div class="ovv-player-wrap"${labelsUi.attrs(labelsUi.inForce(clip), { owner: clip.username, opened: true })}><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${avatarAttr(clip.username, config.networkUrl)}${posterAttr(httpsUrl(clip.thumbnail_url))} src="${fileOf(clip) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}
<div class="ovv-head">
--<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
++${titleLine(aiClip ? 'AI clip' : 'Clip', clip.title || 'Clip')}
${labelsUi.chips(labelsUi.inForce(clip))}
--${what}
--${clipCredit}
<div class="ovv-head-row">
--<div class="ovv-byline"><div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
--${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${manageable ? html` · ${visibilityBadge(clip.visibility)}` : ''}</span>` : ''}</div></div>
--<div class="ovv-actions">${manageable ? html`${editButton('clip')}${deleteAction({ action: `/c/${id}`, what: 'clip' })}` : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : partyLink(watchPath, clip.title || 'Clip', clip)}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
++${channelRow(clip.source_streamer_username || clip.username, clip.source_streamer_display_name || clip.display_name || clip.username, clip.follower_count)}
++<div class="ovv-actions-shell"><div class="ovv-actions">${likeButton(likes, 'clip', id)}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${cutting ? '' : partyLink(watchPath, clip.title || 'Clip', clip)}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${fullRecording ? html`<a class="ovv-btn" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${watchMore({ path: watchPath, manageable, kind: 'clip', id, items: moreItems })}</div></div>
</div>
</div>
</div>
${manageable ? ownerPanel('clip', id, clip) : ''}
--${clip.ai_overview ? html`<section class="ovv-about" aria-labelledby="h-about"><h2 id="h-about" class="ovv-about-title">About</h2><p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span> ${clip.ai_overview}</p></section>` : ''}
++${watchAbout({ facts, description: clip.description || clip.ai_overview || attribution, source: clipCredit, madeBy, ai: clip.ai_overview, tags: Array.isArray(clip.tags) ? clip.tags : [] })}
++${commentRows ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}
</div>
<aside class="ovv-col ovv-col--side">
${side || ''}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 631d980..9859d5c 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -39,11 +39,16 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
const t = await boot({ env: { MEDIAHUB_EVENTS_SECRET: SECRET } });
const kim = t.network.addUser('kim');
const sam = t.network.addUser('sam');
++ await t.ctx.s.db.exec('INSERT INTO video_likes (kind, item_id, owner, created_at) VALUES ($1, $2, $3, $4), ($1, $2, $5, $4)', ['vod', 101, `user:${kim.subject}`, '2026-11-09T00:00:00Z', `user:${sam.subject}`]);
const file = (await upload(t, kim, bytes(300, 'kim'), { name: 'kim.bin' })).json().file;
const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
await t.get(`/s/${slug}/report`, { as: sam, form: { reason: 'malware', note: 'nope' }, headers: SAME });
const sams = (await upload(t, sam, bytes(300, 'sam'), { name: 'sam.bin' })).json().file;
++ await t.ctx.s.db.exec('INSERT INTO mh_radio_tracks (id, file_id, owner, title, artist, source_kind, licence, licence_url) VALUES ($1, $2, $3, $4, $5, $6, $7, $8), ($9, $10, $11, $12, $13, $6, $7, $8)', [
++ 'radio_kim', file.id, `user:${kim.subject}`, 'Kim track', 'Kim', 'creator', 'creator_grant', 'https://openvibe.radio/grant',
++ 'radio_sam', sams.id, `user:${sam.subject}`, 'Sam track', 'Sam',
++ ]);
// And kim files one against sam's file, so each has a report of their own in the export.
const samSlug = (await share(t, sam, { file_id: sams.id, hours: 24 })).json().share.slug;
await t.get(`/s/${samSlug}/report`, { as: kim, form: { reason: 'other', note: 'kim says' }, headers: SAME });
@@@ -80,7 +85,9 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
assert.ok(files, 'a files.json part');
assert.strictEqual(files.content.length, 1);
assert.strictEqual(files.content[0].id, file.id);
-- assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['files.json', 'reports.json', 'shares.json', 'usage.json'], 'and the rest of the person\'s own data');
++ assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['files.json', 'radio_tracks.json', 'reports.json', 'shares.json', 'usage.json', 'video_likes.json'], 'and the rest of the person\'s own data');
++ assert.deepStrictEqual(part.files.find((f) => f.name === 'radio_tracks.json').content.map((track) => track.id), ['radio_kim'], 'a person can export their radio publications');
++ assert.deepStrictEqual(part.files.find((f) => f.name === 'video_likes.json').content.map((like) => like.item_id), [101], 'a person can export their likes');
assert.ok(part.files.find((f) => f.name === 'shares.json').content.some((s) => s.slug === slug));
assert.strictEqual(part.files.find((f) => f.name === 'reports.json').content.length, 1, 'the report kim filed');
assert.ok(!JSON.stringify(part).includes(sams.id), 'nothing of sam\'s');
@@@ -105,6 +112,10 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
assert.strictEqual(confirmation.subject, kim.subject);
assert.ok(confirmation.erased.mh_files >= 1, JSON.stringify(confirmation));
assert.ok(confirmation.erased.mh_shares >= 1, JSON.stringify(confirmation));
++ assert.strictEqual(confirmation.erased.mh_radio_tracks, 1, 'radio publications are erased with the account');
++ assert.strictEqual(confirmation.erased.video_likes, 1, 'likes are erased with the account');
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*) FROM mh_radio_tracks WHERE owner = $1', [`user:${sam.subject}`])), 1, 'another person\'s radio publication remains');
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*) FROM video_likes WHERE owner = $1', [`user:${sam.subject}`])), 1, 'another person\'s like remains');
// Sam's drive is untouched.
assert.strictEqual((await t.get(`/api/v1/files/${sams.id}`, { as: sam })).status, 200);
ddiff --git a/test/labels-ui.test.js b/test/labels-ui.test.js
iindex 5d3c736..9799458 100644
---- a/test/labels-ui.test.js
++++ b/test/labels-ui.test.js
@@@ -136,7 +136,7 @@ function hreq(base, host, path, { method = 'GET', cookie, form, body, type, same
const r = await video('/v/301');
assert.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.ok(r.text.includes('<div class="ovv-player-wrap" data-ov-labels="nudity,18+" data-ov-labels-owner="@kim" data-ov-labels-opened>'), 'the player is the labelled thing');
-- assert.ok(/<h1 class="ovv-title">Kim labelled<\/h1>\s*<ul class="ovk-labels" aria-label="Content labels"><li class="ovk-label" data-label="nudity">Nudity<\/li><li class="ovk-label ovk-label--adult" data-label="18\+">18\+<\/li><\/ul>/.test(r.text), 'the chips under the title');
++ assert.ok(/<h1 class="ovv-title">Kim labelled<\/h1>(?:<\/div>)?\s*<ul class="ovk-labels" aria-label="Content labels"><li class="ovk-label" data-label="nudity">Nudity<\/li><li class="ovk-label ovk-label--adult" data-label="18\+">18\+<\/li><\/ul>/.test(r.text), 'the chips under the title');
assert.ok(!r.text.includes('Flashing lights'), 'a suggestion is never shown to viewers');
assert.ok(!r.text.includes('name="labels"'), 'no picker for a viewer');
});
ddiff --git a/test/video-outcomes.test.js b/test/video-outcomes.test.js
iindex d77e2d6..69a7b11 100644
---- a/test/video-outcomes.test.js
++++ b/test/video-outcomes.test.js
@@@ -113,34 +113,29 @@ function startLive() {
await check('outcome 1: a recording says what it is: a stream recording, the day, the length, the views', async () => {
const r = await get('/v/501');
assert.strictEqual(r.status, 200);
-- const what = /<p class="ovv-what">([\s\S]*?)<\/p>/.exec(r.text);
-- assert.ok(what, 'the page says what the video is');
-- const line = what[1];
-- assert.ok(line.includes('Stream recording'), line);
-- assert.ok(line.includes('Streamed on') && line.includes('Oct 12, 2026'), 'the day it was streamed: ' + line);
-- assert.ok(line.includes('13 minutes'), 'its length, in words: ' + line);
++ assert.ok(r.text.includes('<span class="ovv-kind">Stream recording</span>'), 'the title says what the video is');
++ const line = /<div class="ovv-about-facts">([\s\S]*?)<\/div>/.exec(r.text)[1];
++ assert.ok(line.includes('13 min'), 'its length: ' + line);
assert.ok(line.includes('1,204 views'), 'its views: ' + line);
-- assert.ok(/<time datetime="2026-10-12T09:30:00.000Z">/.test(line), 'the day in a machine-readable time: ' + line);
++ assert.ok(r.text.includes('Oct 12, 2026'), 'the stream date remains on the page');
// The channel is in the byline under it, and the category in About: nothing is said twice.
-- assert.ok(r.text.includes('class="ovv-channel-name"') && r.text.includes('@Streamer'), 'the channel');
++ assert.ok(r.text.includes('class="ovv-channel-name"') && r.text.includes('>Streamer</a>'), 'the channel');
assert.ok(r.text.includes("Streamed live on") && r.text.includes('in Gaming'), 'the category is in About');
});
await check('outcome 1: a recording with no stream time says when it was published, and guesses nothing', async () => {
const r = await get('/v/502');
assert.strictEqual(r.status, 200);
-- const line = /<p class="ovv-what">([\s\S]*?)<\/p>/.exec(r.text)[1];
-- assert.ok(line.includes('Published on') && line.includes('Oct 12, 2026'), line);
-- assert.ok(!line.includes('Streamed on'), 'no day it was streamed: Live never said one');
-- assert.ok(!line.includes('views'), 'no views: Live counted none');
-- assert.ok(line.includes('45 seconds'), 'the length it does have: ' + line);
++ const line = /<div class="ovv-about-facts">([\s\S]*?)<\/div>/.exec(r.text)[1];
++ assert.ok(!r.text.includes('Streamed on'), 'no day it was streamed: Live never said one');
++ assert.ok(line.includes('45 s'), 'the length it does have: ' + line);
});
await check('outcome 1: a clip says what it is, and an upload says it is an upload', async () => {
const clip = await get('/c/601');
-- const line = /<p class="ovv-what">([\s\S]*?)<\/p>/.exec(clip.text)[1];
-- assert.ok(line.includes('Clip</span>') && !line.includes('AI clip'), 'a clip: ' + line);
-- assert.ok(line.includes('30 seconds') && line.includes('20 views'), line);
++ assert.ok(clip.text.includes('<span class="ovv-kind">Clip</span>') && !clip.text.includes('<span class="ovv-kind">AI clip</span>'), 'a clip');
++ const line = /<div class="ovv-about-facts">([\s\S]*?)<\/div>/.exec(clip.text)[1];
++ assert.ok(line.includes('30 s') && line.includes('20 views'), line);
// The upload's own row: the day it was uploaded, its length and its views.
const s = t.ctx.s;
const id = 'vup_01J0000000000000000000000B';
@@@ -163,8 +158,8 @@ function startLive() {
assert.ok(line.includes('Cut from'), line);
assert.ok(line.includes('A long recording'), 'the title of the recording it came from: ' + line);
assert.ok(line.includes('href="/v/501?t=65"'), 'a link back to the recording, at the clip\'s moment: ' + line);
-- assert.ok(line.includes("Streamer's stream recording"), 'whose recording it was: ' + line);
-- assert.ok(line.includes('starts at 1:05 in it'), 'where the clip starts in it: ' + line);
++ assert.ok(line.includes("Streamer's stream"), 'whose recording it was: ' + line);
++ assert.ok(line.includes('at 1:05'), 'where the clip starts in it: ' + line);
});
await check('outcome 2: a clip whose recording is not public says so, and names nothing it cannot know', async () => {
ddiff --git a/test/video-owner-delete.test.js b/test/video-owner-delete.test.js
iindex 6766e63..1d7723c 100644
---- a/test/video-owner-delete.test.js
++++ b/test/video-owner-delete.test.js
@@@ -1,9 +1,7 @@
'use strict';
/**
-- * The owner deletes a recording or clip from the action row (a streamer, 11-08: deleting a test stream's recording
-- * "wasn't immediately obvious": it was Edit, then a fold inside the edit panel, then a checkbox). Delete sits beside
-- * Edit as a small question with one button; the form carries confirm=1 for the route's check; the edit panel no
-- * longer hides a second delete.
++ * The owner deletes a recording or clip from the More menu. Delete sits beside Edit there as a small question with
++ * one button; the form carries confirm=1 for the route's check; the edit panel no longer hides a second delete.
*
* node test/video-owner-delete.test.js
*/
@@@ -13,14 +11,13 @@ const path = require('path');
const { deleteAction } = require('../server/video/watch-ui');
const out = String(deleteAction({ action: '/v/42', what: 'recording' }));
--assert.match(out, /<details class="ovv-pop ovv-deleter" data-ovv-menu>/, 'a small menu in the action row');
++assert.match(out, /<details class="ovv-pop ovv-deleter" data-ovv-menu>/, 'a small confirmation menu');
assert.match(out, /Delete this recording\?/, 'it asks first');
assert.match(out, /<form method="post" action="\/v\/42\/delete"><input type="hidden" name="confirm" value="1">/, 'the form confirms itself');
assert.match(out, /Delete for good<\/button>/, 'one button says what happens');
const pages = fs.readFileSync(path.join(__dirname, '..', 'server', 'video', 'pages.js'), 'utf8');
--assert.ok(pages.includes("deleteAction({ action: `/v/${id}`, what: 'recording' })"), 'the recording page offers it beside Edit');
--assert.ok(pages.includes("deleteAction({ action: `/c/${id}`, what: 'clip' })"), 'the clip page too');
++assert.ok(pages.includes('deleteAction({ action: `/${kind === \'clip\' ? \'c\' : \'v\'}/${id}`'), 'both watch pages offer Delete through More');
const panel = pages.slice(pages.indexOf('function ownerPanel('), pages.indexOf('// The shell\'s navbar/footer scripts'));
assert.ok(!/ovv-danger|I want to delete it for good/.test(panel), 'the edit panel no longer hides a second delete');
--console.log('video: Delete is beside Edit, one question, one button');
++console.log('video: Delete is in More, one question, one button');
ddiff --git a/test/video-owner.test.js b/test/video-owner.test.js
iindex 2bef878..bbabf66 100644
---- a/test/video-owner.test.js
++++ b/test/video-owner.test.js
@@@ -87,20 +87,36 @@ function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {
const mine = await vreq(t.base, '/v/101', { cookie: kimCookie });
assert.strictEqual(mine.status, 200);
assert.ok(mine.text.includes('Edit this recording') && mine.text.includes('action="/v/101/manage"'), 'the owner panel');
-- // 2026-10-27: Edit is first in the action row and opens the panel under the player; who can see it is three
-- // choices; the meta line tells the owner which it is now.
-- assert.ok(/<div class="ovv-actions"><a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage"/.test(mine.text), 'Edit leads the actions');
-- assert.ok(mine.text.indexOf('id="manage"') < mine.text.indexOf('id="h-about"'), 'the panel sits under the player, before About');
++ // Owner tools live in More, with the panel under the player.
++ assert.ok(/class="ovv-more-actions"[\s\S]*class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage"/.test(mine.text), 'Edit is in More');
++ assert.ok(mine.text.indexOf('id="manage"') < mine.text.indexOf('id="about"'), 'the panel sits under the player, before About');
assert.strictEqual((mine.text.match(/<input type="radio" name="visibility" value="(public|unlisted|private)"/g) || []).length, 3, 'three visibility choices');
assert.ok(/class="ovv-vis-badge ovv-vis-badge--/.test(mine.text), 'the owner sees who can see it');
assert.ok(mine.text.includes('value="Kim stream"') && mine.text.includes('<input type="radio" name="visibility" value="public" checked>'), 'prefilled');
const other = await vreq(t.base, '/v/101', { cookie: samCookie });
assert.ok(!other.text.includes('Edit this recording'), 'not for someone else');
-- assert.ok(!/class="ovv-vis-badge/.test(other.text) && !other.text.includes('ovv-btn--edit'), 'no badge or Edit for someone else');
++ assert.ok(!other.text.includes('ovv-btn--edit'), 'no Edit for someone else');
const anon = await vreq(t.base, '/v/101');
assert.ok(!anon.text.includes('Edit this recording'), 'not signed out');
});
++ await check('recording likes are counted per signed-in person', async () => {
++ const path = '/watch/like';
++ const body = { kind: 'vod', id: '101' };
++ assert.strictEqual((await vreq(t.base, path, { method: 'POST', form: body })).status, 401);
++ let r = await vreq(t.base, path, { method: 'POST', cookie: kimCookie, form: body });
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(JSON.parse(r.text), { count: 1, liked: true });
++ r = await vreq(t.base, '/v/101', { cookie: samCookie });
++ assert.match(r.text, /class="ovv-like-count">1<\/span>/);
++ r = await vreq(t.base, path, { method: 'POST', cookie: samCookie, form: body });
++ assert.deepStrictEqual(JSON.parse(r.text), { count: 2, liked: true });
++ r = await vreq(t.base, path, { method: 'POST', cookie: kimCookie, form: body });
++ assert.deepStrictEqual(JSON.parse(r.text), { count: 1, liked: false });
++ assert.strictEqual((await vreq(t.base, path, { method: 'POST', cookie: kimCookie, form: { kind: 'clip', id: '999' } })).status, 404);
++ assert.strictEqual((await vreq(t.base, path, { method: 'POST', cookie: kimCookie, form: body, same: false })).status, 403);
++ });
++
await check('an edit is made on Live as the person, then the page shows it', async () => {
await vreq(t.base, '/v/101'); // warm the read cache
const r = await vreq(t.base, '/v/101/manage', { method: 'POST', cookie: kimCookie, form: { title: 'Kim renamed', description: 'Second', visibility: 'unlisted' } });
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex a6b2441..3e1dc5a 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -60,6 +60,7 @@ function startLive() {
};
const publicClip = {
id: 201, vod_id: 101, title: 'A clip', start_time: 5, end_time: 15, duration_seconds: 10,
++ created_at: '2026-10-26T11:00:00.000Z', view_count: 49,
playback_url: 'https://openvibe.media/c/201', thumbnail_url: null, visibility: 'public',
auto_generated: false, username: 'clipper', display_name: 'Clipper',
source_streamer_username: 'streamer', source_streamer_display_name: 'Streamer',
@@@ -374,6 +375,9 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('Chapters'));
assert.ok(r.text.includes('Transcript'));
assert.ok(r.text.includes('A public overview'));
++ assert.ok(r.text.includes('<details class="ovv-about" id="about">') && r.text.includes('<span class="ovv-about-preview">A public stream</span>'), 'description has a collapsed preview');
++ assert.ok(/class="ovv-about-facts"><span>100 views<\/span>.*ovv-vis-badge.*<span>· 45 s<\/span>/.test(r.text), 'views, visibility and short duration lead the description');
++ assert.ok(r.text.includes('<p class="ovv-about-text">A public stream</p>') && r.text.includes('ovv-about-toggle-less'), 'full description is in the HTML for expansion');
assert.ok(r.text.includes('/@streamer'));
assert.ok(!r.text.includes('Edit this recording'), 'owner tools only for the channel (video-owner.test.js)');
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
@@@ -410,6 +414,8 @@ function countOccurrences(haystack, needle) {
assert.strictEqual(cld.duration, 'PT10S');
assert.ok(r.text.includes('src="https://openvibe.media/c/201"'), 'the clip playback url');
assert.ok(r.text.includes('Made by Clipper'), 'attributed to the person who made it');
++ assert.ok(r.text.includes('<span>49 views</span>') && r.text.includes('<span>· 10 s</span>'), 'clip stats use the short description line');
++ assert.ok(/<summary>[\s\S]*Cut from <a href="\/v\/101\?t=5">/.test(r.text), 'the source link is in the short description');
assert.ok(r.text.includes('Full recording</a>') && r.text.includes('title="Watch the recording it was cut from, from this moment"'));
assert.ok(r.text.includes('/v/101?t=5'), 'the full recording link carries the clip start');
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));