openvibers/openvibe.mediahub Commits

Watch pages reorganised: one action row (Save, Clip, Download, Share, More), chat replay, chapters and transcript as tabs beside the player with their downloads in a corner menu, About with the stream's parts, Manage folded away (#143)

OP OpenVibers committed 18 days ago · b715845b17 · 11 files, +552 −103

Owner, 2026-10-26: "perform a review on this whole page and re organize it nicely and more user friendly and intuitive";
the per-panel download links "are ugly and should be moved to a nice sleek button/menu … into the corner".

Changes

ddiff --git a/public/css/video.css b/public/css/video.css
iindex 2e2c2d8..f86b5fd 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -297,17 +297,148 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-owner-delete { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 10px; }
.ovv-watch--clip .ovv-owner { max-width: 640px; }
--/* Make a clip: a disclosure in the side column (any signed-in viewer). */
--.ovv-clipper > summary { list-style: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
--.ovv-clipper > summary::-webkit-details-marker { display: none; }
--.ovv-clipper > summary h2 { margin: 0; }
--.ovv-clipper > summary::after { content: '+'; font-size: 1.3rem; line-height: 1; color: var(--ovv-dim); }
--.ovv-clipper[open] > summary::after { content: '−'; }
--.ovv-clipper > summary:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 4px; border-radius: 6px; }
++/* Make a clip: the Clip button's form in the action row (any signed-in viewer). */
.ovv-clip-times { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.ovv-clip-now { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }
.ovv-clip-now[hidden] { display: none; }
++/* ── Watch page layout (2026-10-26) ─────────────────────────────────────────────────────────────────────────────
++ Left: the player, its title, who made it and one row of actions (Save, Clip, Download, Share, More), then About,
++ the owner's tools and comments. Right: chat replay, chapters and the transcript as tabs, the size of the player,
++ then up next and the stream's clips. One column on a narrower screen, in the order people use them: the player and
++ its actions, the tabs, up next, About, clips, comments (.ovv-col becomes display: contents and the pieces take an
++ order). Server-rendered and complete without JavaScript: the tabs then list one after another. */
++.ovv-watch { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 22px 28px; align-items: start; overflow-x: clip; } /* the player's glow and a closed menu's box never widen the page */
++.ovv-watch--clip:not(:has(.ovv-col--side > *)) { grid-template-columns: minmax(0, 960px); justify-content: center; }
++.ovv-col { display: grid; gap: 18px; min-width: 0; align-content: start; }
++.ovv-col > .ovv-panel, .ovv-col > .ovv-about { margin-top: 0; }
++.ovv-head { margin-top: 14px; }
++.ovv-head .ovv-title { margin: 0 0 12px; }
++.ovv-head-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; }
++.ovv-head .ovv-byline { flex: 1 1 240px; padding: 0; border: 0; min-width: 0; }
++.ovv-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
++.ovv-btn--icon { width: 38px; padding: 0; justify-content: center; }
++.ovv-btn[hidden] { display: none; }
++
++/* A button that opens a small menu or form (Save, Clip, More): a <details>; video.js closes it on a click elsewhere. */
++.ovv-pop { position: relative; }
++.ovv-pop > summary { list-style: none; cursor: pointer; }
++.ovv-pop > summary::-webkit-details-marker { display: none; }
++.ovv-pop[open] > summary { border-color: var(--ovv-accent); background: var(--bg-hover, #1c2a44); }
++.ovv-pop-panel, .ovv-save[open] > .ovv-save-form { position: absolute; z-index: 40; top: calc(100% + 8px); left: 0; width: min(360px, calc(100vw - 32px)); box-sizing: border-box; margin: 0; padding: 14px; border: 1px solid var(--ovv-line-strong); border-radius: 14px; background: var(--bg-secondary, #0f1729); box-shadow: 0 18px 48px rgba(0, 0, 0, .45); }
++.ovv-pop--up .ovv-pop-panel, .ovv-pop--up.ovv-save[open] > .ovv-save-form { top: auto; bottom: calc(100% + 8px); }
++.ovv-more-menu .ovv-pop-panel { left: auto; right: 0; width: 290px; padding: 6px; }
++.ovv-pop-title { margin: 0 0 10px; font-weight: 700; }
++.ovv-pop-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
++.ovv-pop-list a { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; gap: 10px; padding: 9px 10px; border-radius: 9px; color: inherit !important; text-decoration: none; font-weight: 700; font-size: .9rem; }
++.ovv-pop-list a:hover, .ovv-pop-list a:focus-visible { background: var(--bg-hover, #1c2a44); }
++.ovv-pop-list i { text-align: center; color: var(--accent-light, var(--ovv-accent)); }
++.ovv-pop-list small { display: block; font-weight: 500; font-size: .76rem; color: var(--ovv-dim); }
++
++/* Chat replay, chapters and the transcript beside the player (server/video/watch-ui.js companion). */
++.ovv-companion { display: flex; flex-direction: column; min-height: 0; border: 1px solid var(--ovv-line); border-radius: 14px; background: var(--ovv-card); overflow: hidden; }
++.ovv-companion--tabs, .ovv-companion--one { height: min(680px, calc(100vh - 110px)); height: min(680px, calc(100dvh - 110px)); }
++.ovv-tabs { display: flex; gap: 2px; padding: 4px 8px 0; border-bottom: 1px solid var(--ovv-line); overflow-x: auto; scrollbar-width: none; }
++.ovv-tabs[hidden] { display: none; }
++.ovv-tabs [role=tab] { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; gap: 6px; padding: 11px 12px 9px; border-bottom: 2px solid transparent; font-size: .9rem; font-weight: 700; color: var(--ovv-dim); cursor: pointer; white-space: nowrap; }
++.ovv-tabs [role=tab]:hover { color: var(--text-primary, #e6edf7); }
++.ovv-tabs [role=tab][aria-selected="true"] { color: var(--text-primary, #e6edf7); border-bottom-color: var(--ovv-accent); }
++.ovv-tabs [role=tab]:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: -2px; border-radius: 8px 8px 0 0; }
++.ovv-tab-count { padding: 1px 7px; border-radius: 999px; background: color-mix(in srgb, var(--ovv-accent) 14%, transparent); color: var(--accent-light, var(--ovv-accent)); font-size: .72rem; }
++.ovv-tp { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
++.ovv-tp[hidden] { display: none; }
++.ovv-companion:not(.ovv-companion--tabs) .ovv-tp + .ovv-tp { border-top: 1px solid var(--ovv-line); }
++.ovv-companion:not(.ovv-companion--tabs):not(.ovv-companion--one) .ovv-tp-body { max-height: 22rem; }
++.ovv-tp-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 10px 8px 14px; }
++.ovv-tp-heading { display: grid; gap: 1px; min-width: 0; }
++.ovv-tp-title { margin: 0; font-size: 1rem; }
++.ovv-companion--tabs .ovv-tp-title { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++.ovv-tp-note { font-size: .78rem; color: var(--ovv-faint); }
++.ovv-tp-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0 14px 12px; overscroll-behavior: contain; }
++.ovv-tp-more, .ovv-tp-empty { margin: 8px 0 0; font-size: .8rem; color: var(--ovv-faint); }
++.ovv-tp-empty[hidden] { display: none; }
++.ovv-tp-search { display: flex; align-items: center; gap: 8px; margin: 0 14px 8px; padding: 0 10px; border: 1px solid var(--ovv-line-strong); border-radius: 9px; background: var(--bg-input, var(--bg-secondary, #0f1729)); color: var(--ovv-faint); }
++.ovv-tp-search[hidden] { display: none; }
++.ovv-tp-search:focus-within { border-color: var(--ovv-accent); }
++.ovv-tp-search input { all: unset; flex: 1 1 auto; min-width: 0; min-height: 34px; font-size: .88rem; color: var(--text-primary, #e6edf7); }
++.ovv-tp-body { align-content: start; }
++.ovv-chapter-list { list-style: none; margin: 0; padding: 0 8px 10px; display: grid; gap: 2px; align-content: start; }
++.ovv-chat-body, .ovv-transcript-body { align-content: start; }
++.ovv-chapter-list a { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: baseline; padding: 8px; border-radius: 8px; color: inherit; text-decoration: none; font-size: .9rem; line-height: 1.4; }
++.ovv-chapter-list a:hover, .ovv-chapter-list a:focus-visible { background: var(--bg-hover, #1c2a44); }
++.ovv-chapter-list a.is-current { background: color-mix(in srgb, var(--ovv-accent) 14%, transparent); }
++.ovv-chapter-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
++.ovv-line { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; margin: 0; padding: 4px 6px; border-radius: 7px; line-height: 1.5; font-size: .92rem; }
++.ovv-line[hidden] { display: none; }
++.ovv-line.is-current { background: color-mix(in srgb, var(--ovv-accent) 13%, transparent); }
++.ovv-chat-row.is-current { background: none; }
++
++/* A panel's own download menu: a quiet icon in its corner. */
++.ovv-mini { position: relative; flex: none; }
++.ovv-mini > summary { list-style: none; }
++.ovv-mini > summary::-webkit-details-marker { display: none; }
++.ovv-mini-btn { display: grid; place-items: center; width: 34px; height: 34px; box-sizing: border-box; border-radius: 9px; border: 1px solid transparent; color: var(--ovv-dim); cursor: pointer; }
++.ovv-mini-btn:hover, .ovv-mini-btn:focus-visible, .ovv-mini[open] > .ovv-mini-btn { border-color: var(--ovv-line-strong); background: var(--bg-hover, #1c2a44); color: var(--text-primary, #e6edf7); }
++.ovv-mini-btn:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: 1px; }
++.ovv-mini-menu { position: absolute; z-index: 45; top: calc(100% + 6px); right: 0; width: 264px; box-sizing: border-box; padding: 6px; border: 1px solid var(--ovv-line-strong); border-radius: 12px; background: var(--bg-secondary, #0f1729); box-shadow: 0 18px 44px rgba(0, 0, 0, .45); }
++.ovv-mini--up .ovv-mini-menu { top: auto; bottom: calc(100% + 6px); }
++.ovv-mini-head { margin: 4px 8px 6px; font-size: .78rem; font-weight: 700; color: var(--ovv-dim); }
++.ovv-mini-item { all: unset; box-sizing: border-box; display: grid; grid-template-columns: 46px minmax(0, 1fr); align-items: center; gap: 10px; width: 100%; padding: 7px 8px; border-radius: 9px; color: inherit !important; text-decoration: none; cursor: pointer; }
++.ovv-mini-item[hidden] { display: none; }
++.ovv-mini-item:hover, .ovv-mini-item:focus-visible { background: var(--bg-hover, #1c2a44); }
++.ovv-mini-item:focus-visible { outline: 2px solid var(--ovv-accent); outline-offset: -2px; }
++.ovv-mini-item > i { text-align: center; color: var(--accent-light, var(--ovv-accent)); font-size: 1rem; }
++.ovv-mini-item b { display: block; font-size: .88rem; }
++.ovv-mini-item small { display: block; font-size: .75rem; color: var(--ovv-dim); }
++.ovv-mini-ext { display: grid; place-items: center; height: 26px; border-radius: 7px; border: 1px solid var(--ovv-line-strong); font-size: .68rem; font-weight: 800; letter-spacing: .5px; color: var(--accent-light, var(--ovv-accent)); }
++
++/* About: the description, the AI summary, the stream's parts and where it was streamed. */
++.ovv-about { display: grid; gap: 8px; padding: 14px 16px; border: 1px solid var(--ovv-line); border-radius: 14px; background: var(--ovv-card); }
++.ovv-about-title { margin: 0; font-size: .95rem; }
++.ovv-about-text { margin: 0; line-height: 1.6; white-space: pre-line; overflow-wrap: anywhere; }
++.ovv-ai-chip { display: inline-block; margin-right: 6px; padding: 1px 8px; border-radius: 999px; background: color-mix(in srgb, var(--ovv-accent) 14%, transparent); color: var(--accent-light, var(--ovv-accent)); font-size: .72rem; font-weight: 700; vertical-align: 1px; }
++.ovv-about-parts, .ovv-about-from { margin: 0; font-size: .88rem; color: var(--ovv-dim); }
++.ovv-about-parts i, .ovv-about-from i { margin-right: 6px; color: var(--ovv-faint); }
++.ovv-about-parts a, .ovv-about-from a { font-weight: 700; }
++
++/* A panel's heading with a link on the right (Comments → Discuss on OpenVibe.Community). */
++.ovv-panel-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 12px; margin-bottom: 4px; }
++.ovv-panel-head h2 { margin: 0; }
++.ovv-panel-link { font-size: .85rem; font-weight: 700; }
++.ovv-panel-link i { font-size: .72rem; margin-left: 3px; }
++.ovv-count { font-size: .8rem; font-weight: 600; color: var(--ovv-dim); }
++.ovv-comments-empty a { font-weight: 700; }
++
++/* The owner's tools, folded away until opened (More → Manage, or its own heading). */
++.ovv-owner > summary { list-style: none; cursor: pointer; display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 10px; }
++.ovv-owner > summary::-webkit-details-marker { display: none; }
++.ovv-owner > summary h2 { margin: 0; font-size: 1rem; }
++.ovv-owner > summary::after { content: '+'; margin-left: auto; font-size: 1.2rem; line-height: 1; color: var(--ovv-dim); }
++.ovv-owner[open] > summary::after { content: '−'; }
++.ovv-owner > summary .ovv-fine { margin: 0; }
++
++@media (max-width: 1000px) {
++ .ovv-watch, .ovv-watch--clip:not(:has(.ovv-col--side > *)) { grid-template-columns: minmax(0, 1fr); gap: 16px; }
++ .ovv-col { display: contents; }
++ .ovv-stage { order: 1; }
++ .ovv-companion { order: 2; }
++ .ovv-upnext { order: 3; }
++ .ovv-about { order: 4; }
++ .ovv-clips { order: 5; }
++ .ovv-comments { order: 6; }
++ .ovv-owner { order: 7; }
++ .ovv-companion--tabs, .ovv-companion--one { height: min(560px, 68vh); height: min(560px, 68dvh); }
++}
++@media (max-width: 600px) {
++ /* The actions in one row that scrolls sideways; their menus open as sheets from the bottom (video.js dims the page). */
++ .ovv-actions { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin: 0 -16px; padding: 2px 16px; width: calc(100% + 32px); }
++ .ovv-actions::-webkit-scrollbar { display: none; }
++ .ovv-actions .ovv-btn { white-space: nowrap; }
++ .ovv-pop-panel, .ovv-save[open] > .ovv-save-form, .ovv-more-menu .ovv-pop-panel { position: fixed; top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 82vh; max-height: 82dvh; overflow-y: auto; border-radius: 16px; padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }
++ .ovv-pop--js[open]::before { content: ''; position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, .55); }
++ .ovv-head .ovv-title { font-size: 1.2rem; }
++}
++
/* A clip Media is still cutting: the player's place, with a note. */
.ovv-cutting { aspect-ratio: 16 / 9; display: grid; place-items: center; padding: 24px; text-align: center; background: var(--ovv-card); color: var(--ovv-dim); }
.ovv-cutting p { margin: 0; max-width: 40ch; }
ddiff --git a/public/js/video.js b/public/js/video.js
iindex e34cc6f..ccb1571 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -104,63 +104,215 @@
return h ? h + ':' + String(m).padStart(2, '0') + ':' + s : m + ':' + s;
}
-- // The Download menu (a <details>, usable without this): it closes on a click elsewhere, on Escape, on its close
-- // button and once a format is chosen, and stays inside the window; on a phone it is a sheet over a dimmed page.
-- // "Copy for a YouTube description" copies the chapters' text where the browser lets a page write the clipboard.
-- function downloads() {
-- const menus = Array.prototype.slice.call(document.querySelectorAll('[data-ovv-dl]'));
-- const close = function (dl, focus) {
-- if (!dl.open) return;
-- dl.open = false;
-- if (focus) dl.querySelector('summary').focus();
++ // The page's menus (Download, Save, Clip, More and each panel's corner menu): <details>, usable without this. They
++ // close on a click elsewhere, on Escape, on a close button and once a file is chosen; opening one closes the
++ // others; each stays inside the window (above its button when only that fits). On a phone the action row's menus
++ // are sheets from the bottom over a dimmed page; a panel's corner menu stays a small popover.
++ function menus() {
++ const all = Array.prototype.slice.call(document.querySelectorAll('[data-ovv-dl], [data-ovv-menu]'));
++ if (!all.length) return;
++ const panelOf = function (d) { return d.querySelector(':scope > :not(summary)'); };
++ const sheet = function (d) { return window.innerWidth <= 600 && !d.classList.contains('ovv-mini'); };
++ const close = function (d, focus) {
++ if (!d.open) return;
++ d.open = false;
++ if (focus) d.querySelector('summary').focus();
};
-- menus.forEach(function (dl) {
-- dl.classList.add('ovv-dl--js');
-- const menu = dl.querySelector('.ovv-dl-menu');
-- dl.addEventListener('toggle', function () {
-- menu.style.left = '';
-- dl.classList.remove('ovv-dl--up');
-- if (!dl.open || window.innerWidth <= 600) return;
-- const over = menu.getBoundingClientRect().right - (document.documentElement.clientWidth - 12);
-- if (over > 0) menu.style.left = -over + 'px';
-- // Below the button when it fits; above when only that fits; else below, scrolled into view.
-- const button = dl.querySelector('summary').getBoundingClientRect();
-- const tall = menu.getBoundingClientRect().height + 16;
++ all.forEach(function (d) {
++ d.classList.add(d.classList.contains('ovv-dl') ? 'ovv-dl--js' : d.classList.contains('ovv-mini') ? 'ovv-mini--js' : 'ovv-pop--js');
++ const panel = panelOf(d);
++ if (!panel) return;
++ d.addEventListener('toggle', function () {
++ panel.style.transform = '';
++ d.classList.remove('ovv-dl--up', 'ovv-pop--up', 'ovv-mini--up');
++ if (!d.open) return;
++ all.forEach(function (o) { if (o !== d && !o.contains(d)) close(o, false); });
++ if (sheet(d)) 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)';
++ else if (r.left < 12) panel.style.transform = 'translateX(' + Math.round(12 - r.left) + 'px)';
++ const button = d.querySelector('summary').getBoundingClientRect();
++ const tall = r.height + 16;
const below = window.innerHeight - button.bottom;
-- if (tall > below && button.top > below && button.top >= tall) dl.classList.add('ovv-dl--up');
-- else if (tall > below) menu.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
++ const up = d.classList.contains('ovv-dl') ? 'ovv-dl--up' : d.classList.contains('ovv-mini') ? 'ovv-mini--up' : 'ovv-pop--up';
++ if (tall > below && button.top > below && button.top >= tall) d.classList.add(up);
++ else if (tall > below) panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
-- const x = dl.querySelector('[data-ovv-dl-close]');
-- if (x) x.addEventListener('click', function () { close(dl, true); });
-- menu.addEventListener('click', function (e) {
-- if (e.target.closest('a[download]')) setTimeout(function () { close(dl, false); }, 150);
++ const x = d.querySelector('[data-ovv-dl-close]');
++ if (x) x.addEventListener('click', function () { close(d, true); });
++ panel.addEventListener('click', function (e) {
++ if (e.target.closest('a[download], a[href^="#"]')) setTimeout(function () { close(d, false); }, 150);
});
});
-- if (menus.length) {
-- document.addEventListener('click', function (e) {
-- menus.forEach(function (dl) {
-- if (dl.open && !dl.querySelector('.ovv-dl-menu').contains(e.target) && !dl.querySelector('summary').contains(e.target)) close(dl, false);
-- });
-- });
-- document.addEventListener('keydown', function (e) {
-- if (e.key === 'Escape') menus.forEach(function (dl) { close(dl, true); });
++ document.addEventListener('click', function (e) {
++ all.forEach(function (d) {
++ const panel = panelOf(d);
++ if (d.open && panel && !panel.contains(e.target) && !d.querySelector('summary').contains(e.target)) close(d, false);
});
-- }
++ });
++ document.addEventListener('keydown', function (e) {
++ if (e.key === 'Escape') all.forEach(function (d) { if (d.open) close(d, true); });
++ });
++ }
++
++ // "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;
document.querySelectorAll('[data-ovv-copy]').forEach(function (b) {
++ b.hidden = false;
const wrap = b.closest('[data-ovv-copy-wrap]');
if (wrap) wrap.hidden = false;
const label = b.innerHTML;
-- const say = function (text) {
-- b.textContent = text;
-- setTimeout(function () { b.innerHTML = label; }, 2000);
++ const say = function (title, hint) {
++ b.innerHTML = '<i class="fa-solid fa-check" aria-hidden="true"></i><span><b></b><small></small></span>';
++ b.querySelector('b').textContent = title;
++ b.querySelector('small').textContent = hint;
++ setTimeout(function () { b.innerHTML = label; }, 2200);
};
b.addEventListener('click', function () {
fetch(b.getAttribute('data-ovv-copy'), { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status)); })
.then(function (text) { return navigator.clipboard.writeText(text); })
-- .then(function () { say('Copied'); }, function () { say('Could not copy: use the TXT download'); });
++ .then(function () { say('Copied', 'Paste it into the description'); }, function () { say('Could not copy', 'Use the Text download instead'); });
++ });
++ });
++ }
++
++ // Chat replay, chapters and the transcript as tabs (the server lists them one after another). The tab chosen last
++ // opens first on the next page.
++ function tabs() {
++ document.querySelectorAll('[data-ovv-tabs]').forEach(function (box) {
++ const list = box.querySelector('[data-ovv-tablist]');
++ if (!list) return;
++ const buttons = Array.prototype.slice.call(list.querySelectorAll('[data-ovv-tab]'));
++ const panels = Array.prototype.slice.call(box.querySelectorAll('[data-ovv-tp]'));
++ const select = function (key, focus) {
++ buttons.forEach(function (b) {
++ const on = b.getAttribute('data-ovv-tab') === key;
++ b.setAttribute('aria-selected', on ? 'true' : 'false');
++ b.tabIndex = on ? 0 : -1;
++ if (on && focus) b.focus();
++ });
++ panels.forEach(function (p) { p.hidden = p.getAttribute('data-ovv-tp') !== key; });
++ };
++ let saved = null;
++ try { saved = window.localStorage.getItem('ovv-tab'); } catch (e) { saved = null; }
++ list.hidden = false;
++ box.classList.add('ovv-companion--tabs');
++ select(buttons.some(function (b) { return b.getAttribute('data-ovv-tab') === saved; }) ? saved : buttons[0].getAttribute('data-ovv-tab'), false);
++ buttons.forEach(function (b, i) {
++ b.addEventListener('click', function () {
++ const key = b.getAttribute('data-ovv-tab');
++ select(key, false);
++ try { window.localStorage.setItem('ovv-tab', key); } catch (e) { /* private mode */ }
++ });
++ b.addEventListener('keydown', function (e) {
++ const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
++ let to = null;
++ if (step) to = buttons[(i + step + buttons.length) % buttons.length];
++ else if (e.key === 'Home') to = buttons[0];
++ else if (e.key === 'End') to = buttons[buttons.length - 1];
++ if (!to) return;
++ e.preventDefault();
++ select(to.getAttribute('data-ovv-tab'), true);
++ });
++ });
++ });
++ }
++
++ // Links that open a folded panel on this page (More → Manage), and the same for an address that names one.
++ function openers() {
++ const open = function (id) {
++ const d = document.getElementById(id);
++ if (!d || d.tagName !== 'DETAILS') return false;
++ d.open = true;
++ d.scrollIntoView({ block: 'start', behavior: 'smooth' });
++ const first = d.querySelector('input, textarea, select');
++ if (first) setTimeout(function () { first.focus({ preventScroll: true }); }, 300);
++ return true;
++ };
++ document.querySelectorAll('[data-ovv-open]').forEach(function (a) {
++ a.addEventListener('click', function (e) { if (open(a.getAttribute('data-ovv-open'))) e.preventDefault(); });
++ });
++ if (window.location.hash.length > 1) open(window.location.hash.slice(1));
++ }
++
++ // Share: the page's link, at the current time once it plays; the phone's share sheet where there is one.
++ function share(video) {
++ document.querySelectorAll('[data-ovv-share]').forEach(function (b) {
++ if (!navigator.share && !navigator.clipboard) return;
++ b.hidden = false;
++ const label = b.innerHTML;
++ b.addEventListener('click', function () {
++ let url = b.getAttribute('data-ovv-share');
++ try {
++ const u = new URL(url);
++ if (video && video.currentTime > 3 && !video.ended) u.searchParams.set('t', String(Math.floor(video.currentTime)));
++ url = u.toString();
++ } catch (e) { /* the page's own link */ }
++ const title = (document.querySelector('.ovv-title') || {}).textContent || document.title;
++ if (navigator.share && window.matchMedia('(hover: none)').matches) {
++ navigator.share({ title: title, url: url }).catch(function () { /* closed */ });
++ return;
++ }
++ navigator.clipboard.writeText(url).then(function () {
++ b.innerHTML = '<i class="fa-solid fa-check" aria-hidden="true"></i> Link copied';
++ setTimeout(function () { b.innerHTML = label; }, 2000);
++ }, function () { window.prompt('Copy this link', url); });
++ });
++ });
++ }
++
++ // The transcript follows the video (the line being said is marked and kept in view inside its panel, unless the
++ // reader scrolled it lately or is searching), and its search box keeps only the lines that have the words.
++ function transcript(video) {
++ const body = document.querySelector('[data-ovv-transcript]');
++ if (!body) return;
++ const lines = Array.prototype.slice.call(body.querySelectorAll('.ovv-line'));
++ const wrap = document.querySelector('[data-ovv-search-wrap]');
++ const input = wrap && wrap.querySelector('[data-ovv-search]');
++ const empty = body.querySelector('[data-ovv-search-empty]');
++ let searching = false;
++ if (wrap && input) {
++ wrap.hidden = false;
++ input.addEventListener('input', function () {
++ const q = input.value.trim().toLowerCase();
++ searching = !!q;
++ let shown = 0;
++ lines.forEach(function (l) {
++ const hit = !q || l.textContent.toLowerCase().indexOf(q) !== -1;
++ l.hidden = !hit;
++ if (hit) shown++;
++ });
++ if (empty) empty.hidden = shown > 0;
});
++ }
++ if (!video) return;
++ let current = null;
++ let userScrollUntil = 0;
++ let lastAutoScroll = 0;
++ body.addEventListener('scroll', function () {
++ if (Date.now() - lastAutoScroll > 250) userScrollUntil = Date.now() + 5000;
++ });
++ video.addEventListener('timeupdate', function () {
++ const t = video.currentTime;
++ let at = null;
++ for (let i = 0; i < lines.length; i++) {
++ if (Number(lines[i].getAttribute('data-ovv-at')) <= t + 0.25) at = lines[i];
++ else break;
++ }
++ if (at === current) return;
++ if (current) current.classList.remove('is-current');
++ current = at;
++ if (!at) return;
++ at.classList.add('is-current');
++ if (searching || Date.now() < userScrollUntil || !body.offsetParent) return;
++ lastAutoScroll = Date.now();
++ const pr = body.getBoundingClientRect();
++ const rr = at.getBoundingClientRect();
++ const top = rr.top - pr.top + body.scrollTop;
++ if (top < body.scrollTop || top + rr.height > body.scrollTop + body.clientHeight) body.scrollTop = Math.max(0, top - body.clientHeight / 3);
});
}
@@@ -172,9 +324,14 @@
setTimeout(function () { window.location.replace(window.location.pathname); }, wait);
}
-- downloads();
++ menus();
++ copyButtons();
++ tabs();
++ openers();
++ share(document.querySelector('video[data-ovv-video]'));
const video = document.querySelector('video[data-ovv-video]');
++ transcript(video);
if (!video) return;
// "Make a clip": with the player here, mark the start and end at the current time.
ddiff --git a/server/video/downloads.js b/server/video/downloads.js
iindex 71a40aa..4b8eced 100644
---- a/server/video/downloads.js
++++ b/server/video/downloads.js
@@@ -240,12 +240,6 @@ function downloadMenu({ kind, id, item, has }) {
</div></details>`;
}
--/** A panel's own links ("Download: TXT · SRT …"), for the transcript, chapters and chat replay panels. */
--function panelLinks(kind, id, what, exts, helps = {}) {
-- const at = `/${kind === 'clip' ? 'c' : 'v'}/${id}/download/${what}`;
-- return html`<p class="ovv-panel-dl"><i class="fa-solid fa-download" aria-hidden="true"></i> Download ${exts.map((ext, i) => html`${i ? ' · ' : ''}<a href="${at}.${ext}" download rel="nofollow" title="${helps[ext] || FORMAT_HELP[ext] || ''}">${ext.toUpperCase()}</a>`)}</p>`;
--}
--
// ── Routes ─────────────────────────────────────────────────
const TYPES = {
txt: 'text/plain; charset=utf-8', srt: 'application/x-subrip; charset=utf-8', vtt: 'text/vtt; charset=utf-8',
@@@ -425,6 +419,6 @@ function mountDownloads(r, deps) {
}
module.exports = {
-- mountDownloads, downloadMenu, panelLinks, fileBase, disposition, clockLabel, srtTime, csvCell, zip, crc32, sizeLabel,
++ mountDownloads, downloadMenu, fileBase, disposition, clockLabel, srtTime, csvCell, zip, crc32, sizeLabel,
transcriptFormats, chatFormats, chapterFormats, infoJson, CHAPTER_TXT_HELP,
};
ddiff --git a/server/video/live-client.js b/server/video/live-client.js
iindex 37018f2..37bfca9 100644
---- a/server/video/live-client.js
++++ b/server/video/live-client.js
@@@ -225,6 +225,10 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
vodMemories(id) {
return getJson(`/api/vods/${id}/memories`);
},
++ /** A stream's recording parts (Live's history: a broadcaster who reconnected is recorded in parts), or null. */
++ streamHistory(streamId) {
++ return isId(streamId) ? getJson(`/api/streams/${streamId}/history`) : Promise.resolve(null);
++ },
clip(id) {
return getJson(`/api/clips/${id}`);
},
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 8f097e8..2b040c8 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -41,7 +41,8 @@ const formatBytes = (b) => rules.formatBytes(b).replace(/\.0 /, ' ');
const { createVideoApiRoutes } = require('./api');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
--const { mountDownloads, downloadMenu, panelLinks, CHAPTER_TXT_HELP } = require('./downloads');
++const { mountDownloads, downloadMenu } = require('./downloads');
++const { actionMenu, menuLinks, companion } = require('./watch-ui');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
@@@ -253,13 +254,6 @@ function utcSeconds(v) {
}
/** An offset into the stream as H:MM:SS (no leading zero on the hour). */
--function streamTime(s) {
-- const n = Math.max(0, Math.floor(Number(s) || 0));
-- const h = Math.floor(n / 3600);
-- const m = Math.floor((n % 3600) / 60);
-- const sec = n % 60;
-- return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
--}
/** A short "2 hours ago" for a comment's created time. */
function relativeDate(iso) {
@@@ -594,30 +588,33 @@ function createVideoRoutes(ctx) {
}
const savedNotice = (req) => (req.query.saved === '1' ? notice('Saved on OpenVibe.Live.', 'ok') : '');
const visibilityOptions = (current) => VISIBILITIES.map((v) => html`<option value="${v}"${v === current ? ' selected' : ''}>${visibilityLabel(v)}</option>`);
-- /** "Make a clip" of a finished recording, for any signed-in viewer who can see it (Live's rule, as on Live). */
-- function clipPanel(req, id, vod) {
++ /**
++ * "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.
++ */
++ function clipAction(req, id, vod) {
if (vod.is_recording) return '';
if (!signedIn(req)) {
-- return html`<section class="ovv-panel ovv-clipper" aria-labelledby="h-clip"><h2 id="h-clip">Make a clip</h2><p class="ovv-fine"><a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a> to cut a clip of up to ${CLIP_MAX_SECONDS} seconds from this recording.</p></section>`;
++ return html`<a class="ovv-btn" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}" title="Sign in to make a clip of up to ${CLIP_MAX_SECONDS} seconds"><i class="fa-solid fa-scissors" aria-hidden="true"></i> Clip</a>`;
}
const dur = Math.floor(Number(vod.duration_seconds) || 0);
-- return html`<details class="ovv-panel ovv-clipper" data-ovv-clipper><summary><h2 id="h-clip">Make a clip</h2></summary>
++ return actionMenu({ icon: 'fa-solid fa-scissors', label: 'Clip', title: `Make a clip of up to ${CLIP_MAX_SECONDS} seconds`, cls: 'ovv-clipper', body: html`<p class="ovv-pop-title">Make a clip</p>
<form class="ovv-owner-form" method="post" action="/v/${id}/clip">
<div class="ovv-clip-times">
<label class="ovv-field"><span>Start</span><input name="start" type="text" inputmode="numeric" placeholder="0:00" pattern="[0-9:.]{1,12}" required data-ovv-clip-start></label>
<label class="ovv-field"><span>End</span><input name="end" type="text" inputmode="numeric" placeholder="0:30" pattern="[0-9:.]{1,12}" required data-ovv-clip-end></label>
</div>
--<p class="ovv-clip-now" hidden data-ovv-clip-now><button type="button" class="ovv-btn" data-ovv-clip-mark="start">Start here</button> <button type="button" class="ovv-btn" data-ovv-clip-mark="end">End here</button></p>
++<p class="ovv-clip-now" hidden data-ovv-clip-now><button type="button" class="ovv-btn ovv-btn--small" data-ovv-clip-mark="start">Start here</button> <button type="button" class="ovv-btn ovv-btn--small" data-ovv-clip-mark="end">End here</button></p>
<label class="ovv-field"><span>Title <span class="ovv-fine">(optional)</span></span><input name="title" type="text" maxlength="100"></label>
--<p class="ovv-fine">Up to ${CLIP_MAX_SECONDS} seconds${dur ? `, within ${clock(dur)}` : ''}. Times are m:ss or h:mm:ss. The clip is cut on OpenVibe.Media and credited to you.</p>
++<p class="ovv-fine">Up to ${CLIP_MAX_SECONDS} seconds${dur ? `, within ${clock(dur)}` : ''}. Times are m:ss or h:mm:ss. It is cut on OpenVibe.Media and credited to you.</p>
<button class="ovv-btn ovv-btn--primary" type="submit">Make the clip</button>
--</form></details>`;
++</form>` });
}
function ownerPanel(kind, id, item) {
const what = kind === 'vod' ? 'recording' : 'clip';
const action = `/${kind === 'vod' ? 'v' : 'c'}/${id}`;
-- return html`<section class="ovv-panel ovv-owner" aria-labelledby="h-owner"><h2 id="h-owner">Manage this ${what}</h2>
++ return html`<details class="ovv-panel ovv-owner" id="manage"><summary><h2 id="h-owner">Manage this ${what}</h2><span class="ovv-fine">Title, ${kind === 'vod' ? 'description, ' : ''}who can see it, delete</span></summary>
<form class="ovv-owner-form" method="post" action="${action}/manage">
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="200" value="${item.title || ''}" required></label>
${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textarea name="description" rows="3" maxlength="2000">${item.description || ''}</textarea></label>` : ''}
@@@ -628,7 +625,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
<details class="ovv-danger"><summary>Delete this ${what}</summary>
<form class="ovv-owner-delete" method="post" action="${action}/delete"><label class="ovv-confirm"><input type="checkbox" name="confirm" value="1" required> I want to delete it for good</label> <button class="ovv-btn ovv-btn--danger" type="submit">Delete</button></form>
</details>
--</section>`;
++</details>`;
}
// The shell's navbar/footer scripts come from the site's own /shared copy, and the one
@@@ -661,11 +658,11 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
// ── Playlist page pieces ───────────────────────────────
async function playlistForm(req, kind, id) {
if (!signedIn(req)) {
-- return html`<a class="ovv-btn" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}"><i class="fa-solid fa-plus" aria-hidden="true"></i> Sign in to save to a playlist</a>`;
++ return html`<a class="ovv-btn" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}" title="Sign in to save it to a playlist"><i class="fa-solid fa-plus" aria-hidden="true"></i> Save</a>`;
}
const lists = await playlists.list(ownerOf(req));
-- return html`<details class="ovv-save"><summary class="ovv-btn"><i class="fa-solid fa-plus" aria-hidden="true"></i> Save to playlist</summary>
--<form class="ovv-save-form" method="post" action="/playlists/add">
++ return html`<details class="ovv-save ovv-pop" data-ovv-menu><summary class="ovv-btn" title="Save to a playlist"><i class="fa-solid fa-plus" aria-hidden="true"></i> Save</summary>
++<form class="ovv-save-form ovv-pop-panel" method="post" action="/playlists/add">
<input type="hidden" name="kind" value="${kind}">
<input type="hidden" name="id" value="${id}">
<span class="ovv-save-label">Add to playlist</span>
@@@ -1051,13 +1048,15 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
// Chapters, comments, chat replay, the playlist form and up next do not depend on each other: asked together
// (one after another, five services' answers in turn, the page's p95 was 581 ms on 2026-10-17).
const fail = Symbol('failed');
-- const [m, cm, replay, saveForm, up] = await Promise.all([
++ const [m, cm, replay, saveForm, up, hist] = await Promise.all([
// A private recording's moments are Live's answer to its owner only.
Promise.resolve().then(() => (ownOnly ? asPerson(req, res, (token) => live.vodMemoriesAs(token, id)) : live.vodMemories(id))).catch(() => fail),
Promise.resolve().then(() => live.comments('vod', id)).catch(() => fail),
Promise.resolve().then(() => chat.chatReplay(vod.stream_id)).catch(() => fail),
playlistForm(req, 'vod', id),
upNext(req, 'vod', id),
++ // One part of a stream recorded in parts (its broadcaster reconnected): the other parts, to move between.
++ Promise.resolve().then(() => (vod.part ? live.streamHistory(vod.stream_id) : null)).catch(() => fail),
]);
let mems = [];
try { if (m !== fail) mems = chapters(moments(m && m.memories), vod.duration_seconds); } catch { mems = []; }
@@@ -1094,6 +1093,17 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
const thin = thinRecording(vod, segs.length);
const about = vod.description || vod.ai_overview || factualDescription('vod', vod, segs.slice(0, 6).map((s) => s.text).join(' '));
++
++ // The other parts of a stream recorded in parts, in order: Part 1 · Part 2 (this one).
++ const parts = hist && hist !== fail && Array.isArray(hist.parts) ? hist.parts.filter((p) => p && live.isId(p.vod_id)) : [];
++ const partNav = parts.length > 1 ? html`<p class="ovv-about-parts"><i class="fa-solid fa-layer-group" aria-hidden="true"></i> The stream was recorded in ${parts.length} parts: ${parts.map((p, i) => [i ? ' · ' : '', String(p.vod_id) === id ? html`<b>Part ${p.part} (this one)</b>` : html`<a href="/v/${p.vod_id}">Part ${p.part}</a>`])}</p>` : '';
++ // The More menu: where else this lives, and the owner's tools.
++ const moreItems = [];
++ if (channelName) moreItems.push({ href: liveChannel, icon: 'fa-solid fa-tower-broadcast', label: 'Channel on OpenVibe.Live', hint: 'Live streams and chat' });
++ if (threadUrl) moreItems.push({ href: threadUrl, icon: 'fa-regular fa-comments', label: 'Discuss on OpenVibe.Community' });
++ if (manageable) moreItems.push({ href: '#manage', icon: 'fa-solid fa-pen', label: 'Manage this recording', hint: 'Title, description, who can see it', data: raw('data-ovv-open="manage"') });
++ // Chat replay, chapters and the transcript, beside the player.
++ const side = companion({ kind: 'vod', id, chat: chatRows, chapters: mems, transcript: segs, long: Number(vod.duration_seconds) >= 3600 });
return page(req, res, {
index: !unlisted && !ownOnly && !thin,
noindex: unlisted || ownOnly || thin,
@@@ -1106,29 +1116,38 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
}),
scripts: ['js/video.js'],
player: true,
-- body: html`<div class="ovv-watch">
--<div class="ovv-main">
++ body: html`<div class="ovv-watch ovv-watch--vod">
++<div class="ovv-col ovv-col--main">
++<div class="ovv-stage">
${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}
<div class="ovv-player-wrap"><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))} 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></div>
++<div class="ovv-head">
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
++<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 class="ovv-byline-actions">${saveForm}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${channelName ? html`<a class="ovv-btn" href="${liveChannel}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a>` : ''}${threadUrl ? html`<a class="ovv-btn" href="${threadUrl}"><i class="fa-regular fa-comments" aria-hidden="true"></i> Discuss on OpenVibe.Community</a>` : ''}</div>
++</div>
++<div class="ovv-actions">${saveForm}${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 } })}<button type="button" class="ovv-btn" hidden data-ovv-share="${base}/v/${id}" title="Share a link (at the current time once it plays)"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</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>` : ''}
--${vod.ai_overview ? html`<section class="ovv-panel" aria-labelledby="h-overview"><h2 id="h-overview">About this stream</h2><p>${vod.ai_overview}</p><p class="ovv-fine">Written by AI from the stream.</p></section>` : ''}
--${segs.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcript <span class="ovv-fine">(AI, ${segs.length} lines)</span></summary>${panelLinks('vod', id, 'transcript', ['txt', 'srt', 'vtt', 'json'])}<div class="ovv-transcript-body">${segs.map((s) => html`<p><button type="button" class="ovv-seek" data-ovv-seek="${s.start}">${vttTime(s.start).slice(0, 8)}</button> <span>${s.text}</span></p>`)}</div></details>` : ''}
--${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><h2 id="h-comments">Comments</h2>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.</p>`}${threadUrl ? html`<p class="ovv-comments-link"><a class="ovv-btn" href="${threadUrl}">Join the discussion on OpenVibe.Community</a></p>` : ''}</section>` : ''}
</div>
--<aside class="ovv-side">
++</div>
++<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>
${manageable ? ownerPanel('vod', id, vod) : ''}
--${ownOnly ? '' : clipPanel(req, id, vod)}
++${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">
++${side || ''}
${up.html}
--${mems.length ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${mems.map((m) => html`<li><a href="/v/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}"><span class="ovv-time">${vttTime(m.offset_seconds).slice(0, 8)}</span><span>${m.description}</span></a></li>`)}</ol><p class="ovv-fine">From the AI moments of the stream.</p>${panelLinks('vod', id, 'chapters', ['txt', 'vtt', 'json'], { txt: CHAPTER_TXT_HELP })}<p class="ovv-panel-dl" hidden data-ovv-copy-wrap><button type="button" class="ovv-btn ovv-btn--small" data-ovv-copy="/v/${id}/download/chapters.txt"><i class="fa-regular fa-copy" aria-hidden="true"></i> Copy for a YouTube description</button></p></section>` : ''}
--${chatRows ? html`<section class="ovv-panel ovv-chat" aria-labelledby="h-chat"><h2 id="h-chat">Chat replay</h2><div class="ovv-chat-body" data-ovv-chat>${chatRows.rows.map((r) => html`<div class="ovv-chat-row" data-ovv-at="${r.offset}"><button type="button" class="ovv-seek" data-ovv-seek="${r.offset}">${streamTime(r.offset)}</button><span class="ovv-chat-msg">${r.name ? html`<b class="ovv-chat-name">${r.name}</b> ` : ''}${r.text}</span></div>`)}</div>${chatRows.leftOut ? html`<p class="ovv-fine">Showing the first 1,000 messages; ${chatRows.leftOut} more not shown (the download has them all).</p>` : ''}${panelLinks('vod', id, 'chat', ['txt', 'csv', 'json'])}</section>` : ''}
--${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 id="h-clips">Clips from this stream</h2><div class="ovv-grid ovv-grid--side">${clips.map(clipCard)}</div></section>` : ''}
++${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-clips"><h2 id="h-clips">Clips from this stream</h2><div class="ovv-grid ovv-grid--side">${clips.map(clipCard)}</div></section>` : ''}
</aside>
</div>`,
});
@@@ -1173,6 +1192,13 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const viewAttr = html` data-ovv-view="${livePublic}/api/views/clip/${id}"`;
const hlsAttr = hlsAttrs(clip);
++ const segs = transcriptSegments(clip);
++ const moreItems = [];
++ if (clip.source_streamer_username) moreItems.push({ href: `${livePublic}/@${clip.source_streamer_username}`, icon: 'fa-solid fa-tower-broadcast', label: 'Channel on OpenVibe.Live', hint: 'Live streams and chat' });
++ if (threadUrl) moreItems.push({ href: threadUrl, icon: 'fa-regular fa-comments', label: 'Discuss on OpenVibe.Community' });
++ if (manageable) moreItems.push({ href: '#manage', icon: 'fa-solid fa-pen', label: 'Manage this clip', hint: 'Title, who can see it', data: raw('data-ovv-open="manage"') });
++ // A clip's transcript, when it has one, beside the player as on a recording.
++ const side = companion({ kind: 'clip', id, chat: null, chapters: [], transcript: segs, long: false });
return page(req, res, {
index: !unlisted && !ownOnly && !aiClip,
@@@ -1187,20 +1213,27 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
scripts: ['js/video.js'],
player: true,
body: html`<div class="ovv-watch ovv-watch--clip">
--<div class="ovv-main">
++<div class="ovv-col ovv-col--main">
++<div class="ovv-stage">
${savedNotice(req)}${req.query.made === '1' ? notice('Your clip was made. It is credited to you on OpenVibe.Live and here.', 'ok') : ''}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this clip.', 'info') : ''}
${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"><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${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>
--<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>${clip.duration_seconds != null ? html` · ${durationLabel(clip.duration_seconds)}` : ''}</span>` : ''}</div>
--<div class="ovv-byline-actions">${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: transcriptSegments(clip).length } })}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}"><i class="fa-solid fa-film" aria-hidden="true"></i> Watch in the full recording</a>` : ''}${threadUrl ? html`<a class="ovv-btn" href="${threadUrl}"><i class="fa-regular fa-comments" aria-hidden="true"></i> Discuss on OpenVibe.Community</a>` : ''}</div>
++<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>${clip.duration_seconds != null ? html` · ${durationLabel(clip.duration_seconds)}` : ''}</span>` : ''}</div></div>
++<div class="ovv-actions">${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 ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}<button type="button" class="ovv-btn" hidden data-ovv-share="${base}/c/${id}" title="Share a link"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
--${up.html}
--${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><p class="ovv-fine">Written by AI from the clip.</p></section>` : ''}
++</div>
++</div>
++${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>` : ''}
${manageable ? ownerPanel('clip', id, clip) : ''}
</div>
++<aside class="ovv-col ovv-col--side">
++${side || ''}
++${up.html}
++</aside>
</div>`,
});
});
ddiff --git a/server/video/watch-ui.js b/server/video/watch-ui.js
nnew file mode 100644
iindex 0000000..fe3a00a
---- /dev/null
++++ b/server/video/watch-ui.js
@@@ -0,0 +1,118 @@
++'use strict';
++
++/**
++ * The pieces of the watch pages' layout (owner, 2026-10-26: "perform a review on this whole page and re organize it
++ * nicely and more user friendly and intuitive"):
++ *
++ * actionMenu({ … }) a button in the action row that opens a small menu (More, Clip): a <details>, so it works
++ * without JavaScript; video.js closes it on a click elsewhere or Escape and keeps it on screen
++ * cornerMenu({ … }) a panel's own download menu: one quiet icon button in the panel's corner instead of a line of
++ * links ("these are ugly … move to a nice sleek button/menu into the corner")
++ * companion({ … }) chat replay, chapters and the transcript as one panel beside the player, as tabs (video.js
++ * turns them on; without it the three are listed one after another, each with its heading)
++ *
++ * Times read the way a player shows them (1:57, or 1:02:05 in a video an hour or longer), not 00:01:57.
++ */
++const { html } = require('../render/html');
++
++/** Seconds → "1:57", or "0:01:57" when the video runs an hour or more (every time in it then lines up). */
++function clockOf(seconds, long = false) {
++ const n = Math.max(0, Math.floor(Number(seconds) || 0));
++ const h = Math.floor(n / 3600);
++ const m = Math.floor((n % 3600) / 60);
++ const s = String(n % 60).padStart(2, '0');
++ return h || long ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
++}
++
++/** A button of the action row that opens a menu or a small form. `body` is the panel's content (html). */
++function actionMenu({ icon, label, title = null, body, cls = '', iconOnly = false }) {
++ return html`<details class="ovv-pop ${cls}" data-ovv-menu><summary class="ovv-btn${iconOnly ? ' ovv-btn--icon' : ''}" title="${title || label}"${iconOnly ? html` aria-label="${label}"` : ''}><i class="${icon}" aria-hidden="true"></i>${iconOnly ? '' : html` ${label}`}</summary>
++<div class="ovv-pop-panel" role="group" aria-label="${label}">${body}</div></details>`;
++}
++
++/** The More menu's rows: [{ href, icon, label, hint? }]. */
++function menuLinks(items) {
++ return html`<ul class="ovv-pop-list">${items.map((it) => html`<li><a href="${it.href}"${it.external ? html` rel="noopener"` : ''}${it.data ? html` ${it.data}` : ''}><i class="${it.icon}" aria-hidden="true"></i><span>${it.label}${it.hint ? html`<small>${it.hint}</small>` : ''}</span></a></li>`)}</ul>`;
++}
++
++const FORMATS = {
++ txt: { name: 'Text', ext: 'TXT' },
++ srt: { name: 'Subtitles', ext: 'SRT' },
++ vtt: { name: 'Web captions', ext: 'VTT' },
++ csv: { name: 'Spreadsheet', ext: 'CSV' },
++ json: { name: 'Data', ext: 'JSON' },
++};
++
++/**
++ * A panel's download menu: a quiet icon in its corner. `items`: [{ ext, hint }] files under `${at}.${ext}`, and an
++ * optional `copy` ({ label, hint, href }) shown first, only with JavaScript (it copies the file's text).
++ */
++function cornerMenu({ at, what, items, copy = null }) {
++ return html`<details class="ovv-mini" data-ovv-menu><summary class="ovv-mini-btn" title="Download the ${what}" aria-label="Download the ${what}"><i class="fa-solid fa-download" aria-hidden="true"></i></summary>
++<div class="ovv-mini-menu" role="group" aria-label="Download the ${what}">
++<p class="ovv-mini-head">Download the ${what}</p>
++${copy ? html`<button type="button" class="ovv-mini-item" data-ovv-copy="${copy.href}" hidden><i class="fa-regular fa-copy" aria-hidden="true"></i><span><b>${copy.label}</b><small>${copy.hint}</small></span></button>` : ''}
++${items.map((it) => html`<a class="ovv-mini-item" href="${at}.${it.ext}" download rel="nofollow"><span class="ovv-mini-ext">${FORMATS[it.ext].ext}</span><span><b>${FORMATS[it.ext].name}</b><small>${it.hint}</small></span></a>`)}
++</div></details>`;
++}
++
++/** Each tab's download menu (the files /v/:id/download/… and /c/:id/download/… serve). */
++function downloadsFor(kind, id, what) {
++ const at = `/${kind === 'clip' ? 'c' : 'v'}/${id}/download/${what}`;
++ if (what === 'transcript') {
++ return cornerMenu({ at, what: 'transcript', items: [
++ { ext: 'txt', hint: 'To read, with times' }, { ext: 'srt', hint: 'For video editors and players' },
++ { ext: 'vtt', hint: 'For web players' }, { ext: 'json', hint: 'For programs' }] });
++ }
++ if (what === 'chat') {
++ return cornerMenu({ at, what: 'chat replay', items: [
++ { ext: 'txt', hint: 'To read, with times' }, { ext: 'csv', hint: 'Opens in Excel or Sheets' }, { ext: 'json', hint: 'For programs' }] });
++ }
++ return cornerMenu({ at, what: 'chapters',
++ copy: { label: 'Copy for YouTube', hint: 'Paste into a video description', href: `${at}.txt` },
++ items: [{ ext: 'txt', hint: 'The same lines, as a file' }, { ext: 'vtt', hint: 'A chapter track for players' }, { ext: 'json', hint: 'For programs' }] });
++}
++
++/**
++ * Chat replay, chapters and transcript in one panel. `chat` = { rows, leftOut } (rows: { offset, name, text }) or
++ * null; `chapters` = [{ offset_seconds, description }]; `transcript` = [{ start, text }]; `long` = the video is an
++ * hour or longer. Null when there is none of the three.
++ */
++function companion({ kind, id, chat, chapters, transcript, long }) {
++ const tabs = [];
++ if (chat && chat.rows.length) {
++ const total = chat.rows.length + chat.leftOut;
++ tabs.push({
++ key: 'chat', tab: 'Chat', title: 'Chat replay', count: total,
++ note: `${total.toLocaleString('en-US')} ${total === 1 ? 'message' : 'messages'}, in time with the video`,
++ body: html`<div class="ovv-tp-body ovv-chat-body" data-ovv-chat>${chat.rows.map((r) => html`<div class="ovv-chat-row" data-ovv-at="${r.offset}"><button type="button" class="ovv-seek" data-ovv-seek="${r.offset}" title="Play from here">${clockOf(r.offset, long)}</button><span class="ovv-chat-msg">${r.name ? html`<b class="ovv-chat-name">${r.name}</b> ` : ''}${r.text}</span></div>`)}${chat.leftOut ? html`<p class="ovv-tp-more">The first ${chat.rows.length.toLocaleString('en-US')} messages are shown here; the download has all ${total.toLocaleString('en-US')}.</p>` : ''}</div>`,
++ });
++ }
++ if (chapters && chapters.length) {
++ tabs.push({
++ key: 'chapters', tab: 'Chapters', title: 'Chapters', count: chapters.length,
++ note: 'Made by AI from the stream',
++ body: html`<ol class="ovv-tp-body ovv-chapter-list">${chapters.map((m) => html`<li><a href="/${kind === 'clip' ? 'c' : 'v'}/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}" title="${m.description}"><span class="ovv-time">${clockOf(m.offset_seconds, long)}</span><span class="ovv-chapter-title">${m.description}</span></a></li>`)}</ol>`,
++ });
++ }
++ if (transcript && transcript.length) {
++ tabs.push({
++ key: 'transcript', tab: 'Transcript', title: 'Transcript', count: transcript.length,
++ note: 'Written by AI from the audio',
++ search: true,
++ body: html`<div class="ovv-tp-body ovv-transcript-body" data-ovv-transcript>${transcript.map((s) => html`<p class="ovv-line" data-ovv-at="${Math.floor(s.start)}"><button type="button" class="ovv-seek" data-ovv-seek="${s.start}" title="Play from here">${clockOf(s.start, long)}</button> <span>${s.text}</span></p>`)}<p class="ovv-tp-empty" hidden data-ovv-search-empty>No line has that.</p></div>`,
++ });
++ }
++ if (!tabs.length) return null;
++ const one = tabs.length === 1;
++ return html`<section class="ovv-companion${one ? ' ovv-companion--one' : ''}" data-ovv-tabs aria-label="${tabs.map((t) => t.title).join(', ')}">
++${one ? '' : html`<div class="ovv-tabs" role="tablist" aria-label="${tabs.map((t) => t.title).join(', ')}" hidden data-ovv-tablist>${tabs.map((t, i) => html`<button type="button" role="tab" id="ovv-tab-${t.key}" aria-controls="ovv-tp-${t.key}" aria-selected="${i === 0 ? 'true' : 'false'}" tabindex="${i === 0 ? '0' : '-1'}" data-ovv-tab="${t.key}">${t.tab} <span class="ovv-tab-count">${t.count > 999 ? '999+' : t.count}</span></button>`)}</div>`}
++${tabs.map((t) => html`<section class="ovv-tp" id="ovv-tp-${t.key}"${one ? '' : html` role="tabpanel" aria-labelledby="ovv-tab-${t.key}"`} data-ovv-tp="${t.key}">
++<header class="ovv-tp-head"><div class="ovv-tp-heading"><h2 class="ovv-tp-title">${t.title}</h2><span class="ovv-tp-note">${t.note}</span></div>${downloadsFor(kind, id, t.key)}</header>
++${t.search ? html`<label class="ovv-tp-search" hidden data-ovv-search-wrap><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input type="search" placeholder="Search the transcript" aria-label="Search the transcript" data-ovv-search></label>` : ''}
++${t.body}
++</section>`)}
++</section>`;
++}
++
++module.exports = { clockOf, actionMenu, menuLinks, cornerMenu, downloadsFor, companion };
ddiff --git a/test/video-clip.test.js b/test/video-clip.test.js
iindex 2b37cee..9d0ca4d 100644
---- a/test/video-clip.test.js
++++ b/test/video-clip.test.js
@@@ -68,7 +68,7 @@ function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {
await check('signed out, the panel offers sign-in; signed in, the form', async () => {
const anon = await vreq(t.base, '/v/101');
-- assert.ok(anon.text.includes('Make a clip') && anon.text.includes('to cut a clip of up to 60 seconds'), 'a sign-in prompt');
++ assert.ok(/<a class="ovv-btn" href="\/auth\/login\?next=%2Fv%2F101" title="Sign in to make a clip of up to 60 seconds">[^<]*<i class="fa-solid fa-scissors"/.test(anon.text), 'the Clip button signs in first');
assert.ok(!anon.text.includes('action="/v/101/clip"'));
const r = await vreq(t.base, '/v/101', { cookie: samCookie });
assert.ok(r.text.includes('action="/v/101/clip"'), 'any signed-in viewer, not only the channel');
ddiff --git a/test/video-downloads.test.js b/test/video-downloads.test.js
iindex 07955ff..df5bd1a 100644
---- a/test/video-downloads.test.js
++++ b/test/video-downloads.test.js
@@@ -60,6 +60,9 @@ function startLive() {
const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
let m = /^\/api\/chat\/(\d+)\/replay$/.exec(url.pathname);
if (m) return json(200, { messages: m[1] === '2795' ? messages : [] });
++ if (url.pathname === '/api/streams/2795/history') {
++ return json(200, { stream_id: 2795, parts: [{ part: 1, vod_id: 6025, at: 28, end: 708 }, { part: 2, vod_id: 6026, at: 709, end: 1930 }], shapes: [], reconnects: [] });
++ }
m = /^\/api\/(vods|clips)\/(\d+)(\/memories|\/download-url)?$/.exec(url.pathname);
if (!m) return json(404, { error: 'not found' });
const item = (m[1] === 'vods' ? vods : clips)[m[2]];
@@@ -147,7 +150,16 @@ function unzip(buf) {
assert.ok(menu.includes('The whole recording · 571 MB'), 'the video with its size');
assert.ok(menu.includes('2 lines, by AI') && menu.includes('2 messages') && menu.includes('2 chapters'), 'what each holds');
assert.ok(/rel="nofollow"/.test(menu) && /download rel/.test(menu), 'download links, not followed by crawlers');
-- assert.ok(r.text.includes('class="ovv-panel-dl"') && r.text.includes('data-ovv-copy="/v/6026/download/chapters.txt"'), 'panel links and the copy button');
++ // Each tab's own download menu sits in its corner (one quiet icon), with Copy for YouTube on the chapters.
++ const panel = (key) => { const i = r.text.indexOf(`data-ovv-tp="${key}"`); return r.text.slice(i, r.text.indexOf('</section>', i)); };
++ assert.ok(panel('chat').includes('class="ovv-mini"') && panel('chat').includes('href="/v/6026/download/chat.csv"'), 'the chat panel\'s corner menu');
++ assert.ok(panel('transcript').includes('href="/v/6026/download/transcript.srt"'), 'the transcript panel\'s corner menu');
++ assert.ok(panel('chapters').includes('data-ovv-copy="/v/6026/download/chapters.txt"') && panel('chapters').includes('href="/v/6026/download/chapters.vtt"'), 'the chapters panel\'s, with Copy for YouTube');
++ assert.ok(!r.text.includes('ovv-panel-dl'), 'no line of links under a panel any more');
++ // The layout: tabs beside the player, one action row, the stream's parts in About.
++ assert.strictEqual((r.text.match(/role="tab"/g) || []).length, 3, 'Chat, Chapters, Transcript');
++ assert.ok(r.text.includes('data-ovv-share="https://openvibe.video/v/6026"') && r.text.includes('aria-label="More"'), 'Share and More');
++ assert.ok(r.text.includes('<a href="/v/6025">Part 1</a>') && r.text.includes('<b>Part 2 (this one)</b>'), 'the other part of the stream');
// Chat replay sits in the part's own time: 12 s in, not 721 s.
assert.ok(r.text.includes('data-ovv-at="12"') && !r.text.includes('in part one'), 'chat in part 2\'s time');
});
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex d556ec5..1e29d45 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -357,7 +357,7 @@ 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('Watch in the full recording'));
++ 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'));
assert.ok(!r.text.includes('Manage this clip'), 'owner tools only for its maker or channel (video-owner.test.js)');
@@@ -582,9 +582,9 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('Chat replay'));
assert.ok(r.text.includes('data-ovv-at="5"'), 'the first message is 5 seconds in');
assert.ok(r.text.includes('data-ovv-seek="5"'), 'the seek button carries the offset');
-- assert.ok(r.text.includes('0:00:05'), 'the offset is H:MM:SS');
++ assert.ok(r.text.includes('>0:05</button>'), 'the offset reads as the player shows it (m:ss under an hour)');
assert.ok(r.text.includes('data-ovv-at="25"'), 'the second message is 25 seconds in');
-- assert.ok(r.text.includes('0:00:25'));
++ assert.ok(r.text.includes('>0:25</button>'));
assert.ok(r.text.includes('Hello &lt;b&gt;world&lt;/b&gt;'), 'message text is escaped');
assert.ok(!r.text.includes('Hello <b>world</b>'), 'raw HTML never appears');
assert.ok(!r.text.includes('EARLY_MSG_XYZ'), 'message before the stream start is dropped');
@@@ -603,7 +603,7 @@ function countOccurrences(haystack, needle) {
await check('the chat replay caps at 1,000 messages and says how many were left out', async () => {
const r = await getVideo('/v/104');
assert.strictEqual(r.status, 200);
-- assert.ok(r.text.includes('Showing the first 1,000 messages; 5 more not shown (the download has them all).'), 'the cap note names the count');
++ assert.ok(r.text.includes('The first 1,000 messages are shown here; the download has all 1,005.'), 'the cap note names the count');
assert.strictEqual(countOccurrences(r.text, 'class="ovv-chat-row"'), 1000, 'exactly 1,000 rows render');
assert.ok(r.text.includes('1005 messages'), 'the Download menu counts every message');
});
@@@ -619,7 +619,7 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('3 replies'), 'reply count when present');
assert.ok(r.text.includes('<time class="ovv-comment-date"'), 'a relative date is shown');
assert.strictEqual(countOccurrences(r.text, 'class="ovv-comment"'), 2, 'the deleted comment is not rendered');
-- assert.ok(r.text.includes('Join the discussion on OpenVibe.Community'));
++ assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('href="https://openvibe.community/t/vod-101"'));
});
ddiff --git a/test/video-playlists.test.js b/test/video-playlists.test.js
iindex 1167e36..0dab2c2 100644
---- a/test/video-playlists.test.js
++++ b/test/video-playlists.test.js
@@@ -124,7 +124,7 @@ const videoEnv = (live) => ({
await check('the signed-out watch page offers sign-in, not the save form', async () => {
const r = await vreq(t.base, '/v/101');
assert.strictEqual(r.status, 200);
-- assert.ok(r.text.includes('Sign in to save to a playlist'), r.text.slice(0, 400));
++ assert.ok(r.text.includes('title="Sign in to save it to a playlist"'), r.text.slice(0, 400));
assert.ok(!r.text.includes('name="kind"'), 'no add-to-playlist form for a guest');
});
ddiff --git a/test/video-uploads.test.js b/test/video-uploads.test.js
iindex 6f79209..c018561 100644
---- a/test/video-uploads.test.js
++++ b/test/video-uploads.test.js
@@@ -113,7 +113,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
await check('a ready upload goes in a playlist (kind upload, by its number): listed, played, and next in line', async () => {
const watch = await vreq(t.base, `/u/${id}`, { cookie: samCookie });
-- assert.ok(watch.text.includes('Save to playlist'), 'a signed-in viewer can save it');
++ assert.ok(watch.text.includes('title="Save to a playlist"'), 'a signed-in viewer can save it');
assert.ok(watch.text.includes('<input type="hidden" name="kind" value="upload">'));
const seq = Number((/<input type="hidden" name="id" value="(\d+)">/.exec(watch.text) || [])[1]);
assert.ok(seq > 0, 'the form carries the upload\'s number');