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)
Changes
@@@ -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; }@@@ -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.@@@ -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,};@@@ -225,6 +225,10 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = consolevodMemories(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}`);},@@@ -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="ovvconst 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 iconst 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 iscripts: ['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>`,});});nnew file mode 100644@@@ -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 };@@@ -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');@@@ -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');});@@@ -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 <b>world</b>'), '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"'));});@@@ -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');});@@@ -113,7 +113,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = truawait 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');
@@ -297,17 +297,148 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -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-owner-delete { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 10px; }
.ovv-watch--clip .ovv-owner { max-width: 640px; }
.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-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 { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }
.ovv-clip-now[hidden] { display: none; }
.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. */
/* 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 { 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; }
.ovv-cutting p { margin: 0; max-width: 40ch; }
@@ -104,63 +104,215 @@
@@ -104,63 +104,215 @@
return h ? h + ':' + String(m).padStart(2, '0') + ':' + s : m + ':' + s;
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;
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;
if (!navigator.clipboard || !window.fetch) return;
document.querySelectorAll('[data-ovv-copy]').forEach(function (b) {
document.querySelectorAll('[data-ovv-copy]').forEach(function (b) {
+ b.hidden = false;
const wrap = b.closest('[data-ovv-copy-wrap]');
const wrap = b.closest('[data-ovv-copy-wrap]');
if (wrap) wrap.hidden = false;
if (wrap) wrap.hidden = false;
const label = b.innerHTML;
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 () {
b.addEventListener('click', function () {
fetch(b.getAttribute('data-ovv-copy'), { credentials: 'same-origin' })
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 (r) { return r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status)); })
.then(function (text) { return navigator.clipboard.writeText(text); })
.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 @@
@@ -172,9 +324,14 @@
setTimeout(function () { window.location.replace(window.location.pathname); }, wait);
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]');
const video = document.querySelector('video[data-ovv-video]');
+ transcript(video);
if (!video) return;
if (!video) return;
// "Make a clip": with the player here, mark the start and end at the current time.
// "Make a clip": with the player here, mark the start and end at the current time.
@@ -240,12 +240,6 @@ function downloadMenu({ kind, id, item, has }) {
@@ -240,12 +240,6 @@ function downloadMenu({ kind, id, item, has }) {
</div></details>`;
</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 ─────────────────────────────────────────────────
// ── Routes ─────────────────────────────────────────────────
const TYPES = {
const TYPES = {
txt: 'text/plain; charset=utf-8', srt: 'application/x-subrip; charset=utf-8', vtt: 'text/vtt; charset=utf-8',
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) {
@@ -425,6 +419,6 @@ function mountDownloads(r, deps) {
}
}
module.exports = {
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,
transcriptFormats, chatFormats, chapterFormats, infoJson, CHAPTER_TXT_HELP,
};
};
@@ -225,6 +225,10 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -225,6 +225,10 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
vodMemories(id) {
vodMemories(id) {
return getJson(`/api/vods/${id}/memories`);
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) {
clip(id) {
return getJson(`/api/clips/${id}`);
return getJson(`/api/clips/${id}`);
},
},
@@ -41,7 +41,8 @@ const formatBytes = (b) => rules.formatBytes(b).replace(/\.0 /, ' ');
@@ -41,7 +41,8 @@ const formatBytes = (b) => rules.formatBytes(b).replace(/\.0 /, ' ');
const { createVideoApiRoutes } = require('./api');
const { createVideoApiRoutes } = require('./api');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
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 PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
const HOME_LIMIT = 10;
@@ -253,13 +254,6 @@ function utcSeconds(v) {
@@ -253,13 +254,6 @@ function utcSeconds(v) {
}
}
/** An offset into the stream as H:MM:SS (no leading zero on the hour). */
/** 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. */
/** A short "2 hours ago" for a comment's created time. */
function relativeDate(iso) {
function relativeDate(iso) {
@@ -594,30 +588,33 @@ function createVideoRoutes(ctx) {
@@ -594,30 +588,33 @@ function createVideoRoutes(ctx) {
}
}
const savedNotice = (req) => (req.query.saved === '1' ? notice('Saved on OpenVibe.Live.', 'ok') : '');
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>`);
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 (vod.is_recording) return '';
if (!signedIn(req)) {
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);
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">
<form class="ovv-owner-form" method="post" action="/v/${id}/clip">
<div class="ovv-clip-times">
<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>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>
<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>
</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>
<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>
<button class="ovv-btn ovv-btn--primary" type="submit">Make the clip</button>
-</form></details>`;
+</form>` });
}
}
function ownerPanel(kind, id, item) {
function ownerPanel(kind, id, item) {
const what = kind === 'vod' ? 'recording' : 'clip';
const what = kind === 'vod' ? 'recording' : 'clip';
const action = `/${kind === 'vod' ? 'v' : 'c'}/${id}`;
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">
<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>
<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>` : ''}
${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
@@ -628,7 +625,7 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
<details class="ovv-danger"><summary>Delete this ${what}</summary>
<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>
<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>
</details>
-</section>`;
+</details>`;
}
}
// The shell's navbar/footer scripts come from the site's own /shared copy, and the one
// 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
@@ -661,11 +658,11 @@ ${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textar
// ── Playlist page pieces ───────────────────────────────
// ── Playlist page pieces ───────────────────────────────
async function playlistForm(req, kind, id) {
async function playlistForm(req, kind, id) {
if (!signedIn(req)) {
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));
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="kind" value="${kind}">
<input type="hidden" name="id" value="${id}">
<input type="hidden" name="id" value="${id}">
<span class="ovv-save-label">Add to playlist</span>
<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
@@ -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
// 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).
// (one after another, five services' answers in turn, the page's p95 was 581 ms on 2026-10-17).
const fail = Symbol('failed');
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.
// 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(() => (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(() => live.comments('vod', id)).catch(() => fail),
Promise.resolve().then(() => chat.chatReplay(vod.stream_id)).catch(() => fail),
Promise.resolve().then(() => chat.chatReplay(vod.stream_id)).catch(() => fail),
playlistForm(req, 'vod', id),
playlistForm(req, 'vod', id),
upNext(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 = [];
let mems = [];
try { if (m !== fail) mems = chapters(moments(m && m.memories), vod.duration_seconds); } catch { 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
@@ -1094,6 +1093,17 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
const thin = thinRecording(vod, segs.length);
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(' '));
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, {
return page(req, res, {
index: !unlisted && !ownOnly && !thin,
index: !unlisted && !ownOnly && !thin,
noindex: unlisted || ownOnly || thin,
noindex: unlisted || ownOnly || thin,
@@ -1106,29 +1116,38 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1106,29 +1116,38 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
}),
}),
scripts: ['js/video.js'],
scripts: ['js/video.js'],
player: true,
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') : ''}
${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-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>
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
+<div class="ovv-head-row">
<div class="ovv-byline">
<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>` : ''}
${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>`) : ''}
<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>
${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>
</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.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>
</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) : ''}
${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}
${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>
</aside>
</div>`,
</div>`,
});
});
@@ -1173,6 +1192,13 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
@@ -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 nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const viewAttr = html` data-ovv-view="${livePublic}/api/views/clip/${id}"`;
const viewAttr = html` data-ovv-view="${livePublic}/api/views/clip/${id}"`;
const hlsAttr = hlsAttrs(clip);
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, {
return page(req, res, {
index: !unlisted && !ownOnly && !aiClip,
index: !unlisted && !ownOnly && !aiClip,
@@ -1187,20 +1213,27 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
@@ -1187,20 +1213,27 @@ ${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 i
scripts: ['js/video.js'],
scripts: ['js/video.js'],
player: true,
player: true,
body: html`<div class="ovv-watch ovv-watch--clip">
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') : ''}
${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>`
${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>`}
: 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>
<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>
</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) : ''}
${manageable ? ownerPanel('clip', id, clip) : ''}
</div>
</div>
+<aside class="ovv-col ovv-col--side">
+${side || ''}
+${up.html}
+</aside>
</div>`,
</div>`,
});
});
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -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 };
@@ -68,7 +68,7 @@ function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {
@@ -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 () => {
await check('signed out, the panel offers sign-in; signed in, the form', async () => {
const anon = await vreq(t.base, '/v/101');
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"'));
assert.ok(!anon.text.includes('action="/v/101/clip"'));
const r = await vreq(t.base, '/v/101', { cookie: samCookie });
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');
assert.ok(r.text.includes('action="/v/101/clip"'), 'any signed-in viewer, not only the channel');
@@ -60,6 +60,9 @@ function startLive() {
@@ -60,6 +60,9 @@ function startLive() {
const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
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);
let m = /^\/api\/chat\/(\d+)\/replay$/.exec(url.pathname);
if (m) return json(200, { messages: m[1] === '2795' ? messages : [] });
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);
m = /^\/api\/(vods|clips)\/(\d+)(\/memories|\/download-url)?$/.exec(url.pathname);
if (!m) return json(404, { error: 'not found' });
if (!m) return json(404, { error: 'not found' });
const item = (m[1] === 'vods' ? vods : clips)[m[2]];
const item = (m[1] === 'vods' ? vods : clips)[m[2]];
@@ -147,7 +150,16 @@ function unzip(buf) {
@@ -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('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(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(/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.
// 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');
assert.ok(r.text.includes('data-ovv-at="12"') && !r.text.includes('in part one'), 'chat in part 2\'s time');
});
});
@@ -357,7 +357,7 @@ function countOccurrences(haystack, needle) {
@@ -357,7 +357,7 @@ function countOccurrences(haystack, needle) {
assert.strictEqual(cld.duration, 'PT10S');
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('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('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('/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('Discuss on OpenVibe.Community'));
assert.ok(!r.text.includes('Manage this clip'), 'owner tools only for its maker or channel (video-owner.test.js)');
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) {
@@ -582,9 +582,9 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('Chat replay'));
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-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('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('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 <b>world</b>'), 'message text is escaped');
assert.ok(r.text.includes('Hello <b>world</b>'), 'message text is escaped');
assert.ok(!r.text.includes('Hello <b>world</b>'), 'raw HTML never appears');
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');
assert.ok(!r.text.includes('EARLY_MSG_XYZ'), 'message before the stream start is dropped');
@@ -603,7 +603,7 @@ function countOccurrences(haystack, needle) {
@@ -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 () => {
await check('the chat replay caps at 1,000 messages and says how many were left out', async () => {
const r = await getVideo('/v/104');
const r = await getVideo('/v/104');
assert.strictEqual(r.status, 200);
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.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');
assert.ok(r.text.includes('1005 messages'), 'the Download menu counts every message');
});
});
@@ -619,7 +619,7 @@ function countOccurrences(haystack, needle) {
@@ -619,7 +619,7 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('3 replies'), 'reply count when present');
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.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.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"'));
assert.ok(r.text.includes('href="https://openvibe.community/t/vod-101"'));
});
});
@@ -124,7 +124,7 @@ const videoEnv = (live) => ({
@@ -124,7 +124,7 @@ const videoEnv = (live) => ({
await check('the signed-out watch page offers sign-in, not the save form', async () => {
await check('the signed-out watch page offers sign-in, not the save form', async () => {
const r = await vreq(t.base, '/v/101');
const r = await vreq(t.base, '/v/101');
assert.strictEqual(r.status, 200);
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');
assert.ok(!r.text.includes('name="kind"'), 'no add-to-playlist form for a guest');
});
});
@@ -113,7 +113,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
@@ -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 () => {
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 });
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">'));
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]);
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');
assert.ok(seq > 0, 'the form carries the upload\'s number');