openvibers/openvibe.mediahub Commits
Icons: OpenVibe glyphs, no Font Awesome (#433)
Changes
@@@ -116,7 +116,7 @@ endpoints `/api/health`, `/api/ready`, `/release.json`, `/metrics` are loopback- Never render a file's bytes into a page; escape every owner-supplied name with the `html` template.- CSP: add every external origin to the `helmet` directives in [server/app.js](server/app.js) (Media images, Live HLS,Community, Events, Search). Do not widen `frameAncestors`/`objectSrc`. The HLS player is vendored on purpose.--- **No Font Awesome** — use `openvibe-shared/glyphs` and `openvibe-shared/brands`; [test/no-font-awesome.test.js](test/no-font-awesome.test.js)++- **No Font Awesome** — use `openvibe-shared/glyphs` and `openvibe-shared/brands`; the no-FA guardchecks every page and stylesheet, and a new `fa-` name must be one the glyphs/brands draw.- **No looks filter** — `openvibe-sdk/no-looks` is gone; AI text may describe people. Only sex acts/porn stay gated([test/no-looks-guard.test.js](test/no-looks-guard.test.js)).@@@ -150,7 +150,7 @@ endpoints `/api/health`, `/api/ready`, `/release.json`, `/metrics` are loopback`npm test` runs each file in its own process on PGlite with Network/Media stand-ins. The suite covers driveupload/share/download/range, sync keys, agent grants, drops, video upload/record/playlists/AI/cut/trim/search/comments,--pics upload/gallery/album/labels/policy/sharing/rotate, radio, sharing, account data, host routing, no-Font-Awesome,++pics upload/gallery/album/labels/policy/sharing/rotate, radio, sharing, account data, host routing, no Font Awesome,no-looks, empty states, phone/contrast, secret-leak/redirect and size budgets. Run the narrowest file first, then thesuite; `npm run test:pg` for the PostgreSQL path. Always `node --check` changed files.@@@ -281,7 +281,7 @@if (wrap) wrap.hidden = false;const label = b.innerHTML;const say = function (title, hint) {-- b.innerHTML = '<i class="fa-solid fa-check" aria-hidden="true"></i><span><b></b><small></small></span>';++ b.innerHTML = '<i class="ovg-fill" data-ov-icon="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);@@@ -458,7 +458,7 @@mute.type = 'button';const hide = document.createElement('button');hide.type = 'button';-- hide.innerHTML = '<i class="fa-solid fa-eye-slash" aria-hidden="true"></i>';++ hide.innerHTML = '<i class="ovg-fill" data-ov-icon="eye-off" aria-hidden="true"></i>';hide.title = 'Hide ' + p.title;hide.setAttribute('aria-label', hide.title);const grip = document.createElement('span');@@@ -471,7 +471,7 @@root.appendChild(box);const item = { p: p, id: id, g: g, box: box, v: v, loaded: false, aspect: null };const paintMute = function () {-- mute.innerHTML = '<i class="fa-solid ' + (g.muted ? 'fa-volume-xmark' : 'fa-volume-high') + '" aria-hidden="true"></i>';++ mute.innerHTML = '<i class="ovg-fill" data-ov-icon="' + (g.muted ? 'mute' : 'volume') + '" aria-hidden="true"></i>';mute.title = g.muted ? 'Turn on ' + p.title + "'s sound" : 'Mute ' + p.title;mute.setAttribute('aria-label', mute.title);v.muted = g.muted || video.muted;@@@ -39,10 +39,10 @@ const code = (s) => html`<code>${s}</code>`;*/// A caller with no class of its own gets the kit's quiet button (kit.css is on every page that shows one): an empty class// drew an unstyled grey box beside the row's buttons (11-07).--function shareButton({ url = '', title = '', text = '', image = '', timeFrom = null, kind = null, id = null, cls = 'ovk-btn ovk-btn--quiet', label = 'Share', icon = 'fa-solid fa-share-nodes' }) {++function shareButton({ url = '', title = '', text = '', image = '', timeFrom = null, kind = null, id = null, cls = 'ovk-btn ovk-btn--quiet', label = 'Share', icon = 'nodes' }) {const line = String(text || '').trim().replace(/\s+/g, ' ');const summary = line.length > 240 ? `${line.slice(0, 237).trimEnd()}…` : line;-- return html`<button type="button" class="${cls}" data-ov-share-sheet${url ? html` data-url="${url}"` : ''}${title ? html` data-title="${title}"` : ''}${summary ? html` data-text="${summary}"` : ''}${image ? html` data-image="${image}"` : ''}${timeFrom ? html` data-time-from="${timeFrom}"` : ''}${kind && id ? html` data-kind="${kind}" data-id="${id}"` : ''}><i class="${icon}" aria-hidden="true"></i> ${label}</button>`;++ return html`<button type="button" class="${cls}" data-ov-share-sheet${url ? html` data-url="${url}"` : ''}${title ? html` data-title="${title}"` : ''}${summary ? html` data-text="${summary}"` : ''}${image ? html` data-image="${image}"` : ''}${timeFrom ? html` data-time-from="${timeFrom}"` : ''}${kind && id ? html` data-kind="${kind}" data-id="${id}"` : ''}><i class="ovg-fill" data-ov-icon="${icon}" aria-hidden="true"></i> ${label}</button>`;}/**@@@ -26,9 +26,9 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');// The product's own places: the main ones carry an icon (phones get them as the bottom tab bar), the rest fold into// the navbar's More (Shared 4.22). Search is the navbar's magnifier and What shipped is the footer's.const NAV = [-- { label: 'Your files', href: '/files', icon: 'fa-file-lines' },-- { label: 'Backups', href: '/sync', icon: 'fa-clock-rotate-left' },-- { label: 'Safety', href: '/safety', icon: 'fa-shield-halved', more: true },++ { label: 'Your files', href: '/files', icon: 'file' },++ { label: 'Backups', href: '/sync', icon: 'history' },++ { label: 'Safety', href: '/safety', icon: 'shield', more: true },];const hashes = new Map();@@@ -194,7 +194,7 @@ const CHAPTER_TXT_HELP = 'Times and titles, ready to paste into a YouTube descri/** One row of the menu: an icon, what it is (and a line about it), and a link per format. Each format says what it is* for under its name, so the menu reads without hovering (a phone has no hover). */function row({ icon, label, about, links }) {-- return html`<li class="ovv-dl-row"><i class="${icon}" aria-hidden="true"></i><span class="ovv-dl-what"><span class="ovv-dl-label">${label}</span>${about ? html`<span class="ovv-dl-about">${about}</span>` : ''}</span><span class="ovv-dl-formats">${links.map((l) => html`<a class="ovv-dl-format${l.main ? ' ovv-dl-format--main' : ''}" href="${l.href}" download rel="nofollow" title="${l.help || FORMAT_HELP[l.ext] || ''}" aria-label="${label}: ${l.text || l.ext.toUpperCase()} (${l.help || FORMAT_HELP[l.ext] || ''})"><span>${l.text || l.ext.toUpperCase()}</span>${l.purpose ? html`<small>${l.purpose}</small>` : ''}</a>`)}</span></li>`;++ return html`<li class="ovv-dl-row"><i class="ovg-fill" data-ov-icon="${icon}" aria-hidden="true"></i><span class="ovv-dl-what"><span class="ovv-dl-label">${label}</span>${about ? html`<span class="ovv-dl-about">${about}</span>` : ''}</span><span class="ovv-dl-formats">${links.map((l) => html`<a class="ovv-dl-format${l.main ? ' ovv-dl-format--main' : ''}" href="${l.href}" download rel="nofollow" title="${l.help || FORMAT_HELP[l.ext] || ''}" aria-label="${label}: ${l.text || l.ext.toUpperCase()} (${l.help || FORMAT_HELP[l.ext] || ''})"><span>${l.text || l.ext.toUpperCase()}</span>${l.purpose ? html`<small>${l.purpose}</small>` : ''}</a>`)}</span></li>`;}/** "570.7 MB" for a byte count (null when unknown). */@@@ -233,32 +233,32 @@ function downloadMenu({ kind, id, item, has }) {if (ready && parts) {// A stream recorded in parts plays as one video (Media's recording); its files are one per session, named by where// each sits in it.-- rows.push(row({ icon: 'fa-solid fa-film', label: 'Video', about: `${parts.length} files, one per recorded session`,++ rows.push(row({ icon: 'film', label: 'Video', about: `${parts.length} files, one per recorded session`,links: parts.map((p, i) => ({ href: `${at}/video?part=${p.vod_id}`, ext: videoExt(item), main: i === 0, text: `${clockLabel(p.start_sec)}–${clockLabel(p.start_sec + p.duration_sec)}` })) }));} else if (ready) {const size = sizeLabel(item.file_size);const ext = videoExt(item);-- rows.push(row({ icon: 'fa-solid fa-film', label: 'Video', about: [kind === 'clip' ? 'The clip' : 'The whole recording', size].filter(Boolean).join(' · '), links: [{ href: `${at}/video`, ext, main: true }] }));++ rows.push(row({ icon: 'film', label: 'Video', about: [kind === 'clip' ? 'The clip' : 'The whole recording', size].filter(Boolean).join(' · '), links: [{ href: `${at}/video`, ext, main: true }] }));}-- if (has.transcript) rows.push(row({ icon: 'fa-solid fa-closed-captioning', label: 'Transcript', about: `${has.transcript} lines, written by AI`, links: [++ if (has.transcript) rows.push(row({ icon: 'cc', label: 'Transcript', about: `${has.transcript} lines, written by AI`, links: [{ href: `${at}/transcript.txt`, ext: 'txt', purpose: 'to read' },{ href: `${at}/transcript.srt`, ext: 'srt', purpose: 'for editors' },{ href: `${at}/transcript.vtt`, ext: 'vtt', purpose: 'for web players' },{ href: `${at}/transcript.json`, ext: 'json', purpose: 'for programs' }] }));-- if (has.chat) rows.push(row({ icon: 'fa-solid fa-comments', label: 'Chat replay', about: `${has.chat} messages`, links: [++ if (has.chat) rows.push(row({ icon: 'chat', label: 'Chat replay', about: `${has.chat} messages`, links: [{ href: `${at}/chat.txt`, ext: 'txt', purpose: 'to read' },{ href: `${at}/chat.csv`, ext: 'csv', purpose: 'for spreadsheets' },{ href: `${at}/chat.json`, ext: 'json', purpose: 'for programs' }] }));-- if (has.chapters) rows.push(row({ icon: 'fa-solid fa-list-ol', label: 'Chapters', about: `${has.chapters} chapters, made by AI`, links: [++ if (has.chapters) rows.push(row({ icon: 'list-ordered', label: 'Chapters', about: `${has.chapters} chapters, made by AI`, links: [{ href: `${at}/chapters.txt`, ext: 'txt', purpose: 'to paste', help: CHAPTER_TXT_HELP },{ href: `${at}/chapters.vtt`, ext: 'vtt', purpose: 'for players' },{ href: `${at}/chapters.json`, ext: 'json', purpose: 'for programs' }] }));-- if (item.thumbnail_url) rows.push(row({ icon: 'fa-regular fa-image', label: 'Cover picture', about: null, links: [{ href: `${at}/thumbnail`, ext: 'jpg', text: 'Image' }] }));-- rows.push(row({ icon: 'fa-solid fa-circle-info', label: 'Details', about: 'Title, channel, dates, summary', links: [{ href: `${at}/info.json`, ext: 'json' }] }));-- const everything = html`<li class="ovv-dl-row ovv-dl-row--all"><i class="fa-solid fa-box-archive" aria-hidden="true"></i><span class="ovv-dl-what"><span class="ovv-dl-label">Everything</span><span class="ovv-dl-about">All of the above but the video, in one .zip</span></span><span class="ovv-dl-formats"><a class="ovv-dl-format ovv-dl-format--main" href="${at}/all.zip" download rel="nofollow" title="${FORMAT_HELP.zip}" aria-label="Everything: ZIP (${FORMAT_HELP.zip})">ZIP</a></span></li>`;-- return html`<details class="ovv-dl" data-ovv-dl><summary class="ovv-btn"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</summary>++ if (item.thumbnail_url) rows.push(row({ icon: 'image', label: 'Cover picture', about: null, links: [{ href: `${at}/thumbnail`, ext: 'jpg', text: 'Image' }] }));++ rows.push(row({ icon: 'info', label: 'Details', about: 'Title, channel, dates, summary', links: [{ href: `${at}/info.json`, ext: 'json' }] }));++ const everything = html`<li class="ovv-dl-row ovv-dl-row--all"><i class="ovg-fill" data-ov-icon="archive" aria-hidden="true"></i><span class="ovv-dl-what"><span class="ovv-dl-label">Everything</span><span class="ovv-dl-about">All of the above but the video, in one .zip</span></span><span class="ovv-dl-formats"><a class="ovv-dl-format ovv-dl-format--main" href="${at}/all.zip" download rel="nofollow" title="${FORMAT_HELP.zip}" aria-label="Everything: ZIP (${FORMAT_HELP.zip})">ZIP</a></span></li>`;++ return html`<details class="ovv-dl" data-ovv-dl><summary class="ovv-btn"><i class="ovg-fill" data-ov-icon="download" aria-hidden="true"></i> Download</summary><div class="ovv-dl-menu" role="group" aria-label="Download this ${kind === 'clip' ? 'clip' : 'recording'}">--<div class="ovv-dl-head"><span>Download this ${kind === 'clip' ? 'clip' : 'recording'}</span><button type="button" class="ovv-dl-close" data-ovv-dl-close aria-label="Close"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button></div>++<div class="ovv-dl-head"><span>Download this ${kind === 'clip' ? 'clip' : 'recording'}</span><button type="button" class="ovv-dl-close" data-ovv-dl-close aria-label="Close"><i class="ovg-fill" data-ov-icon="close" aria-hidden="true"></i></button></div><ul class="ovv-dl-list">${rows}${everything}</ul><p class="ovv-dl-hint">Every format says what it is for.</p></div></details>`;@@@ -129,12 +129,12 @@ ${render(o.body)}// (Shared 4.22). Search is the navbar's magnifier, which offers this site's own search first; the page without// JavaScript keeps its Search link.const NAV_LINKS = [-- { label: 'Videos', href: '/', icon: 'fa-play' },-- { label: 'Search', href: '/search', icon: 'fa-magnifying-glass', noscriptOnly: true },-- { label: 'Upload', href: '/upload', icon: 'fa-upload' },-- { label: 'Record', href: '/record', icon: 'fa-video' },-- { label: 'Your videos', href: '/me/videos', icon: 'fa-photo-film' },-- { label: 'Playlists', href: '/playlists', icon: 'fa-list-check', more: true },++ { label: 'Videos', href: '/', icon: 'play' },++ { label: 'Search', href: '/search', icon: 'search', noscriptOnly: true },++ { label: 'Upload', href: '/upload', icon: 'upload' },++ { label: 'Record', href: '/record', icon: 'video' },++ { label: 'Your videos', href: '/me/videos', icon: 'film' },++ { label: 'Playlists', href: '/playlists', icon: 'steps', more: true },];// A signed-out copy is never reused without asking: Cloudflare drops `Vary` (it keeps only Accept-Encoding), so a@@@ -416,7 +416,7 @@ function commentItem(c, depth = 0) {<p class="ovv-comment-text">${text}</p>${files.length ? html`<ul>${files.map((file) => html`<li><a href="https://openvibe.media/o/${file.id}" rel="noopener noreferrer">${file.name || 'Attached file'}</a></li>`)}</ul>` : ''}${shownReplies.map((reply) => commentItem(reply, 1))}--${replies > 0 ? html`<p class="ovv-comment-replies"><i class="fa-regular fa-comments" aria-hidden="true"></i> ${replies} ${replies === 1 ? 'reply' : 'replies'}${replies > shownReplies.length ? html` · ${replies - shownReplies.length} more` : ''}</p>` : ''}++${replies > 0 ? html`<p class="ovv-comment-replies"><i data-ov-icon="chat" aria-hidden="true"></i> ${replies} ${replies === 1 ? 'reply' : 'replies'}${replies > shownReplies.length ? html` · ${replies - shownReplies.length} more` : ''}</p>` : ''}</article>`;}@@@ -569,12 +569,12 @@ function cardPlaceholder(o, icon) {const face = `${faceBase}/avatar/${name}?s=160`;return html`<span class="ovv-card-placeholder ovv-card-who"><img class="ovv-who-bg" src="${face}" alt="" loading="lazy" decoding="async"><img class="ovv-who-face" src="${face}" alt="" loading="lazy" decoding="async"></span>`;}-- return html`<span class="ovv-card-placeholder"><i class="fa-solid ${icon}"></i></span>`;++ return html`<span class="ovv-card-placeholder"><i class="ovg-fill" data-ov-icon="${icon}"></i></span>`;}function card(o) {const channel = o.channel ? html`<a href="${o.channelHref}">@${o.channel}</a>` : null;-- const icon = o.live ? 'fa-tower-broadcast' : (o.kind === 'clip' ? 'fa-scissors' : 'fa-video');++ const icon = o.live ? 'live' : (o.kind === 'clip' ? 'scissors' : 'video');const labels = o.labels || [];const media = html`<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : cardPlaceholder(o, icon)}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag${o.live ? ' ovv-tag--live' : ''}">${o.badge}</span>` : ''}</a>`;return html`<article class="ovv-card"${labelsUi.attrs(labels, { owner: o.owner || o.channel })}>@@@ -689,7 +689,7 @@ function createVideoRoutes(ctx) {const partyLink = (path, title, item) => {const file = item ? fileOf(item) : null;if (!file) return '';-- return html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(file)}&source_url=${encodeURIComponent(`${base}${path}`)}&title=${encodeURIComponent(String(title || 'Watch party'))}"><i class="fa-solid fa-users" aria-hidden="true"></i> Watch together</a>`;++ return html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(file)}&source_url=${encodeURIComponent(`${base}${path}`)}&title=${encodeURIComponent(String(title || 'Watch party'))}"><i class="ovg-fill" data-ov-icon="members" aria-hidden="true"></i> Watch together</a>`;};const hasSessionCookie = (req) => Boolean(req.cookies && (req.cookies[ACCESS_COOKIE] || req.cookies[REFRESH_COOKIE]));@@@ -793,24 +793,24 @@ function createVideoRoutes(ctx) {// Who can see it, as three choices with what each means (owner, 2026-10-27: the page had no clear way to make a// recording private or unlisted). Radios, so the form works without JavaScript.const VISIBILITY_LOOK = {-- public: ['fa-solid fa-earth-americas', 'Listed and searchable'],-- unlisted: ['fa-solid fa-link', 'Only people with the link'],-- private: ['fa-solid fa-lock', 'Only you (and staff)'],++ public: ['globe', 'Listed and searchable'],++ unlisted: ['link', 'Only people with the link'],++ private: ['lock', 'Only you (and staff)'],};-- const visibilityChoices = (current) => html`<fieldset class="ovv-vis"><legend>Who can see it</legend>${VISIBILITIES.map((v) => html`<label class="ovv-vis-opt"><input type="radio" name="visibility" value="${v}"${v === current ? ' checked' : ''}><span class="ovv-vis-card"><i class="${VISIBILITY_LOOK[v][0]}" aria-hidden="true"></i><b>${visibilityLabel(v)}</b><small>${VISIBILITY_LOOK[v][1]}</small></span></label>`)}</fieldset>`;++ const visibilityChoices = (current) => html`<fieldset class="ovv-vis"><legend>Who can see it</legend>${VISIBILITIES.map((v) => html`<label class="ovv-vis-opt"><input type="radio" name="visibility" value="${v}"${v === current ? ' checked' : ''}><span class="ovv-vis-card"><i class="ovg-fill" data-ov-icon="${VISIBILITY_LOOK[v][0]}" aria-hidden="true"></i><b>${visibilityLabel(v)}</b><small>${VISIBILITY_LOOK[v][1]}</small></span></label>`)}</fieldset>`;/** The owner's badge in the meta line: who can see it now. */-- const visibilityBadge = (v) => html`<span class="ovv-vis-badge ovv-vis-badge--${VISIBILITIES.includes(v) ? v : 'public'}"><i class="${(VISIBILITY_LOOK[v] || VISIBILITY_LOOK.public)[0]}" aria-hidden="true"></i> ${visibilityLabel(v || 'public')}</span>`;++ const visibilityBadge = (v) => html`<span class="ovv-vis-badge ovv-vis-badge--${VISIBILITIES.includes(v) ? v : 'public'}"><i class="ovg-fill" data-ov-icon="${(VISIBILITY_LOOK[v] || VISIBILITY_LOOK.public)[0]}" aria-hidden="true"></i> ${visibilityLabel(v || 'public')}</span>`;/** The owner's Edit link in More opens the panel under the player. */-- const editButton = (what) => html`<a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage" title="Edit this ${what}: title, description, who can see it"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</a>`;++ const editButton = (what) => html`<a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage" title="Edit this ${what}: title, description, who can see it"><i class="ovg-fill" data-ov-icon="pencil" aria-hidden="true"></i> Edit</a>`;const titleLine = (kind, title) => html`<div class="ovv-title-line"><span class="ovv-kind">${kind}</span><h1 class="ovv-title">${title}</h1></div>`;-- const likeButton = (state, kind, id) => html`<button class="ovv-btn ovv-like" type="button" data-ovv-like="${kind}:${id}" aria-pressed="${state.liked ? 'true' : 'false'}" title="Like this ${kind === 'clip' ? 'clip' : 'recording'}"><i class="fa-regular fa-heart" aria-hidden="true"></i> Like <span class="ovv-like-count">${state.count}</span></button>`;++ const likeButton = (state, kind, id) => html`<button class="ovv-btn ovv-like" type="button" data-ovv-like="${kind}:${id}" aria-pressed="${state.liked ? 'true' : 'false'}" title="Like this ${kind === 'clip' ? 'clip' : 'recording'}"><i data-ov-icon="heart" aria-hidden="true"></i> Like <span class="ovv-like-count">${state.count}</span></button>`;const channelRow = (username, name, followers) => {if (!username) return '';const channel = `${livePublic}/@${username}`;const count = Number(followers);-- return html`<div class="ovv-byline"><a class="ovv-avatar" href="${channel}" aria-hidden="true" tabindex="-1">${String(name || username).slice(0, 1).toUpperCase()}${live.isUsername(String(username)) ? html`<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy">` : ''}</a><div class="ovv-byline-text"><a class="ovv-channel-name" href="${channel}">@${username}</a>${Number.isSafeInteger(count) && count >= 0 ? html`<span class="ovv-meta">${count.toLocaleString('en-US')} ${count === 1 ? 'follower' : 'followers'}</span>` : ''}</div><a class="ovv-btn ovv-follow" href="${channel}" title="Follow ${name || username} on OpenVibe.Live"><i class="fa-solid fa-plus" aria-hidden="true"></i> Follow</a></div>`;++ return html`<div class="ovv-byline"><a class="ovv-avatar" href="${channel}" aria-hidden="true" tabindex="-1">${String(name || username).slice(0, 1).toUpperCase()}${live.isUsername(String(username)) ? html`<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy">` : ''}</a><div class="ovv-byline-text"><a class="ovv-channel-name" href="${channel}">@${username}</a>${Number.isSafeInteger(count) && count >= 0 ? html`<span class="ovv-meta">${count.toLocaleString('en-US')} ${count === 1 ? 'follower' : 'followers'}</span>` : ''}</div><a class="ovv-btn ovv-follow" href="${channel}" title="Follow ${name || username} on OpenVibe.Live"><i class="ovg-fill" data-ov-icon="plus" aria-hidden="true"></i> Follow</a></div>`;};-- const watchMore = ({ path, manageable, kind, id, items }) => actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: html`<div class="ovv-more-actions"><button type="button" data-ovv-copy-time="${base}${path}"><i class="fa-regular fa-copy" aria-hidden="true"></i> Copy link at time</button><button type="button" data-ovv-pip hidden><i class="fa-regular fa-window-restore" aria-hidden="true"></i> Picture in picture</button><a href="mailto:[email protected]?subject=${encodeURIComponent(`Report ${kind} ${base}${path}`)}"><i class="fa-regular fa-flag" aria-hidden="true"></i> Report</a>${manageable ? html`${editButton(kind === 'clip' ? 'clip' : 'recording')}${deleteAction({ action: `/${kind === 'clip' ? 'c' : 'v'}/${id}`, what: kind === 'clip' ? 'clip' : 'recording' })}` : ''}${menuLinks(items)}</div>` });++ const watchMore = ({ path, manageable, kind, id, items }) => actionMenu({ icon: 'more', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: html`<div class="ovv-more-actions"><button type="button" data-ovv-copy-time="${base}${path}"><i data-ov-icon="copy" aria-hidden="true"></i> Copy link at time</button><button type="button" data-ovv-pip hidden><i data-ov-icon="window-restore" aria-hidden="true"></i> Picture in picture</button><a href="mailto:[email protected]?subject=${encodeURIComponent(`Report ${kind} ${base}${path}`)}"><i data-ov-icon="flag" aria-hidden="true"></i> Report</a>${manageable ? html`${editButton(kind === 'clip' ? 'clip' : 'recording')}${deleteAction({ action: `/${kind === 'clip' ? 'c' : 'v'}/${id}`, what: kind === 'clip' ? 'clip' : 'recording' })}` : ''}${menuLinks(items)}</div>` });const watchFacts = (views, date, duration, visibility) => {const count = Math.max(0, Number(views) || 0);const stamp = date instanceof Date ? date.getTime() : Date.parse(String(date || ''));@@@ -841,12 +841,12 @@ ${ai ? html`<p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</spanfunction clipAction(req, id, vod) {if (vod.is_recording) return '';if (!signedIn(req)) {-- 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>`;++ 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="ovg-fill" data-ov-icon="scissors" aria-hidden="true"></i> Clip</a>`;}const dur = Math.floor(Number(vod.duration_seconds) || 0);// The whole video of a stream recorded in parts: its times are the video's, and a clip may cross a join.const whole = !!(vod.recording && vod.recording.joined && String(vod.recording.vod_id) === String(id));-- 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>++ return actionMenu({ icon: '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">${whole ? html`<input type="hidden" name="recording_time" value="1">` : ''}<div class="ovv-clip-times">@@@ -864,7 +864,7 @@ ${whole ? html`<input type="hidden" name="recording_time" value="1">` : ''}function ownerPanel(kind, id, item) {const what = kind === 'vod' ? 'recording' : 'clip';const action = `/${kind === 'vod' ? 'v' : 'c'}/${id}`;-- return html`<details class="ovv-panel ovv-owner" id="manage"><summary><h2 id="h-owner"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit this ${what}</h2><span class="ovv-fine">Title, ${kind === 'vod' ? 'description, ' : ''}who can see it, labels</span></summary>++ return html`<details class="ovv-panel ovv-owner" id="manage"><summary><h2 id="h-owner"><i class="ovg-fill" data-ov-icon="pencil" aria-hidden="true"></i> Edit this ${what}</h2><span class="ovv-fine">Title, ${kind === 'vod' ? 'description, ' : ''}who can see it, labels</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>` : ''}@@@ -1064,10 +1064,10 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}// ── 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)}" title="Sign in to save it to a playlist"><i class="fa-solid fa-plus" aria-hidden="true"></i> Save</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="ovg-fill" data-ov-icon="plus" aria-hidden="true"></i> Save</a>`;}const lists = await playlists.list(ownerOf(req));-- 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>++ return html`<details class="ovv-save ovv-pop" data-ovv-menu><summary class="ovv-btn" title="Save to a playlist"><i class="ovg-fill" data-ov-icon="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}">@@@ -1086,7 +1086,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}if (kind === 'upload') {const row = await ctx.videoUploads.bySeq(itemId);if (!row || row.status !== 'ready' || row.state !== 'active' || row.visibility === 'private') return null;-- return { href: watchHref('upload', row.id, row.watch_id), title: row.title, thumb: httpsUrl(row.thumbnail_url), channel: row.owner_username || null, duration: row.duration_seconds, icon: 'fa-film' };++ return { href: watchHref('upload', row.id, row.watch_id), title: row.title, thumb: httpsUrl(row.thumbnail_url), channel: row.owner_username || null, duration: row.duration_seconds, icon: 'film' };}let info = null;try {@@@ -1100,7 +1100,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}thumb: httpsUrl(info.thumbnail_url),channel: info.username || info.display_name || null,duration: info.duration_seconds != null ? info.duration_seconds : info.duration,-- icon: kind === 'clip' ? 'fa-scissors' : 'fa-video',++ icon: kind === 'clip' ? 'scissors' : 'video',};}@@@ -1153,7 +1153,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}const info = await itemInfo(next.kind, nextId);const label = (info && info.title) || `${next.kind === 'vod' ? 'VOD' : next.kind === 'clip' ? 'Clip' : 'Video'} ${nextId}`;return {-- html: html`<section class="ovv-panel ovv-upnext" aria-label="Up next"><p class="ovv-upnext-in">Up next in <a href="/playlists/${pl.id}">${pl.title}</a></p><a class="ovv-upnext-link" href="${nextUrl}"><i class="fa-solid fa-play" aria-hidden="true"></i> <span>${label}</span></a><p class="ovv-fine">Plays on its own when this one ends.</p></section>`,++ html: html`<section class="ovv-panel ovv-upnext" aria-label="Up next"><p class="ovv-upnext-in">Up next in <a href="/playlists/${pl.id}">${pl.title}</a></p><a class="ovv-upnext-link" href="${nextUrl}"><i class="ovg-fill" data-ov-icon="play" aria-hidden="true"></i> <span>${label}</span></a><p class="ovv-fine">Plays on its own when this one ends.</p></section>`,nextUrl,};}@@@ -1169,7 +1169,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}return html`<li class="ovv-playlist-row"><div class="ovv-playlist-main"><a class="ovv-playlist-title" href="/playlists/${p.id}">${p.title}</a><span class="ovv-meta">${visibilityLabel(p.visibility)} · ${n} item${n === 1 ? '' : 's'} · updated ${dateLabel(p.updated_at)}</span></div>--<details class="ovv-pl-edit"><summary class="ovv-btn"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</summary>++<details class="ovv-pl-edit"><summary class="ovv-btn"><i class="ovg-fill" data-ov-icon="pencil" aria-hidden="true"></i> Edit</summary><div class="ovv-playlist-actions"><form class="ovv-inline" method="post" action="/playlists/${p.id}/rename"><input name="title" type="text" maxlength="${playlists.TITLE_MAX}" value="${p.title}" aria-label="New title" required> <button class="ovv-btn" type="submit">Rename</button></form><form class="ovv-inline" method="post" action="/playlists/${p.id}/visibility"><select name="visibility" aria-label="Visibility">${playlists.VISIBILITIES.map((v) => html`<option value="${v}"${v === p.visibility ? ' selected' : ''}>${visibilityLabel(v)}</option>`)}</select> <button class="ovv-btn" type="submit">Change</button></form>@@@ -1341,10 +1341,10 @@ ${feedKind === 'following' ? html`<p class="muted">See updates from followed chaseo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Video', url: `${base}/` }, { name: cat.label, url: `${base}/category/${slug}` }]),seo.jsonLd.itemList(title, items.map((it) => ({ name: it.title || 'Stream recording', url: `${base}${it.watch_id ? `/watch?v=${it.watch_id}` : `/v/${it.id}`}` })), { url: `${base}/category/${slug}` }),],-- body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true"><i class="fa-solid ${cat.icon}"></i></span><div><h1>${cat.label}</h1><p class="ovv-meta">${cat.blurb}${count ? ` ${n(count.count)} ${count.count === 1 ? 'recording' : 'recordings'}.` : ''}</p></div></header>++ body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true"><i class="ovg-fill" data-ov-icon="${cat.icon}"></i></span><div><h1>${cat.label}</h1><p class="ovv-meta">${cat.blurb}${count ? ` ${n(count.count)} ${count.count === 1 ? 'recording' : 'recordings'}.` : ''}</p></div></header><section class="ovv-section" aria-label="${title}">${grid(items.map((it) => feedCard(it)), { title: 'Nothing in this category yet', text: 'Recordings of this kind appear here when a streamer goes live.', action: { href: '/', label: 'See the latest recordings', icon: 'play' } })}</section>${pageOf.next ? html`<p class="ovv-pagination"><a class="ovv-btn" href="/category/${slug}?after=${encodeURIComponent(pageOf.next)}" rel="next">Older recordings</a></p>` : ''}--${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h2 id="h-other">Other categories</h2><ul class="ovv-shelf" role="list">${others.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="fa-solid ${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}`,++${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h2 id="h-other">Other categories</h2><ul class="ovv-shelf" role="list">${others.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="ovg-fill" data-ov-icon="${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}`,});});@@@ -1352,7 +1352,7 @@ ${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h// Without ?type the three kinds show their best eight each; ?type=vod|clip|video pages one kind (24 at a time, Search's// cursor), and recordings filter by category. Results are noindex; the empty search page is not.const SEARCH_KINDS = [['vod', 'Stream recordings'], ['clip', 'Clips'], ['video', 'Uploads']];-- const searchForm = (q = '') => html`<form class="ovv-search" method="get" action="/search" role="search"><label class="sr-only" for="ovv-q">Search videos</label><input id="ovv-q" name="q" type="search" value="${q}" maxlength="200" placeholder="Search recordings, clips and uploads" required><button class="ovv-btn ovv-btn--primary" type="submit"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i> Search</button></form>`;++ const searchForm = (q = '') => html`<form class="ovv-search" method="get" action="/search" role="search"><label class="sr-only" for="ovv-q">Search videos</label><input id="ovv-q" name="q" type="search" value="${q}" maxlength="200" placeholder="Search recordings, clips and uploads" required><button class="ovv-btn ovv-btn--primary" type="submit"><i class="ovg-fill" data-ov-icon="search" aria-hidden="true"></i> Search</button></form>`;async function searchCards(kind, items) {if (kind === 'video') {const rows = await Promise.all(items.map((x) => settle(ctx.videoUploads.byId(x.id))));@@@ -1374,7 +1374,7 @@ ${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><hindex: true, title: 'Search OpenVibe.Video', description: 'Search stream recordings and clips from OpenVibe.Live and videos people uploaded to OpenVibe.Video.',crumbs: [{ label: 'Home', href: '/' }, { label: 'Search' }],body: html`<h1>Search videos</h1>${searchForm()}--${categories.length ? html`<section class="ovv-section" aria-labelledby="h-cats"><h2 id="h-cats">Or browse by category</h2><ul class="ovv-shelf" role="list">${categories.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="fa-solid ${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}`,++${categories.length ? html`<section class="ovv-section" aria-labelledby="h-cats"><h2 id="h-cats">Or browse by category</h2><ul class="ovv-shelf" role="list">${categories.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="ovg-fill" data-ov-icon="${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}`,});}const kinds = kind ? [kind] : SEARCH_KINDS.map(([k]) => k);@@@ -1563,13 +1563,13 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv// ("Part 1 (this one) · Part 2 · Part 1", 2026-10-27).const seenParts = new Set();const parts = vod.part && hist && hist !== fail && Array.isArray(hist.parts) ? hist.parts.filter((p) => p && live.isId(p.vod_id) && !seenParts.has(String(p.vod_id)) && seenParts.add(String(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 ${i + 1} (this one)</b>` : html`<a href="/v/${p.vod_id}">Part ${i + 1}</a>`])}</p>` : '';++ const partNav = parts.length > 1 ? html`<p class="ovv-about-parts"><i class="ovg-fill" data-ov-icon="layers" 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 ${i + 1} (this one)</b>` : html`<a href="/v/${p.vod_id}">Part ${i + 1}</a>`])}</p>` : '';const ar = hist && hist !== fail ? startAspect(hist, vod) : null;const shapeAttr = ar ? html` style="--ovv-ar: ${String(ar)}" data-ovv-ar="${String(ar)}"` : '';// 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 (channelName) moreItems.push({ href: liveChannel, icon: 'live', label: 'Channel on OpenVibe.Live', hint: 'Live streams and chat' });++ if (threadUrl) moreItems.push({ href: threadUrl, icon: 'chat', label: 'Discuss on OpenVibe.Community' });// Chat replay, chapters and the transcript, beside the player.const side = companion({ kind: 'vod', id, watchId: vod.watch_id || null, chat: chatRows, chapters: mems, transcript: segs, long: Number(vod.duration_seconds) >= 3600 });const likes = await likesOf(req, 'vod', id);@@@ -1600,7 +1600,7 @@ ${labelsUi.chips(labelsUi.inForce(vod))}${channelRow(vod.username, channelName, vod.follower_count)}<div class="ovv-actions-shell"><div class="ovv-actions">${likeButton(likes, 'vod', id)}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${saveForm}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${partyLink(watchPath, vod.title || 'Stream recording', vod)}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${ownOnly ? '' : clipAction(req, id, vod)}${watchMore({ path: watchPath, manageable, kind: 'vod', id, items: moreItems })}</div></div></div>--${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}++${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="ovg-fill" data-ov-icon="circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}</div></div>${manageable ? ownerPanel('vod', id, vod) : ''}@@@ -1681,9 +1681,9 @@ ${more || ''}const clipAt = clip.start_time != null ? clock(Number(clip.start_time)) : null;const clipCredit = !clip.vod_id ? '': sourceVod === 'down'-- ? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording. OpenVibe.Live did not answer just now, so its title is not shown.</p>`++ ? html`<p class="ovv-source"><i class="ovg-fill" data-ov-icon="film" aria-hidden="true"></i> Cut from a stream recording. OpenVibe.Live did not answer just now, so its title is not shown.</p>`: !sourceVod-- ? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording that is not public any more.</p>`++ ? html`<p class="ovv-source"><i class="ovg-fill" data-ov-icon="film" aria-hidden="true"></i> Cut from a stream recording that is not public any more.</p>`: html`<p class="ovv-source">Cut from <a href="${fullRecording}">${sourceTitle || 'the recording it came from'}</a>${clipAt ? html` at ${clipAt}` : ''}${sourceWho ? html` · ${sourceWho}'s stream` : ''}</p>`;const facts = watchFacts(clip.view_count, clip.created_at, clip.duration_seconds != null ? clip.duration_seconds : clip.duration, clip.visibility);@@@ -1692,8 +1692,8 @@ ${more || ''}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 (clip.source_streamer_username) moreItems.push({ href: `${livePublic}/@${clip.source_streamer_username}`, icon: 'live', label: 'Channel on OpenVibe.Live', hint: 'Live streams and chat' });++ if (threadUrl) moreItems.push({ href: threadUrl, icon: 'chat', label: 'Discuss on OpenVibe.Community' });// A clip's transcript, when it has one, beside the player as on a recording.const side = companion({ kind: 'clip', id, watchId: clip.watch_id || null, chat: null, chapters: [], transcript: segs, long: false });const likes = await likesOf(req, 'clip', id);@@@ -1717,14 +1717,14 @@ ${more || ''}<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>`++${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv-reload="8"><p><i class="ovg-fill" data-ov-icon="scissors" aria-hidden="true"></i> This clip is being cut. It plays here in a moment; the page checks again by itself.</p></div>`: html`<div class="ovv-player-wrap"${labelsUi.attrs(labelsUi.inForce(clip), { owner: clip.username, opened: true })}><video class="ovv-player" data-ov-player${miniAttr(clip.title || 'Clip', clip.source_streamer_display_name || clip.display_name || clip.username, watchPath, fileOf(clip))} controls preload="metadata" playsinline${avatarAttr(clip.username, config.networkUrl)}${posterAttr(httpsUrl(clip.thumbnail_url))} src="${fileOf(clip) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}<div class="ovv-head">${titleLine(aiClip ? 'AI clip' : 'Clip', clip.title || 'Clip')}${labelsUi.chips(labelsUi.inForce(clip))}<div class="ovv-head-row">${channelRow(clip.source_streamer_username || clip.username, clip.source_streamer_display_name || clip.display_name || clip.username, clip.follower_count)}--<div class="ovv-actions-shell"><div class="ovv-actions">${likeButton(likes, 'clip', id)}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${cutting ? '' : partyLink(watchPath, clip.title || 'Clip', clip)}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${fullRecording ? html`<a class="ovv-btn" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${watchMore({ path: watchPath, manageable, kind: 'clip', id, items: moreItems })}</div></div>++<div class="ovv-actions-shell"><div class="ovv-actions">${likeButton(likes, 'clip', id)}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${cutting ? '' : partyLink(watchPath, clip.title || 'Clip', clip)}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${fullRecording ? html`<a class="ovv-btn" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="ovg-fill" data-ov-icon="film" aria-hidden="true"></i> Full recording</a>` : ''}${watchMore({ path: watchPath, manageable, kind: 'clip', id, items: moreItems })}</div></div></div></div></div>@@@ -1892,7 +1892,7 @@ ${more || ''}title: `@${username}`,styles: ['kit.css'],description: `Videos, recordings and clips from @${username} on OpenVibe.Video.`,-- body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy"></span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p>${raw(shareButton({ url: `${base}/@${username}`, title: `@${username}`, text: `Videos, recordings and clips from @${username} on OpenVibe.Video.`, image: `${config.networkUrl}/avatar/${username}` }))}</div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>++ body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy"></span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p>${raw(shareButton({ url: `${base}/@${username}`, title: `@${username}`, text: `Videos, recordings and clips from @${username} on OpenVibe.Video.`, image: `${config.networkUrl}/avatar/${username}` }))}</div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="ovg-fill" data-ov-icon="live" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Videos</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), { title: 'No recordings yet', text: 'Recordings of this channel’s streams appear here.' })}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), { title: 'No clips yet', text: 'Clips cut from this channel appear here.' })}</section>` : ''}@@@ -1910,7 +1910,7 @@ ${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists' }],body: html`<h1>Your playlists</h1><section class="ovv-section" aria-labelledby="h-create"><h2 id="h-create">New playlist</h2>--<form class="ovv-save-form ovv-panel" method="post" action="/playlists"><label for="new-title">Title</label> <input id="new-title" name="title" type="text" maxlength="${playlists.TITLE_MAX}" required placeholder="Watch later"> <label for="new-vis">Who can see it</label> <select id="new-vis" name="visibility">${playlists.VISIBILITIES.map((v) => html`<option value="${v}"${v === 'private' ? ' selected' : ''}>${visibilityLabel(v)}</option>`)}</select> <button class="ovv-btn ovv-btn--primary" type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Create</button></form></section>++<form class="ovv-save-form ovv-panel" method="post" action="/playlists"><label for="new-title">Title</label> <input id="new-title" name="title" type="text" maxlength="${playlists.TITLE_MAX}" required placeholder="Watch later"> <label for="new-vis">Who can see it</label> <select id="new-vis" name="visibility">${playlists.VISIBILITIES.map((v) => html`<option value="${v}"${v === 'private' ? ' selected' : ''}>${visibilityLabel(v)}</option>`)}</select> <button class="ovv-btn ovv-btn--primary" type="submit"><i class="ovg-fill" data-ov-icon="plus" aria-hidden="true"></i> Create</button></form></section><section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2>${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>` : raw(kit.empty({ icon: 'list', title: 'No playlists yet', text: 'Save a recording or clip from its watch page.', action: { href: '/', label: 'Find something to watch', icon: 'play' } }))}</section>`,});@@@ -1994,7 +1994,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>const { href, thumb, title, channel, duration: dur } = info;listLd.push({ '@type': 'ListItem', position: listLd.length + 1, url: `${base}${href}`, name: String(title).slice(0, 110) });rows.push(html`<li class="ovv-item-row">--<a class="ovv-item-media" href="${href}" tabindex="-1" aria-hidden="true">${thumb ? thumbImg(thumb, '132px') : html`<span class="ovv-card-placeholder"><i class="fa-solid ${info.icon}"></i></span>`}${dur != null ? html`<span class="ovv-dur">${durationLabel(dur)}</span>` : ''}</a>++<a class="ovv-item-media" href="${href}" tabindex="-1" aria-hidden="true">${thumb ? thumbImg(thumb, '132px') : html`<span class="ovv-card-placeholder"><i class="ovg-fill" data-ov-icon="${info.icon}"></i></span>`}${dur != null ? html`<span class="ovv-dur">${durationLabel(dur)}</span>` : ''}</a><div class="ovv-playlist-main"><a class="ovv-item-title" href="${href}">${title}</a>${channel ? html`<span class="ovv-meta">@${channel}</span>` : ''}</div>${isOwner ? itemControls(pl.id, it) : ''}</li>`);@@@ -2015,7 +2015,7 @@ ${isOwner ? itemControls(pl.id, it) : ''}${pl.description ? html`<p class="ovv-lede">${pl.description}</p>` : ''}<p class="ovv-meta">${visibilityLabel(pl.visibility)} · ${items.length} item${items.length === 1 ? '' : 's'} · updated ${dateLabel(pl.updated_at)}</p><p class="ovv-meta">Curated by ${ownerBy}</p>--${playAllHref ? html`<p><a class="ovv-btn ovv-btn--primary" href="${playAllHref}"><i class="fa-solid fa-play" aria-hidden="true"></i> Play all</a></p>` : ''}</header>++${playAllHref ? html`<p><a class="ovv-btn ovv-btn--primary" href="${playAllHref}"><i class="ovg-fill" data-ov-icon="play" aria-hidden="true"></i> Play all</a></p>` : ''}</header>${items.length ? html`<ol class="ovv-item-list">${rows}</ol>` : html`<p class="muted">This playlist is empty.</p>`}${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down buttons, or remove them. <a href="/playlists">Back to your playlists</a>.</p>` : ''}`,});@@@ -18,15 +18,15 @@ const PAGE = 24;// Live's stream categories (OpenVibe.Live server/ai/ai-analysis.js CATEGORIES), as people read them, with an icon each.const CATEGORIES = {-- gaming: { label: 'Gaming', icon: 'fa-gamepad', blurb: 'Games played live, from battle royales to sports games.' },-- irl: { label: 'IRL and chatting', icon: 'fa-comments', blurb: 'Talking, cooking and everyday life, on camera.' },-- desktop: { label: 'Desktop and tech', icon: 'fa-desktop', blurb: 'Screens shared live: coding, building software and tech.' },-- robot: { label: 'Robots', icon: 'fa-robot', blurb: 'Robots driven, built and tested live.' },-- outdoors: { label: 'Outdoors', icon: 'fa-mountain-sun', blurb: 'Streams from outside: walks, hikes and time in the open air.' },-- travel: { label: 'Travel and driving', icon: 'fa-route', blurb: 'On the road, in the air and out exploring.' },-- building: { label: 'Making and crafts', icon: 'fa-hammer', blurb: 'Building, fixing and making things by hand.' },-- music: { label: 'Music', icon: 'fa-music', blurb: 'Playing, mixing and making music live.' },-- other: { label: 'Everything else', icon: 'fa-shapes', blurb: 'Streams that fit no other shelf.' },++ gaming: { label: 'Gaming', icon: 'game', blurb: 'Games played live, from battle royales to sports games.' },++ irl: { label: 'IRL and chatting', icon: 'chat', blurb: 'Talking, cooking and everyday life, on camera.' },++ desktop: { label: 'Desktop and tech', icon: 'computer', blurb: 'Screens shared live: coding, building software and tech.' },++ robot: { label: 'Robots', icon: 'robot', blurb: 'Robots driven, built and tested live.' },++ outdoors: { label: 'Outdoors', icon: 'mountain', blurb: 'Streams from outside: walks, hikes and time in the open air.' },++ travel: { label: 'Travel and driving', icon: 'route', blurb: 'On the road, in the air and out exploring.' },++ building: { label: 'Making and crafts', icon: 'hammer', blurb: 'Building, fixing and making things by hand.' },++ music: { label: 'Music', icon: 'music', blurb: 'Playing, mixing and making music live.' },++ other: { label: 'Everything else', icon: 'shapes', blurb: 'Streams that fit no other shelf.' },};const isCategory = (v) => Object.prototype.hasOwnProperty.call(CATEGORIES, String(v || ''));const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;@@@ -201,7 +201,7 @@ function mountUploadPages(r, h) {const done = Number(row.parts_received) * Number(row.part_size);const total = Number(row.size_bytes);return html`<li class="ovv-myrow" data-state="${state}">--<a class="ovv-mythumb" href="${watch}" tabindex="-1" aria-hidden="true">${row.thumbnail_url ? html`<img src="${row.thumbnail_url}" alt="" loading="lazy">` : html`<i class="fa-solid fa-film"></i>`}</a>++<a class="ovv-mythumb" href="${watch}" tabindex="-1" aria-hidden="true">${row.thumbnail_url ? html`<img src="${row.thumbnail_url}" alt="" loading="lazy">` : html`<i class="ovg-fill" data-ov-icon="film"></i>`}</a><div class="ovv-mybody"><div class="ovv-mytitleline"><a class="ovv-mytitle" href="${watch}">${row.title}</a><span class="ovv-mystate">${statusText(row)}</span></div><p class="ovv-mymeta">${meta}</p>@@@ -312,9 +312,9 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}<div class="ovv-my-head"><p class="ovv-lede">${fresh.length? `${fresh.length} video${fresh.length === 1 ? '' : 's'}, newest first. A processing video finishes in the background, so you can close the page.`: 'Everything you upload to OpenVibe.Video appears here, with its state and who can see it.'}</p>--<a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a></div>++<a class="ovv-btn ovv-btn--primary" href="/upload"><i class="ovg-fill" data-ov-icon="upload" aria-hidden="true"></i> Upload a video</a></div><form class="ovv-panel" method="post" action="/me/videos/share-default"><label>New videos <select name="audience">${audienceOptions(defaultAudience)}</select></label> <label><input type="checkbox" name="apply" value="1"> Apply to all my existing videos</label> <button class="ovv-btn" type="submit">Save default</button></form>--${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : html`<div class="ovv-my-empty"><i class="fa-solid fa-film" aria-hidden="true"></i><h2>No videos yet</h2><p>Choose a video and the upload starts while you fill in the title.</p><a class="ovv-btn ovv-btn--primary" href="/upload">Upload your first video</a></div>`}++${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : html`<div class="ovv-my-empty"><i class="ovg-fill" data-ov-icon="film" aria-hidden="true"></i><h2>No videos yet</h2><p>Choose a video and the upload starts while you fill in the title.</p><a class="ovv-btn ovv-btn--primary" href="/upload">Upload your first video</a></div>`}<dialog class="ovv-rules" data-delete-sheet aria-label="Delete this video"><h2>Delete this video?</h2><p data-delete-message>Its file is removed from OpenVibe.Media too. This cannot be undone.</p>@@@ -505,7 +505,7 @@ ${what}${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}${/^[A-Za-z0-9_]{1,32}$/.test(channel) ? html`<img src="${ctx.config.networkUrl}/avatar/${channel}" alt="" loading="lazy">` : ''}</a>` : ''}<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}</div>--<div class="ovv-byline-actions">${saveForm}${ready && vod && fileOf(vod) ? html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(fileOf(vod))}&source_url=${encodeURIComponent(`${String(ctx.config.video.url).replace(/\/+$/, '')}${watchPath}`)}&title=${encodeURIComponent(String(row.title || 'Watch party'))}"><i class="fa-solid fa-users" aria-hidden="true"></i> Watch together</a>` : ''}${own && ready && row.state === 'active' && studioVideoEdit ? html`<a class="ovk-btn ovk-btn--quiet" href="${studioEditUrl(row)}">Edit in Studio</a>` : ''}${raw(shareButton({ cls: 'ovv-btn', url: `${ctx.config.video.url}${watchPath}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null, kind: 'video', id: row.id }))}${raw(askAgent({ text: `About the video “${row.title}” on OpenVibe.Video (${ctx.config.video.url}${watchPath}): ` }))}</div>++<div class="ovv-byline-actions">${saveForm}${ready && vod && fileOf(vod) ? html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(fileOf(vod))}&source_url=${encodeURIComponent(`${String(ctx.config.video.url).replace(/\/+$/, '')}${watchPath}`)}&title=${encodeURIComponent(String(row.title || 'Watch party'))}"><i class="ovg-fill" data-ov-icon="members" aria-hidden="true"></i> Watch together</a>` : ''}${own && ready && row.state === 'active' && studioVideoEdit ? html`<a class="ovk-btn ovk-btn--quiet" href="${studioEditUrl(row)}">Edit in Studio</a>` : ''}${raw(shareButton({ cls: 'ovv-btn', url: `${ctx.config.video.url}${watchPath}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null, kind: 'video', id: row.id }))}${raw(askAgent({ text: `About the video “${row.title}” on OpenVibe.Video (${ctx.config.video.url}${watchPath}): ` }))}</div></div>${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>@@@ -64,9 +64,9 @@ function streamDate(v) {/** What a kind of video is called on the page. */const KINDS = {-- recording: { icon: 'fa-solid fa-tower-broadcast', label: 'Stream recording' },-- clip: { icon: 'fa-solid fa-scissors', label: 'Clip' },-- upload: { icon: 'fa-solid fa-upload', label: 'Upload' },++ recording: { icon: 'live', label: 'Stream recording' },++ clip: { icon: 'scissors', label: 'Clip' },++ upload: { icon: 'upload', label: 'Upload' },};/**@@@ -94,7 +94,7 @@ function whatItIs({ kind, label = null, streamedAt = null, publishedAt = null, dif (len) facts.push(len);const v = Number(views);if (Number.isSafeInteger(v) && v > 0) facts.push(`${v.toLocaleString('en-US')} ${v === 1 ? 'view' : 'views'}`);-- return html`<p class="ovv-what"><span class="ovv-what-kind"><i class="${k.icon}" aria-hidden="true"></i> ${label || k.label}</span>${facts.length ? html`<span class="ovv-what-facts">${facts.map((f, i) => (i === 0 ? f : [html` · `, f]))}</span>` : ''}</p>`;++ return html`<p class="ovv-what"><span class="ovv-what-kind"><i class="ovg-fill" data-ov-icon="${k.icon}" aria-hidden="true"></i> ${label || k.label}</span>${facts.length ? html`<span class="ovv-what-facts">${facts.map((f, i) => (i === 0 ? f : [html` · `, f]))}</span>` : ''}</p>`;}/** A date as the value of a <time> element's datetime. */@@@ -107,20 +107,20 @@ function isoOf(v) {/** 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>++ 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="ovg-fill" data-ov-icon="${icon}" aria-hidden="true"></i>${iconOnly ? '' : html` ${label}`}</summary><div class="ovv-pop-panel" role="group" aria-label="${label}">${body}</div></details>`;}/** The owner's Delete in the action row: one tap opens the question, one more deletes (the route checks confirm=1). */function deleteAction({ action, what }) {-- return actionMenu({ icon: 'fa-solid fa-trash-can', label: 'Delete', title: `Delete this ${what}`, cls: 'ovv-deleter', body: html`<p class="ovv-pop-title">Delete this ${what}?</p>++ return actionMenu({ icon: 'trash', label: 'Delete', title: `Delete this ${what}`, cls: 'ovv-deleter', body: html`<p class="ovv-pop-title">Delete this ${what}?</p><p class="ovv-fine">It goes for good: nobody can watch it again, and it cannot be brought back.</p>--<form method="post" action="${action}/delete"><input type="hidden" name="confirm" value="1"><button class="ovv-btn ovv-btn--danger" type="submit"><i class="fa-solid fa-trash-can" aria-hidden="true"></i> Delete for good</button></form>` });++<form method="post" action="${action}/delete"><input type="hidden" name="confirm" value="1"><button class="ovv-btn ovv-btn--danger" type="submit"><i class="ovg-fill" data-ov-icon="trash" aria-hidden="true"></i> Delete for good</button></form>` });}/** 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>`;++ 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="ovg-fill" data-ov-icon="${it.icon}" aria-hidden="true"></i><span>${it.label}${it.hint ? html`<small>${it.hint}</small>` : ''}</span></a></li>`)}</ul>`;}const FORMATS = {@@@ -136,10 +136,10 @@ const FORMATS = {* 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>++ 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="ovg-fill" data-ov-icon="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>` : ''}++${copy ? html`<button type="button" class="ovv-mini-item" data-ovv-copy="${copy.href}" hidden><i data-ov-icon="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>`;}@@@ -200,7 +200,7 @@ function companion({ kind, id, watchId = null, chat, chapters, transcript, long${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.search ? html`<label class="ovv-tp-search" hidden data-ovv-search-wrap><i class="ovg-fill" data-ov-icon="search" aria-hidden="true"></i><input type="search" placeholder="Search the transcript" aria-label="Search the transcript" data-ovv-search></label>` : ''}${t.body}</section>`)}</section>`;@@@ -15,7 +15,7 @@ const fn = src.slice(src.indexOf('function cardPlaceholder('), src.indexOf('funcassert.ok(fn.includes("/^[A-Za-z0-9_]{1,32}$/.test(name)"), 'only a username becomes an avatar address');assert.ok(fn.includes('${faceBase}/avatar/${name}?s=160'), 'Network\'s avatar, at the size it is drawn');assert.ok(fn.includes('class="ovv-who-bg"') && fn.includes('class="ovv-who-face"'), 'the face over a blur of it');--assert.ok(fn.includes('<i class="fa-solid ${icon}"></i>'), 'the icon when there is no username');++assert.ok(fn.includes('<i class="ovg-fill" data-ov-icon="${icon}"></i>'), 'the icon when there is no username');assert.ok(src.includes('${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : cardPlaceholder(o, icon)}'), 'cards use it');assert.ok(/faceBase = \/\^https\?:\\\/\\\/\/\.test\(String\(config\.networkUrl/.test(src), 'the origin comes from the config, and only an http(s) one');for (const sel of ['.ovv-card-who .ovv-who-bg', '.ovv-card-who .ovv-who-face']) assert.ok(css.includes(sel), sel);@@@ -62,18 +62,21 @@ for (const f of LAYOUTS) {assert.ok(/shell\.page|ov-glyphs\.js|\/shared\/navbar/.test(t), `${f} renders pages without the navbar or /shared/ov-glyphs.js`);}--// Every fa- name in the pages, the scripts and the server views is drawn by a glyph or a brand.--const MOD = /^(solid|regular|light|thin|duotone|brands|sharp|classic|fw|lg|xl|xs|sm|2xs|2xl|\d+x|li|ul|border|inverse|pull-left|pull-right|rotate-\d+|rotate-by|flip-horizontal|flip-vertical|flip-both|width-auto|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|stack|stack-1x|stack-2x|layers|layers-text|layers-counter|swap-opacity|stack-text)$/;++// No Font Awesome markup is left: every icon names an OpenVibe glyph or brand, and the name is one that is drawn.const files = [];for (const r of CODE_ROOTS) files.push(...walk(path.join(ROOT, r), /\.(html|js)$/));const missing = new Map();for (const f of files) {-- for (const m of fs.readFileSync(f, 'utf8').matchAll(/\bfa-([a-z0-9]+(?:-[a-z0-9]+)*)\b/g)) {-- const n = m[1];-- if (MOD.test(n) || glyphs.fromFa(n) || brands.fromFa(n)) continue;-- if (!missing.has(n)) missing.set(n, rel(f));++ const t = fs.readFileSync(f, 'utf8');++ const fa = t.match(/class=["'][^"']*\bfa-[a-z][^"']*["']/);++ assert.ok(!fa, `${rel(f)} still uses Font Awesome markup: ${fa ? fa[0] : ''}`);++ assert.ok(!/\bfa-(solid|regular|brands|light)\b/.test(t), `${rel(f)} still names a Font Awesome style class`);++ for (const m of t.matchAll(/data-ov-(icon|brand)="([a-z0-9-]+)"/g)) {++ const [kind, name] = [m[1], m[2]];++ const drawn = kind === 'icon' ? glyphs.NAMES.includes(name) : Boolean(brands.BRANDS[name]);++ if (!drawn && !missing.has(`${kind}:${name}`)) missing.set(`${kind}:${name}`, rel(f));}}--assert.deepStrictEqual([...missing].map(([n, f]) => `fa-${n} (${f})`), [], 'every icon this site names is drawn by the OpenVibe glyphs or brands');++assert.deepStrictEqual([...missing].map(([n, f]) => `${n} (${f})`), [], 'every icon this site names is drawn by the OpenVibe glyphs or brands');--console.log(`no font awesome: ${pages.length} pages, ${files.length} files, every fa- name drawn`);++console.log(`no font awesome: ${pages.length} pages, ${files.length} files, every icon named is drawn`);@@@ -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(/<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(/<a class="ovv-btn" href="\/auth\/login\?next=%2Fv%2F101" title="Sign in to make a clip of up to 60 seconds">[^<]*<i class="ovg-fill" data-ov-icon="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');@@@ -625,7 +625,7 @@ function countOccurrences(haystack, needle) {// (this site's search first); the page without JavaScript keeps its Search link.await check('the navbar names the site\'s places: Videos, Upload, Record, Your videos, and Playlists under More; Search is the magnifier', async () => {const home = (await getVideo('/')).text;-- assert.match(home, /"links":\[\{"label":"Videos","href":"\/","icon":"fa-play","active":true\},\{"label":"Search","href":"\/search","icon":"fa-magnifying-glass","hidden":true,"active":false\},\{"label":"Upload","href":"\/upload","icon":"fa-upload","active":false\},\{"label":"Record","href":"\/record","icon":"fa-video","active":false\},\{"label":"Your videos","href":"\/me\/videos","icon":"fa-photo-film","active":false\},\{"label":"Playlists","href":"\/playlists","icon":"fa-list-check","more":true,"active":false\}\]/);++ assert.match(home, /"links":\[\{"label":"Videos","href":"\/","icon":"play","active":true\},\{"label":"Search","href":"\/search","icon":"search","hidden":true,"active":false\},\{"label":"Upload","href":"\/upload","icon":"upload","active":false\},\{"label":"Record","href":"\/record","icon":"video","active":false\},\{"label":"Your videos","href":"\/me\/videos","icon":"film","active":false\},\{"label":"Playlists","href":"\/playlists","icon":"steps","more":true,"active":false\}\]/);assert.ok(/"appBar":true/.test(home) && /"search":\{"page":"\/search","label":"OpenVibe.Video"\}/.test(home), 'the app bar, and the magnifier offers Video\'s search first');assert.ok(home.includes('data-ov-server-nav="true"') && home.includes('href="/playlists"') && home.includes('href="/search"'), 'the no-JS server nav links Playlists and Search too');});
@@ -116,7 +116,7 @@ endpoints `/api/health`, `/api/ready`, `/release.json`, `/metrics` are loopback
@@ -116,7 +116,7 @@ endpoints `/api/health`, `/api/ready`, `/release.json`, `/metrics` are loopback
- Never render a file's bytes into a page; escape every owner-supplied name with the `html` template.
- Never render a file's bytes into a page; escape every owner-supplied name with the `html` template.
- CSP: add every external origin to the `helmet` directives in [server/app.js](server/app.js) (Media images, Live HLS,
- CSP: add every external origin to the `helmet` directives in [server/app.js](server/app.js) (Media images, Live HLS,
Community, Events, Search). Do not widen `frameAncestors`/`objectSrc`. The HLS player is vendored on purpose.
Community, Events, Search). Do not widen `frameAncestors`/`objectSrc`. The HLS player is vendored on purpose.
-- **No Font Awesome** — use `openvibe-shared/glyphs` and `openvibe-shared/brands`; [test/no-font-awesome.test.js](test/no-font-awesome.test.js)
+- **No Font Awesome** — use `openvibe-shared/glyphs` and `openvibe-shared/brands`; the no-FA guard
checks every page and stylesheet, and a new `fa-` name must be one the glyphs/brands draw.
checks every page and stylesheet, and a new `fa-` name must be one the glyphs/brands draw.
- **No looks filter** — `openvibe-sdk/no-looks` is gone; AI text may describe people. Only sex acts/porn stay gated
- **No looks filter** — `openvibe-sdk/no-looks` is gone; AI text may describe people. Only sex acts/porn stay gated
([test/no-looks-guard.test.js](test/no-looks-guard.test.js)).
([test/no-looks-guard.test.js](test/no-looks-guard.test.js)).
@@ -150,7 +150,7 @@ endpoints `/api/health`, `/api/ready`, `/release.json`, `/metrics` are loopback
@@ -150,7 +150,7 @@ endpoints `/api/health`, `/api/ready`, `/release.json`, `/metrics` are loopback
`npm test` runs each file in its own process on PGlite with Network/Media stand-ins. The suite covers drive
`npm test` runs each file in its own process on PGlite with Network/Media stand-ins. The suite covers drive
upload/share/download/range, sync keys, agent grants, drops, video upload/record/playlists/AI/cut/trim/search/comments,
upload/share/download/range, sync keys, agent grants, drops, video upload/record/playlists/AI/cut/trim/search/comments,
-pics upload/gallery/album/labels/policy/sharing/rotate, radio, sharing, account data, host routing, no-Font-Awesome,
+pics upload/gallery/album/labels/policy/sharing/rotate, radio, sharing, account data, host routing, no Font Awesome,
no-looks, empty states, phone/contrast, secret-leak/redirect and size budgets. Run the narrowest file first, then the
no-looks, empty states, phone/contrast, secret-leak/redirect and size budgets. Run the narrowest file first, then the
suite; `npm run test:pg` for the PostgreSQL path. Always `node --check` changed files.
suite; `npm run test:pg` for the PostgreSQL path. Always `node --check` changed files.
@@ -281,7 +281,7 @@
@@ -281,7 +281,7 @@
if (wrap) wrap.hidden = false;
if (wrap) wrap.hidden = false;
const label = b.innerHTML;
const label = b.innerHTML;
const say = function (title, hint) {
const say = function (title, hint) {
- b.innerHTML = '<i class="fa-solid fa-check" aria-hidden="true"></i><span><b></b><small></small></span>';
+ b.innerHTML = '<i class="ovg-fill" data-ov-icon="check" aria-hidden="true"></i><span><b></b><small></small></span>';
b.querySelector('b').textContent = title;
b.querySelector('b').textContent = title;
b.querySelector('small').textContent = hint;
b.querySelector('small').textContent = hint;
setTimeout(function () { b.innerHTML = label; }, 2200);
setTimeout(function () { b.innerHTML = label; }, 2200);
@@ -458,7 +458,7 @@
@@ -458,7 +458,7 @@
mute.type = 'button';
mute.type = 'button';
const hide = document.createElement('button');
const hide = document.createElement('button');
hide.type = 'button';
hide.type = 'button';
- hide.innerHTML = '<i class="fa-solid fa-eye-slash" aria-hidden="true"></i>';
+ hide.innerHTML = '<i class="ovg-fill" data-ov-icon="eye-off" aria-hidden="true"></i>';
hide.title = 'Hide ' + p.title;
hide.title = 'Hide ' + p.title;
hide.setAttribute('aria-label', hide.title);
hide.setAttribute('aria-label', hide.title);
const grip = document.createElement('span');
const grip = document.createElement('span');
@@ -471,7 +471,7 @@
@@ -471,7 +471,7 @@
root.appendChild(box);
root.appendChild(box);
const item = { p: p, id: id, g: g, box: box, v: v, loaded: false, aspect: null };
const item = { p: p, id: id, g: g, box: box, v: v, loaded: false, aspect: null };
const paintMute = function () {
const paintMute = function () {
- mute.innerHTML = '<i class="fa-solid ' + (g.muted ? 'fa-volume-xmark' : 'fa-volume-high') + '" aria-hidden="true"></i>';
+ mute.innerHTML = '<i class="ovg-fill" data-ov-icon="' + (g.muted ? 'mute' : 'volume') + '" aria-hidden="true"></i>';
mute.title = g.muted ? 'Turn on ' + p.title + "'s sound" : 'Mute ' + p.title;
mute.title = g.muted ? 'Turn on ' + p.title + "'s sound" : 'Mute ' + p.title;
mute.setAttribute('aria-label', mute.title);
mute.setAttribute('aria-label', mute.title);
v.muted = g.muted || video.muted;
v.muted = g.muted || video.muted;
@@ -39,10 +39,10 @@ const code = (s) => html`<code>${s}</code>`;
@@ -39,10 +39,10 @@ const code = (s) => html`<code>${s}</code>`;
*/
*/
// A caller with no class of its own gets the kit's quiet button (kit.css is on every page that shows one): an empty class
// A caller with no class of its own gets the kit's quiet button (kit.css is on every page that shows one): an empty class
// drew an unstyled grey box beside the row's buttons (11-07).
// drew an unstyled grey box beside the row's buttons (11-07).
-function shareButton({ url = '', title = '', text = '', image = '', timeFrom = null, kind = null, id = null, cls = 'ovk-btn ovk-btn--quiet', label = 'Share', icon = 'fa-solid fa-share-nodes' }) {
+function shareButton({ url = '', title = '', text = '', image = '', timeFrom = null, kind = null, id = null, cls = 'ovk-btn ovk-btn--quiet', label = 'Share', icon = 'nodes' }) {
const line = String(text || '').trim().replace(/\s+/g, ' ');
const line = String(text || '').trim().replace(/\s+/g, ' ');
const summary = line.length > 240 ? `${line.slice(0, 237).trimEnd()}…` : line;
const summary = line.length > 240 ? `${line.slice(0, 237).trimEnd()}…` : line;
- return html`<button type="button" class="${cls}" data-ov-share-sheet${url ? html` data-url="${url}"` : ''}${title ? html` data-title="${title}"` : ''}${summary ? html` data-text="${summary}"` : ''}${image ? html` data-image="${image}"` : ''}${timeFrom ? html` data-time-from="${timeFrom}"` : ''}${kind && id ? html` data-kind="${kind}" data-id="${id}"` : ''}><i class="${icon}" aria-hidden="true"></i> ${label}</button>`;
+ return html`<button type="button" class="${cls}" data-ov-share-sheet${url ? html` data-url="${url}"` : ''}${title ? html` data-title="${title}"` : ''}${summary ? html` data-text="${summary}"` : ''}${image ? html` data-image="${image}"` : ''}${timeFrom ? html` data-time-from="${timeFrom}"` : ''}${kind && id ? html` data-kind="${kind}" data-id="${id}"` : ''}><i class="ovg-fill" data-ov-icon="${icon}" aria-hidden="true"></i> ${label}</button>`;
}
}
/**
/**
@@ -26,9 +26,9 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -26,9 +26,9 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// The product's own places: the main ones carry an icon (phones get them as the bottom tab bar), the rest fold into
// The product's own places: the main ones carry an icon (phones get them as the bottom tab bar), the rest fold into
// the navbar's More (Shared 4.22). Search is the navbar's magnifier and What shipped is the footer's.
// the navbar's More (Shared 4.22). Search is the navbar's magnifier and What shipped is the footer's.
const NAV = [
const NAV = [
- { label: 'Your files', href: '/files', icon: 'fa-file-lines' },
- { label: 'Backups', href: '/sync', icon: 'fa-clock-rotate-left' },
- { label: 'Safety', href: '/safety', icon: 'fa-shield-halved', more: true },
+ { label: 'Your files', href: '/files', icon: 'file' },
+ { label: 'Backups', href: '/sync', icon: 'history' },
+ { label: 'Safety', href: '/safety', icon: 'shield', more: true },
];
];
const hashes = new Map();
const hashes = new Map();
@@ -194,7 +194,7 @@ const CHAPTER_TXT_HELP = 'Times and titles, ready to paste into a YouTube descri
@@ -194,7 +194,7 @@ const CHAPTER_TXT_HELP = 'Times and titles, ready to paste into a YouTube descri
/** One row of the menu: an icon, what it is (and a line about it), and a link per format. Each format says what it is
/** One row of the menu: an icon, what it is (and a line about it), and a link per format. Each format says what it is
* for under its name, so the menu reads without hovering (a phone has no hover). */
* for under its name, so the menu reads without hovering (a phone has no hover). */
function row({ icon, label, about, links }) {
function row({ icon, label, about, links }) {
- return html`<li class="ovv-dl-row"><i class="${icon}" aria-hidden="true"></i><span class="ovv-dl-what"><span class="ovv-dl-label">${label}</span>${about ? html`<span class="ovv-dl-about">${about}</span>` : ''}</span><span class="ovv-dl-formats">${links.map((l) => html`<a class="ovv-dl-format${l.main ? ' ovv-dl-format--main' : ''}" href="${l.href}" download rel="nofollow" title="${l.help || FORMAT_HELP[l.ext] || ''}" aria-label="${label}: ${l.text || l.ext.toUpperCase()} (${l.help || FORMAT_HELP[l.ext] || ''})"><span>${l.text || l.ext.toUpperCase()}</span>${l.purpose ? html`<small>${l.purpose}</small>` : ''}</a>`)}</span></li>`;
+ return html`<li class="ovv-dl-row"><i class="ovg-fill" data-ov-icon="${icon}" aria-hidden="true"></i><span class="ovv-dl-what"><span class="ovv-dl-label">${label}</span>${about ? html`<span class="ovv-dl-about">${about}</span>` : ''}</span><span class="ovv-dl-formats">${links.map((l) => html`<a class="ovv-dl-format${l.main ? ' ovv-dl-format--main' : ''}" href="${l.href}" download rel="nofollow" title="${l.help || FORMAT_HELP[l.ext] || ''}" aria-label="${label}: ${l.text || l.ext.toUpperCase()} (${l.help || FORMAT_HELP[l.ext] || ''})"><span>${l.text || l.ext.toUpperCase()}</span>${l.purpose ? html`<small>${l.purpose}</small>` : ''}</a>`)}</span></li>`;
}
}
/** "570.7 MB" for a byte count (null when unknown). */
/** "570.7 MB" for a byte count (null when unknown). */
@@ -233,32 +233,32 @@ function downloadMenu({ kind, id, item, has }) {
@@ -233,32 +233,32 @@ function downloadMenu({ kind, id, item, has }) {
if (ready && parts) {
if (ready && parts) {
// A stream recorded in parts plays as one video (Media's recording); its files are one per session, named by where
// A stream recorded in parts plays as one video (Media's recording); its files are one per session, named by where
// each sits in it.
// each sits in it.
- rows.push(row({ icon: 'fa-solid fa-film', label: 'Video', about: `${parts.length} files, one per recorded session`,
+ rows.push(row({ icon: 'film', label: 'Video', about: `${parts.length} files, one per recorded session`,
links: parts.map((p, i) => ({ href: `${at}/video?part=${p.vod_id}`, ext: videoExt(item), main: i === 0, text: `${clockLabel(p.start_sec)}–${clockLabel(p.start_sec + p.duration_sec)}` })) }));
links: parts.map((p, i) => ({ href: `${at}/video?part=${p.vod_id}`, ext: videoExt(item), main: i === 0, text: `${clockLabel(p.start_sec)}–${clockLabel(p.start_sec + p.duration_sec)}` })) }));
} else if (ready) {
} else if (ready) {
const size = sizeLabel(item.file_size);
const size = sizeLabel(item.file_size);
const ext = videoExt(item);
const ext = videoExt(item);
- rows.push(row({ icon: 'fa-solid fa-film', label: 'Video', about: [kind === 'clip' ? 'The clip' : 'The whole recording', size].filter(Boolean).join(' · '), links: [{ href: `${at}/video`, ext, main: true }] }));
+ rows.push(row({ icon: 'film', label: 'Video', about: [kind === 'clip' ? 'The clip' : 'The whole recording', size].filter(Boolean).join(' · '), links: [{ href: `${at}/video`, ext, main: true }] }));
}
}
- if (has.transcript) rows.push(row({ icon: 'fa-solid fa-closed-captioning', label: 'Transcript', about: `${has.transcript} lines, written by AI`, links: [
+ if (has.transcript) rows.push(row({ icon: 'cc', label: 'Transcript', about: `${has.transcript} lines, written by AI`, links: [
{ href: `${at}/transcript.txt`, ext: 'txt', purpose: 'to read' },
{ href: `${at}/transcript.txt`, ext: 'txt', purpose: 'to read' },
{ href: `${at}/transcript.srt`, ext: 'srt', purpose: 'for editors' },
{ href: `${at}/transcript.srt`, ext: 'srt', purpose: 'for editors' },
{ href: `${at}/transcript.vtt`, ext: 'vtt', purpose: 'for web players' },
{ href: `${at}/transcript.vtt`, ext: 'vtt', purpose: 'for web players' },
{ href: `${at}/transcript.json`, ext: 'json', purpose: 'for programs' }] }));
{ href: `${at}/transcript.json`, ext: 'json', purpose: 'for programs' }] }));
- if (has.chat) rows.push(row({ icon: 'fa-solid fa-comments', label: 'Chat replay', about: `${has.chat} messages`, links: [
+ if (has.chat) rows.push(row({ icon: 'chat', label: 'Chat replay', about: `${has.chat} messages`, links: [
{ href: `${at}/chat.txt`, ext: 'txt', purpose: 'to read' },
{ href: `${at}/chat.txt`, ext: 'txt', purpose: 'to read' },
{ href: `${at}/chat.csv`, ext: 'csv', purpose: 'for spreadsheets' },
{ href: `${at}/chat.csv`, ext: 'csv', purpose: 'for spreadsheets' },
{ href: `${at}/chat.json`, ext: 'json', purpose: 'for programs' }] }));
{ href: `${at}/chat.json`, ext: 'json', purpose: 'for programs' }] }));
- if (has.chapters) rows.push(row({ icon: 'fa-solid fa-list-ol', label: 'Chapters', about: `${has.chapters} chapters, made by AI`, links: [
+ if (has.chapters) rows.push(row({ icon: 'list-ordered', label: 'Chapters', about: `${has.chapters} chapters, made by AI`, links: [
{ href: `${at}/chapters.txt`, ext: 'txt', purpose: 'to paste', help: CHAPTER_TXT_HELP },
{ href: `${at}/chapters.txt`, ext: 'txt', purpose: 'to paste', help: CHAPTER_TXT_HELP },
{ href: `${at}/chapters.vtt`, ext: 'vtt', purpose: 'for players' },
{ href: `${at}/chapters.vtt`, ext: 'vtt', purpose: 'for players' },
{ href: `${at}/chapters.json`, ext: 'json', purpose: 'for programs' }] }));
{ href: `${at}/chapters.json`, ext: 'json', purpose: 'for programs' }] }));
- if (item.thumbnail_url) rows.push(row({ icon: 'fa-regular fa-image', label: 'Cover picture', about: null, links: [{ href: `${at}/thumbnail`, ext: 'jpg', text: 'Image' }] }));
- rows.push(row({ icon: 'fa-solid fa-circle-info', label: 'Details', about: 'Title, channel, dates, summary', links: [{ href: `${at}/info.json`, ext: 'json' }] }));
- const everything = html`<li class="ovv-dl-row ovv-dl-row--all"><i class="fa-solid fa-box-archive" aria-hidden="true"></i><span class="ovv-dl-what"><span class="ovv-dl-label">Everything</span><span class="ovv-dl-about">All of the above but the video, in one .zip</span></span><span class="ovv-dl-formats"><a class="ovv-dl-format ovv-dl-format--main" href="${at}/all.zip" download rel="nofollow" title="${FORMAT_HELP.zip}" aria-label="Everything: ZIP (${FORMAT_HELP.zip})">ZIP</a></span></li>`;
- return html`<details class="ovv-dl" data-ovv-dl><summary class="ovv-btn"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</summary>
+ if (item.thumbnail_url) rows.push(row({ icon: 'image', label: 'Cover picture', about: null, links: [{ href: `${at}/thumbnail`, ext: 'jpg', text: 'Image' }] }));
+ rows.push(row({ icon: 'info', label: 'Details', about: 'Title, channel, dates, summary', links: [{ href: `${at}/info.json`, ext: 'json' }] }));
+ const everything = html`<li class="ovv-dl-row ovv-dl-row--all"><i class="ovg-fill" data-ov-icon="archive" aria-hidden="true"></i><span class="ovv-dl-what"><span class="ovv-dl-label">Everything</span><span class="ovv-dl-about">All of the above but the video, in one .zip</span></span><span class="ovv-dl-formats"><a class="ovv-dl-format ovv-dl-format--main" href="${at}/all.zip" download rel="nofollow" title="${FORMAT_HELP.zip}" aria-label="Everything: ZIP (${FORMAT_HELP.zip})">ZIP</a></span></li>`;
+ return html`<details class="ovv-dl" data-ovv-dl><summary class="ovv-btn"><i class="ovg-fill" data-ov-icon="download" aria-hidden="true"></i> Download</summary>
<div class="ovv-dl-menu" role="group" aria-label="Download this ${kind === 'clip' ? 'clip' : 'recording'}">
<div class="ovv-dl-menu" role="group" aria-label="Download this ${kind === 'clip' ? 'clip' : 'recording'}">
-<div class="ovv-dl-head"><span>Download this ${kind === 'clip' ? 'clip' : 'recording'}</span><button type="button" class="ovv-dl-close" data-ovv-dl-close aria-label="Close"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button></div>
+<div class="ovv-dl-head"><span>Download this ${kind === 'clip' ? 'clip' : 'recording'}</span><button type="button" class="ovv-dl-close" data-ovv-dl-close aria-label="Close"><i class="ovg-fill" data-ov-icon="close" aria-hidden="true"></i></button></div>
<ul class="ovv-dl-list">${rows}${everything}</ul>
<ul class="ovv-dl-list">${rows}${everything}</ul>
<p class="ovv-dl-hint">Every format says what it is for.</p>
<p class="ovv-dl-hint">Every format says what it is for.</p>
</div></details>`;
</div></details>`;
@@ -129,12 +129,12 @@ ${render(o.body)}
@@ -129,12 +129,12 @@ ${render(o.body)}
// (Shared 4.22). Search is the navbar's magnifier, which offers this site's own search first; the page without
// (Shared 4.22). Search is the navbar's magnifier, which offers this site's own search first; the page without
// JavaScript keeps its Search link.
// JavaScript keeps its Search link.
const NAV_LINKS = [
const NAV_LINKS = [
- { label: 'Videos', href: '/', icon: 'fa-play' },
- { label: 'Search', href: '/search', icon: 'fa-magnifying-glass', noscriptOnly: true },
- { label: 'Upload', href: '/upload', icon: 'fa-upload' },
- { label: 'Record', href: '/record', icon: 'fa-video' },
- { label: 'Your videos', href: '/me/videos', icon: 'fa-photo-film' },
- { label: 'Playlists', href: '/playlists', icon: 'fa-list-check', more: true },
+ { label: 'Videos', href: '/', icon: 'play' },
+ { label: 'Search', href: '/search', icon: 'search', noscriptOnly: true },
+ { label: 'Upload', href: '/upload', icon: 'upload' },
+ { label: 'Record', href: '/record', icon: 'video' },
+ { label: 'Your videos', href: '/me/videos', icon: 'film' },
+ { label: 'Playlists', href: '/playlists', icon: 'steps', more: true },
];
];
// A signed-out copy is never reused without asking: Cloudflare drops `Vary` (it keeps only Accept-Encoding), so a
// A signed-out copy is never reused without asking: Cloudflare drops `Vary` (it keeps only Accept-Encoding), so a
@@ -416,7 +416,7 @@ function commentItem(c, depth = 0) {
@@ -416,7 +416,7 @@ function commentItem(c, depth = 0) {
<p class="ovv-comment-text">${text}</p>
<p class="ovv-comment-text">${text}</p>
${files.length ? html`<ul>${files.map((file) => html`<li><a href="https://openvibe.media/o/${file.id}" rel="noopener noreferrer">${file.name || 'Attached file'}</a></li>`)}</ul>` : ''}
${files.length ? html`<ul>${files.map((file) => html`<li><a href="https://openvibe.media/o/${file.id}" rel="noopener noreferrer">${file.name || 'Attached file'}</a></li>`)}</ul>` : ''}
${shownReplies.map((reply) => commentItem(reply, 1))}
${shownReplies.map((reply) => commentItem(reply, 1))}
-${replies > 0 ? html`<p class="ovv-comment-replies"><i class="fa-regular fa-comments" aria-hidden="true"></i> ${replies} ${replies === 1 ? 'reply' : 'replies'}${replies > shownReplies.length ? html` · ${replies - shownReplies.length} more` : ''}</p>` : ''}
+${replies > 0 ? html`<p class="ovv-comment-replies"><i data-ov-icon="chat" aria-hidden="true"></i> ${replies} ${replies === 1 ? 'reply' : 'replies'}${replies > shownReplies.length ? html` · ${replies - shownReplies.length} more` : ''}</p>` : ''}
</article>`;
</article>`;
}
}
@@ -569,12 +569,12 @@ function cardPlaceholder(o, icon) {
@@ -569,12 +569,12 @@ function cardPlaceholder(o, icon) {
const face = `${faceBase}/avatar/${name}?s=160`;
const face = `${faceBase}/avatar/${name}?s=160`;
return html`<span class="ovv-card-placeholder ovv-card-who"><img class="ovv-who-bg" src="${face}" alt="" loading="lazy" decoding="async"><img class="ovv-who-face" src="${face}" alt="" loading="lazy" decoding="async"></span>`;
return html`<span class="ovv-card-placeholder ovv-card-who"><img class="ovv-who-bg" src="${face}" alt="" loading="lazy" decoding="async"><img class="ovv-who-face" src="${face}" alt="" loading="lazy" decoding="async"></span>`;
}
}
- return html`<span class="ovv-card-placeholder"><i class="fa-solid ${icon}"></i></span>`;
+ return html`<span class="ovv-card-placeholder"><i class="ovg-fill" data-ov-icon="${icon}"></i></span>`;
}
}
function card(o) {
function card(o) {
const channel = o.channel ? html`<a href="${o.channelHref}">@${o.channel}</a>` : null;
const channel = o.channel ? html`<a href="${o.channelHref}">@${o.channel}</a>` : null;
- const icon = o.live ? 'fa-tower-broadcast' : (o.kind === 'clip' ? 'fa-scissors' : 'fa-video');
+ const icon = o.live ? 'live' : (o.kind === 'clip' ? 'scissors' : 'video');
const labels = o.labels || [];
const labels = o.labels || [];
const media = html`<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : cardPlaceholder(o, icon)}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag${o.live ? ' ovv-tag--live' : ''}">${o.badge}</span>` : ''}</a>`;
const media = html`<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : cardPlaceholder(o, icon)}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag${o.live ? ' ovv-tag--live' : ''}">${o.badge}</span>` : ''}</a>`;
return html`<article class="ovv-card"${labelsUi.attrs(labels, { owner: o.owner || o.channel })}>
return html`<article class="ovv-card"${labelsUi.attrs(labels, { owner: o.owner || o.channel })}>
@@ -689,7 +689,7 @@ function createVideoRoutes(ctx) {
@@ -689,7 +689,7 @@ function createVideoRoutes(ctx) {
const partyLink = (path, title, item) => {
const partyLink = (path, title, item) => {
const file = item ? fileOf(item) : null;
const file = item ? fileOf(item) : null;
if (!file) return '';
if (!file) return '';
- return html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(file)}&source_url=${encodeURIComponent(`${base}${path}`)}&title=${encodeURIComponent(String(title || 'Watch party'))}"><i class="fa-solid fa-users" aria-hidden="true"></i> Watch together</a>`;
+ return html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(file)}&source_url=${encodeURIComponent(`${base}${path}`)}&title=${encodeURIComponent(String(title || 'Watch party'))}"><i class="ovg-fill" data-ov-icon="members" aria-hidden="true"></i> Watch together</a>`;
};
};
const hasSessionCookie = (req) => Boolean(req.cookies && (req.cookies[ACCESS_COOKIE] || req.cookies[REFRESH_COOKIE]));
const hasSessionCookie = (req) => Boolean(req.cookies && (req.cookies[ACCESS_COOKIE] || req.cookies[REFRESH_COOKIE]));
@@ -793,24 +793,24 @@ function createVideoRoutes(ctx) {
@@ -793,24 +793,24 @@ function createVideoRoutes(ctx) {
// Who can see it, as three choices with what each means (owner, 2026-10-27: the page had no clear way to make a
// Who can see it, as three choices with what each means (owner, 2026-10-27: the page had no clear way to make a
// recording private or unlisted). Radios, so the form works without JavaScript.
// recording private or unlisted). Radios, so the form works without JavaScript.
const VISIBILITY_LOOK = {
const VISIBILITY_LOOK = {
- public: ['fa-solid fa-earth-americas', 'Listed and searchable'],
- unlisted: ['fa-solid fa-link', 'Only people with the link'],
- private: ['fa-solid fa-lock', 'Only you (and staff)'],
+ public: ['globe', 'Listed and searchable'],
+ unlisted: ['link', 'Only people with the link'],
+ private: ['lock', 'Only you (and staff)'],
};
};
- const visibilityChoices = (current) => html`<fieldset class="ovv-vis"><legend>Who can see it</legend>${VISIBILITIES.map((v) => html`<label class="ovv-vis-opt"><input type="radio" name="visibility" value="${v}"${v === current ? ' checked' : ''}><span class="ovv-vis-card"><i class="${VISIBILITY_LOOK[v][0]}" aria-hidden="true"></i><b>${visibilityLabel(v)}</b><small>${VISIBILITY_LOOK[v][1]}</small></span></label>`)}</fieldset>`;
+ const visibilityChoices = (current) => html`<fieldset class="ovv-vis"><legend>Who can see it</legend>${VISIBILITIES.map((v) => html`<label class="ovv-vis-opt"><input type="radio" name="visibility" value="${v}"${v === current ? ' checked' : ''}><span class="ovv-vis-card"><i class="ovg-fill" data-ov-icon="${VISIBILITY_LOOK[v][0]}" aria-hidden="true"></i><b>${visibilityLabel(v)}</b><small>${VISIBILITY_LOOK[v][1]}</small></span></label>`)}</fieldset>`;
/** The owner's badge in the meta line: who can see it now. */
/** The owner's badge in the meta line: who can see it now. */
- const visibilityBadge = (v) => html`<span class="ovv-vis-badge ovv-vis-badge--${VISIBILITIES.includes(v) ? v : 'public'}"><i class="${(VISIBILITY_LOOK[v] || VISIBILITY_LOOK.public)[0]}" aria-hidden="true"></i> ${visibilityLabel(v || 'public')}</span>`;
+ const visibilityBadge = (v) => html`<span class="ovv-vis-badge ovv-vis-badge--${VISIBILITIES.includes(v) ? v : 'public'}"><i class="ovg-fill" data-ov-icon="${(VISIBILITY_LOOK[v] || VISIBILITY_LOOK.public)[0]}" aria-hidden="true"></i> ${visibilityLabel(v || 'public')}</span>`;
/** The owner's Edit link in More opens the panel under the player. */
/** The owner's Edit link in More opens the panel under the player. */
- const editButton = (what) => html`<a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage" title="Edit this ${what}: title, description, who can see it"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</a>`;
+ const editButton = (what) => html`<a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage" title="Edit this ${what}: title, description, who can see it"><i class="ovg-fill" data-ov-icon="pencil" aria-hidden="true"></i> Edit</a>`;
const titleLine = (kind, title) => html`<div class="ovv-title-line"><span class="ovv-kind">${kind}</span><h1 class="ovv-title">${title}</h1></div>`;
const titleLine = (kind, title) => html`<div class="ovv-title-line"><span class="ovv-kind">${kind}</span><h1 class="ovv-title">${title}</h1></div>`;
- const likeButton = (state, kind, id) => html`<button class="ovv-btn ovv-like" type="button" data-ovv-like="${kind}:${id}" aria-pressed="${state.liked ? 'true' : 'false'}" title="Like this ${kind === 'clip' ? 'clip' : 'recording'}"><i class="fa-regular fa-heart" aria-hidden="true"></i> Like <span class="ovv-like-count">${state.count}</span></button>`;
+ const likeButton = (state, kind, id) => html`<button class="ovv-btn ovv-like" type="button" data-ovv-like="${kind}:${id}" aria-pressed="${state.liked ? 'true' : 'false'}" title="Like this ${kind === 'clip' ? 'clip' : 'recording'}"><i data-ov-icon="heart" aria-hidden="true"></i> Like <span class="ovv-like-count">${state.count}</span></button>`;
const channelRow = (username, name, followers) => {
const channelRow = (username, name, followers) => {
if (!username) return '';
if (!username) return '';
const channel = `${livePublic}/@${username}`;
const channel = `${livePublic}/@${username}`;
const count = Number(followers);
const count = Number(followers);
- return html`<div class="ovv-byline"><a class="ovv-avatar" href="${channel}" aria-hidden="true" tabindex="-1">${String(name || username).slice(0, 1).toUpperCase()}${live.isUsername(String(username)) ? html`<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy">` : ''}</a><div class="ovv-byline-text"><a class="ovv-channel-name" href="${channel}">@${username}</a>${Number.isSafeInteger(count) && count >= 0 ? html`<span class="ovv-meta">${count.toLocaleString('en-US')} ${count === 1 ? 'follower' : 'followers'}</span>` : ''}</div><a class="ovv-btn ovv-follow" href="${channel}" title="Follow ${name || username} on OpenVibe.Live"><i class="fa-solid fa-plus" aria-hidden="true"></i> Follow</a></div>`;
+ return html`<div class="ovv-byline"><a class="ovv-avatar" href="${channel}" aria-hidden="true" tabindex="-1">${String(name || username).slice(0, 1).toUpperCase()}${live.isUsername(String(username)) ? html`<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy">` : ''}</a><div class="ovv-byline-text"><a class="ovv-channel-name" href="${channel}">@${username}</a>${Number.isSafeInteger(count) && count >= 0 ? html`<span class="ovv-meta">${count.toLocaleString('en-US')} ${count === 1 ? 'follower' : 'followers'}</span>` : ''}</div><a class="ovv-btn ovv-follow" href="${channel}" title="Follow ${name || username} on OpenVibe.Live"><i class="ovg-fill" data-ov-icon="plus" aria-hidden="true"></i> Follow</a></div>`;
};
};
- const watchMore = ({ path, manageable, kind, id, items }) => actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: html`<div class="ovv-more-actions"><button type="button" data-ovv-copy-time="${base}${path}"><i class="fa-regular fa-copy" aria-hidden="true"></i> Copy link at time</button><button type="button" data-ovv-pip hidden><i class="fa-regular fa-window-restore" aria-hidden="true"></i> Picture in picture</button><a href="mailto:[email protected]?subject=${encodeURIComponent(`Report ${kind} ${base}${path}`)}"><i class="fa-regular fa-flag" aria-hidden="true"></i> Report</a>${manageable ? html`${editButton(kind === 'clip' ? 'clip' : 'recording')}${deleteAction({ action: `/${kind === 'clip' ? 'c' : 'v'}/${id}`, what: kind === 'clip' ? 'clip' : 'recording' })}` : ''}${menuLinks(items)}</div>` });
+ const watchMore = ({ path, manageable, kind, id, items }) => actionMenu({ icon: 'more', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: html`<div class="ovv-more-actions"><button type="button" data-ovv-copy-time="${base}${path}"><i data-ov-icon="copy" aria-hidden="true"></i> Copy link at time</button><button type="button" data-ovv-pip hidden><i data-ov-icon="window-restore" aria-hidden="true"></i> Picture in picture</button><a href="mailto:[email protected]?subject=${encodeURIComponent(`Report ${kind} ${base}${path}`)}"><i data-ov-icon="flag" aria-hidden="true"></i> Report</a>${manageable ? html`${editButton(kind === 'clip' ? 'clip' : 'recording')}${deleteAction({ action: `/${kind === 'clip' ? 'c' : 'v'}/${id}`, what: kind === 'clip' ? 'clip' : 'recording' })}` : ''}${menuLinks(items)}</div>` });
const watchFacts = (views, date, duration, visibility) => {
const watchFacts = (views, date, duration, visibility) => {
const count = Math.max(0, Number(views) || 0);
const count = Math.max(0, Number(views) || 0);
const stamp = date instanceof Date ? date.getTime() : Date.parse(String(date || ''));
const stamp = date instanceof Date ? date.getTime() : Date.parse(String(date || ''));
@@ -841,12 +841,12 @@ ${ai ? html`<p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span
@@ -841,12 +841,12 @@ ${ai ? html`<p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span
function clipAction(req, id, vod) {
function clipAction(req, id, vod) {
if (vod.is_recording) return '';
if (vod.is_recording) return '';
if (!signedIn(req)) {
if (!signedIn(req)) {
- 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>`;
+ 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="ovg-fill" data-ov-icon="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);
// The whole video of a stream recorded in parts: its times are the video's, and a clip may cross a join.
// The whole video of a stream recorded in parts: its times are the video's, and a clip may cross a join.
const whole = !!(vod.recording && vod.recording.joined && String(vod.recording.vod_id) === String(id));
const whole = !!(vod.recording && vod.recording.joined && String(vod.recording.vod_id) === String(id));
- 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>
+ return actionMenu({ icon: '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">
${whole ? html`<input type="hidden" name="recording_time" value="1">` : ''}
${whole ? html`<input type="hidden" name="recording_time" value="1">` : ''}
<div class="ovv-clip-times">
<div class="ovv-clip-times">
@@ -864,7 +864,7 @@ ${whole ? html`<input type="hidden" name="recording_time" value="1">` : ''}
@@ -864,7 +864,7 @@ ${whole ? html`<input type="hidden" name="recording_time" value="1">` : ''}
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`<details class="ovv-panel ovv-owner" id="manage"><summary><h2 id="h-owner"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit this ${what}</h2><span class="ovv-fine">Title, ${kind === 'vod' ? 'description, ' : ''}who can see it, labels</span></summary>
+ return html`<details class="ovv-panel ovv-owner" id="manage"><summary><h2 id="h-owner"><i class="ovg-fill" data-ov-icon="pencil" aria-hidden="true"></i> Edit this ${what}</h2><span class="ovv-fine">Title, ${kind === 'vod' ? 'description, ' : ''}who can see it, labels</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>` : ''}
@@ -1064,10 +1064,10 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -1064,10 +1064,10 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
// ── 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)}" title="Sign in to save it to a playlist"><i class="fa-solid fa-plus" aria-hidden="true"></i> Save</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="ovg-fill" data-ov-icon="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 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>
+ return html`<details class="ovv-save ovv-pop" data-ovv-menu><summary class="ovv-btn" title="Save to a playlist"><i class="ovg-fill" data-ov-icon="plus" aria-hidden="true"></i> Save</summary>
<form class="ovv-save-form ovv-pop-panel" method="post" action="/playlists/add">
<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}">
@@ -1086,7 +1086,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -1086,7 +1086,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
if (kind === 'upload') {
if (kind === 'upload') {
const row = await ctx.videoUploads.bySeq(itemId);
const row = await ctx.videoUploads.bySeq(itemId);
if (!row || row.status !== 'ready' || row.state !== 'active' || row.visibility === 'private') return null;
if (!row || row.status !== 'ready' || row.state !== 'active' || row.visibility === 'private') return null;
- return { href: watchHref('upload', row.id, row.watch_id), title: row.title, thumb: httpsUrl(row.thumbnail_url), channel: row.owner_username || null, duration: row.duration_seconds, icon: 'fa-film' };
+ return { href: watchHref('upload', row.id, row.watch_id), title: row.title, thumb: httpsUrl(row.thumbnail_url), channel: row.owner_username || null, duration: row.duration_seconds, icon: 'film' };
}
}
let info = null;
let info = null;
try {
try {
@@ -1100,7 +1100,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -1100,7 +1100,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
thumb: httpsUrl(info.thumbnail_url),
thumb: httpsUrl(info.thumbnail_url),
channel: info.username || info.display_name || null,
channel: info.username || info.display_name || null,
duration: info.duration_seconds != null ? info.duration_seconds : info.duration,
duration: info.duration_seconds != null ? info.duration_seconds : info.duration,
- icon: kind === 'clip' ? 'fa-scissors' : 'fa-video',
+ icon: kind === 'clip' ? 'scissors' : 'video',
};
};
}
}
@@ -1153,7 +1153,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -1153,7 +1153,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
const info = await itemInfo(next.kind, nextId);
const info = await itemInfo(next.kind, nextId);
const label = (info && info.title) || `${next.kind === 'vod' ? 'VOD' : next.kind === 'clip' ? 'Clip' : 'Video'} ${nextId}`;
const label = (info && info.title) || `${next.kind === 'vod' ? 'VOD' : next.kind === 'clip' ? 'Clip' : 'Video'} ${nextId}`;
return {
return {
- html: html`<section class="ovv-panel ovv-upnext" aria-label="Up next"><p class="ovv-upnext-in">Up next in <a href="/playlists/${pl.id}">${pl.title}</a></p><a class="ovv-upnext-link" href="${nextUrl}"><i class="fa-solid fa-play" aria-hidden="true"></i> <span>${label}</span></a><p class="ovv-fine">Plays on its own when this one ends.</p></section>`,
+ html: html`<section class="ovv-panel ovv-upnext" aria-label="Up next"><p class="ovv-upnext-in">Up next in <a href="/playlists/${pl.id}">${pl.title}</a></p><a class="ovv-upnext-link" href="${nextUrl}"><i class="ovg-fill" data-ov-icon="play" aria-hidden="true"></i> <span>${label}</span></a><p class="ovv-fine">Plays on its own when this one ends.</p></section>`,
nextUrl,
nextUrl,
};
};
}
}
@@ -1169,7 +1169,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -1169,7 +1169,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
return html`<li class="ovv-playlist-row">
return html`<li class="ovv-playlist-row">
<div class="ovv-playlist-main"><a class="ovv-playlist-title" href="/playlists/${p.id}">${p.title}</a>
<div class="ovv-playlist-main"><a class="ovv-playlist-title" href="/playlists/${p.id}">${p.title}</a>
<span class="ovv-meta">${visibilityLabel(p.visibility)} · ${n} item${n === 1 ? '' : 's'} · updated ${dateLabel(p.updated_at)}</span></div>
<span class="ovv-meta">${visibilityLabel(p.visibility)} · ${n} item${n === 1 ? '' : 's'} · updated ${dateLabel(p.updated_at)}</span></div>
-<details class="ovv-pl-edit"><summary class="ovv-btn"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</summary>
+<details class="ovv-pl-edit"><summary class="ovv-btn"><i class="ovg-fill" data-ov-icon="pencil" aria-hidden="true"></i> Edit</summary>
<div class="ovv-playlist-actions">
<div class="ovv-playlist-actions">
<form class="ovv-inline" method="post" action="/playlists/${p.id}/rename"><input name="title" type="text" maxlength="${playlists.TITLE_MAX}" value="${p.title}" aria-label="New title" required> <button class="ovv-btn" type="submit">Rename</button></form>
<form class="ovv-inline" method="post" action="/playlists/${p.id}/rename"><input name="title" type="text" maxlength="${playlists.TITLE_MAX}" value="${p.title}" aria-label="New title" required> <button class="ovv-btn" type="submit">Rename</button></form>
<form class="ovv-inline" method="post" action="/playlists/${p.id}/visibility"><select name="visibility" aria-label="Visibility">${playlists.VISIBILITIES.map((v) => html`<option value="${v}"${v === p.visibility ? ' selected' : ''}>${visibilityLabel(v)}</option>`)}</select> <button class="ovv-btn" type="submit">Change</button></form>
<form class="ovv-inline" method="post" action="/playlists/${p.id}/visibility"><select name="visibility" aria-label="Visibility">${playlists.VISIBILITIES.map((v) => html`<option value="${v}"${v === p.visibility ? ' selected' : ''}>${visibilityLabel(v)}</option>`)}</select> <button class="ovv-btn" type="submit">Change</button></form>
@@ -1341,10 +1341,10 @@ ${feedKind === 'following' ? html`<p class="muted">See updates from followed cha
@@ -1341,10 +1341,10 @@ ${feedKind === 'following' ? html`<p class="muted">See updates from followed cha
seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Video', url: `${base}/` }, { name: cat.label, url: `${base}/category/${slug}` }]),
seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Video', url: `${base}/` }, { name: cat.label, url: `${base}/category/${slug}` }]),
seo.jsonLd.itemList(title, items.map((it) => ({ name: it.title || 'Stream recording', url: `${base}${it.watch_id ? `/watch?v=${it.watch_id}` : `/v/${it.id}`}` })), { url: `${base}/category/${slug}` }),
seo.jsonLd.itemList(title, items.map((it) => ({ name: it.title || 'Stream recording', url: `${base}${it.watch_id ? `/watch?v=${it.watch_id}` : `/v/${it.id}`}` })), { url: `${base}/category/${slug}` }),
],
],
- body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true"><i class="fa-solid ${cat.icon}"></i></span><div><h1>${cat.label}</h1><p class="ovv-meta">${cat.blurb}${count ? ` ${n(count.count)} ${count.count === 1 ? 'recording' : 'recordings'}.` : ''}</p></div></header>
+ body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true"><i class="ovg-fill" data-ov-icon="${cat.icon}"></i></span><div><h1>${cat.label}</h1><p class="ovv-meta">${cat.blurb}${count ? ` ${n(count.count)} ${count.count === 1 ? 'recording' : 'recordings'}.` : ''}</p></div></header>
<section class="ovv-section" aria-label="${title}">${grid(items.map((it) => feedCard(it)), { title: 'Nothing in this category yet', text: 'Recordings of this kind appear here when a streamer goes live.', action: { href: '/', label: 'See the latest recordings', icon: 'play' } })}</section>
<section class="ovv-section" aria-label="${title}">${grid(items.map((it) => feedCard(it)), { title: 'Nothing in this category yet', text: 'Recordings of this kind appear here when a streamer goes live.', action: { href: '/', label: 'See the latest recordings', icon: 'play' } })}</section>
${pageOf.next ? html`<p class="ovv-pagination"><a class="ovv-btn" href="/category/${slug}?after=${encodeURIComponent(pageOf.next)}" rel="next">Older recordings</a></p>` : ''}
${pageOf.next ? html`<p class="ovv-pagination"><a class="ovv-btn" href="/category/${slug}?after=${encodeURIComponent(pageOf.next)}" rel="next">Older recordings</a></p>` : ''}
-${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h2 id="h-other">Other categories</h2><ul class="ovv-shelf" role="list">${others.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="fa-solid ${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}`,
+${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h2 id="h-other">Other categories</h2><ul class="ovv-shelf" role="list">${others.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="ovg-fill" data-ov-icon="${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}`,
});
});
});
});
@@ -1352,7 +1352,7 @@ ${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h
@@ -1352,7 +1352,7 @@ ${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h
// Without ?type the three kinds show their best eight each; ?type=vod|clip|video pages one kind (24 at a time, Search's
// Without ?type the three kinds show their best eight each; ?type=vod|clip|video pages one kind (24 at a time, Search's
// cursor), and recordings filter by category. Results are noindex; the empty search page is not.
// cursor), and recordings filter by category. Results are noindex; the empty search page is not.
const SEARCH_KINDS = [['vod', 'Stream recordings'], ['clip', 'Clips'], ['video', 'Uploads']];
const SEARCH_KINDS = [['vod', 'Stream recordings'], ['clip', 'Clips'], ['video', 'Uploads']];
- const searchForm = (q = '') => html`<form class="ovv-search" method="get" action="/search" role="search"><label class="sr-only" for="ovv-q">Search videos</label><input id="ovv-q" name="q" type="search" value="${q}" maxlength="200" placeholder="Search recordings, clips and uploads" required><button class="ovv-btn ovv-btn--primary" type="submit"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i> Search</button></form>`;
+ const searchForm = (q = '') => html`<form class="ovv-search" method="get" action="/search" role="search"><label class="sr-only" for="ovv-q">Search videos</label><input id="ovv-q" name="q" type="search" value="${q}" maxlength="200" placeholder="Search recordings, clips and uploads" required><button class="ovv-btn ovv-btn--primary" type="submit"><i class="ovg-fill" data-ov-icon="search" aria-hidden="true"></i> Search</button></form>`;
async function searchCards(kind, items) {
async function searchCards(kind, items) {
if (kind === 'video') {
if (kind === 'video') {
const rows = await Promise.all(items.map((x) => settle(ctx.videoUploads.byId(x.id))));
const rows = await Promise.all(items.map((x) => settle(ctx.videoUploads.byId(x.id))));
@@ -1374,7 +1374,7 @@ ${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h
@@ -1374,7 +1374,7 @@ ${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h
index: true, title: 'Search OpenVibe.Video', description: 'Search stream recordings and clips from OpenVibe.Live and videos people uploaded to OpenVibe.Video.',
index: true, title: 'Search OpenVibe.Video', description: 'Search stream recordings and clips from OpenVibe.Live and videos people uploaded to OpenVibe.Video.',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Search' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Search' }],
body: html`<h1>Search videos</h1>${searchForm()}
body: html`<h1>Search videos</h1>${searchForm()}
-${categories.length ? html`<section class="ovv-section" aria-labelledby="h-cats"><h2 id="h-cats">Or browse by category</h2><ul class="ovv-shelf" role="list">${categories.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="fa-solid ${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}`,
+${categories.length ? html`<section class="ovv-section" aria-labelledby="h-cats"><h2 id="h-cats">Or browse by category</h2><ul class="ovv-shelf" role="list">${categories.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="ovg-fill" data-ov-icon="${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}`,
});
});
}
}
const kinds = kind ? [kind] : SEARCH_KINDS.map(([k]) => k);
const kinds = kind ? [kind] : SEARCH_KINDS.map(([k]) => k);
@@ -1563,13 +1563,13 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1563,13 +1563,13 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
// ("Part 1 (this one) · Part 2 · Part 1", 2026-10-27).
// ("Part 1 (this one) · Part 2 · Part 1", 2026-10-27).
const seenParts = new Set();
const seenParts = new Set();
const parts = vod.part && hist && hist !== fail && Array.isArray(hist.parts) ? hist.parts.filter((p) => p && live.isId(p.vod_id) && !seenParts.has(String(p.vod_id)) && seenParts.add(String(p.vod_id))) : [];
const parts = vod.part && hist && hist !== fail && Array.isArray(hist.parts) ? hist.parts.filter((p) => p && live.isId(p.vod_id) && !seenParts.has(String(p.vod_id)) && seenParts.add(String(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 ${i + 1} (this one)</b>` : html`<a href="/v/${p.vod_id}">Part ${i + 1}</a>`])}</p>` : '';
+ const partNav = parts.length > 1 ? html`<p class="ovv-about-parts"><i class="ovg-fill" data-ov-icon="layers" 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 ${i + 1} (this one)</b>` : html`<a href="/v/${p.vod_id}">Part ${i + 1}</a>`])}</p>` : '';
const ar = hist && hist !== fail ? startAspect(hist, vod) : null;
const ar = hist && hist !== fail ? startAspect(hist, vod) : null;
const shapeAttr = ar ? html` style="--ovv-ar: ${String(ar)}" data-ovv-ar="${String(ar)}"` : '';
const shapeAttr = ar ? html` style="--ovv-ar: ${String(ar)}" data-ovv-ar="${String(ar)}"` : '';
// The More menu: where else this lives, and the owner's tools.
// The More menu: where else this lives, and the owner's tools.
const moreItems = [];
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 (channelName) moreItems.push({ href: liveChannel, icon: 'live', label: 'Channel on OpenVibe.Live', hint: 'Live streams and chat' });
+ if (threadUrl) moreItems.push({ href: threadUrl, icon: 'chat', label: 'Discuss on OpenVibe.Community' });
// Chat replay, chapters and the transcript, beside the player.
// Chat replay, chapters and the transcript, beside the player.
const side = companion({ kind: 'vod', id, watchId: vod.watch_id || null, chat: chatRows, chapters: mems, transcript: segs, long: Number(vod.duration_seconds) >= 3600 });
const side = companion({ kind: 'vod', id, watchId: vod.watch_id || null, chat: chatRows, chapters: mems, transcript: segs, long: Number(vod.duration_seconds) >= 3600 });
const likes = await likesOf(req, 'vod', id);
const likes = await likesOf(req, 'vod', id);
@@ -1600,7 +1600,7 @@ ${labelsUi.chips(labelsUi.inForce(vod))}
@@ -1600,7 +1600,7 @@ ${labelsUi.chips(labelsUi.inForce(vod))}
${channelRow(vod.username, channelName, vod.follower_count)}
${channelRow(vod.username, channelName, vod.follower_count)}
<div class="ovv-actions-shell"><div class="ovv-actions">${likeButton(likes, 'vod', id)}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${saveForm}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${partyLink(watchPath, vod.title || 'Stream recording', vod)}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${ownOnly ? '' : clipAction(req, id, vod)}${watchMore({ path: watchPath, manageable, kind: 'vod', id, items: moreItems })}</div></div>
<div class="ovv-actions-shell"><div class="ovv-actions">${likeButton(likes, 'vod', id)}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${saveForm}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${partyLink(watchPath, vod.title || 'Stream recording', vod)}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${ownOnly ? '' : clipAction(req, id, vod)}${watchMore({ path: watchPath, manageable, kind: 'vod', id, items: moreItems })}</div></div>
</div>
</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="ovg-fill" data-ov-icon="circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
</div>
</div>
</div>
</div>
${manageable ? ownerPanel('vod', id, vod) : ''}
${manageable ? ownerPanel('vod', id, vod) : ''}
@@ -1681,9 +1681,9 @@ ${more || ''}
@@ -1681,9 +1681,9 @@ ${more || ''}
const clipAt = clip.start_time != null ? clock(Number(clip.start_time)) : null;
const clipAt = clip.start_time != null ? clock(Number(clip.start_time)) : null;
const clipCredit = !clip.vod_id ? ''
const clipCredit = !clip.vod_id ? ''
: sourceVod === 'down'
: sourceVod === 'down'
- ? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording. OpenVibe.Live did not answer just now, so its title is not shown.</p>`
+ ? html`<p class="ovv-source"><i class="ovg-fill" data-ov-icon="film" aria-hidden="true"></i> Cut from a stream recording. OpenVibe.Live did not answer just now, so its title is not shown.</p>`
: !sourceVod
: !sourceVod
- ? html`<p class="ovv-source"><i class="fa-solid fa-film" aria-hidden="true"></i> Cut from a stream recording that is not public any more.</p>`
+ ? html`<p class="ovv-source"><i class="ovg-fill" data-ov-icon="film" aria-hidden="true"></i> Cut from a stream recording that is not public any more.</p>`
: html`<p class="ovv-source">Cut from <a href="${fullRecording}">${sourceTitle || 'the recording it came from'}</a>${clipAt ? html` at ${clipAt}` : ''}${sourceWho ? html` · ${sourceWho}'s stream` : ''}</p>`;
: html`<p class="ovv-source">Cut from <a href="${fullRecording}">${sourceTitle || 'the recording it came from'}</a>${clipAt ? html` at ${clipAt}` : ''}${sourceWho ? html` · ${sourceWho}'s stream` : ''}</p>`;
const facts = watchFacts(clip.view_count, clip.created_at, clip.duration_seconds != null ? clip.duration_seconds : clip.duration, clip.visibility);
const facts = watchFacts(clip.view_count, clip.created_at, clip.duration_seconds != null ? clip.duration_seconds : clip.duration, clip.visibility);
@@ -1692,8 +1692,8 @@ ${more || ''}
@@ -1692,8 +1692,8 @@ ${more || ''}
const hlsAttr = hlsAttrs(clip);
const hlsAttr = hlsAttrs(clip);
const segs = transcriptSegments(clip);
const segs = transcriptSegments(clip);
const moreItems = [];
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 (clip.source_streamer_username) moreItems.push({ href: `${livePublic}/@${clip.source_streamer_username}`, icon: 'live', label: 'Channel on OpenVibe.Live', hint: 'Live streams and chat' });
+ if (threadUrl) moreItems.push({ href: threadUrl, icon: 'chat', label: 'Discuss on OpenVibe.Community' });
// A clip's transcript, when it has one, beside the player as on a recording.
// A clip's transcript, when it has one, beside the player as on a recording.
const side = companion({ kind: 'clip', id, watchId: clip.watch_id || null, chat: null, chapters: [], transcript: segs, long: false });
const side = companion({ kind: 'clip', id, watchId: clip.watch_id || null, chat: null, chapters: [], transcript: segs, long: false });
const likes = await likesOf(req, 'clip', id);
const likes = await likesOf(req, 'clip', id);
@@ -1717,14 +1717,14 @@ ${more || ''}
@@ -1717,14 +1717,14 @@ ${more || ''}
<div class="ovv-col ovv-col--main">
<div class="ovv-col ovv-col--main">
<div class="ovv-stage">
<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="ovg-fill" data-ov-icon="scissors" aria-hidden="true"></i> This clip is being cut. It plays here in a moment; the page checks again by itself.</p></div>`
: html`<div class="ovv-player-wrap"${labelsUi.attrs(labelsUi.inForce(clip), { owner: clip.username, opened: true })}><video class="ovv-player" data-ov-player${miniAttr(clip.title || 'Clip', clip.source_streamer_display_name || clip.display_name || clip.username, watchPath, fileOf(clip))} controls preload="metadata" playsinline${avatarAttr(clip.username, config.networkUrl)}${posterAttr(httpsUrl(clip.thumbnail_url))} src="${fileOf(clip) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}
: html`<div class="ovv-player-wrap"${labelsUi.attrs(labelsUi.inForce(clip), { owner: clip.username, opened: true })}><video class="ovv-player" data-ov-player${miniAttr(clip.title || 'Clip', clip.source_streamer_display_name || clip.display_name || clip.username, watchPath, fileOf(clip))} controls preload="metadata" playsinline${avatarAttr(clip.username, config.networkUrl)}${posterAttr(httpsUrl(clip.thumbnail_url))} src="${fileOf(clip) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}></video></div>`}
<div class="ovv-head">
<div class="ovv-head">
${titleLine(aiClip ? 'AI clip' : 'Clip', clip.title || 'Clip')}
${titleLine(aiClip ? 'AI clip' : 'Clip', clip.title || 'Clip')}
${labelsUi.chips(labelsUi.inForce(clip))}
${labelsUi.chips(labelsUi.inForce(clip))}
<div class="ovv-head-row">
<div class="ovv-head-row">
${channelRow(clip.source_streamer_username || clip.username, clip.source_streamer_display_name || clip.display_name || clip.username, clip.follower_count)}
${channelRow(clip.source_streamer_username || clip.username, clip.source_streamer_display_name || clip.display_name || clip.username, clip.follower_count)}
-<div class="ovv-actions-shell"><div class="ovv-actions">${likeButton(likes, 'clip', id)}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${cutting ? '' : partyLink(watchPath, clip.title || 'Clip', clip)}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${fullRecording ? html`<a class="ovv-btn" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${watchMore({ path: watchPath, manageable, kind: 'clip', id, items: moreItems })}</div></div>
+<div class="ovv-actions-shell"><div class="ovv-actions">${likeButton(likes, 'clip', id)}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${cutting ? '' : partyLink(watchPath, clip.title || 'Clip', clip)}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${fullRecording ? html`<a class="ovv-btn" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="ovg-fill" data-ov-icon="film" aria-hidden="true"></i> Full recording</a>` : ''}${watchMore({ path: watchPath, manageable, kind: 'clip', id, items: moreItems })}</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1892,7 +1892,7 @@ ${more || ''}
@@ -1892,7 +1892,7 @@ ${more || ''}
title: `@${username}`,
title: `@${username}`,
styles: ['kit.css'],
styles: ['kit.css'],
description: `Videos, recordings and clips from @${username} on OpenVibe.Video.`,
description: `Videos, recordings and clips from @${username} on OpenVibe.Video.`,
- body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy"></span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p>${raw(shareButton({ url: `${base}/@${username}`, title: `@${username}`, text: `Videos, recordings and clips from @${username} on OpenVibe.Video.`, image: `${config.networkUrl}/avatar/${username}` }))}</div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
+ body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy"></span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p>${raw(shareButton({ url: `${base}/@${username}`, title: `@${username}`, text: `Videos, recordings and clips from @${username} on OpenVibe.Video.`, image: `${config.networkUrl}/avatar/${username}` }))}</div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="ovg-fill" data-ov-icon="live" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Videos</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}
${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Videos</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}
<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), { title: 'No recordings yet', text: 'Recordings of this channel’s streams appear here.' })}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), { title: 'No recordings yet', text: 'Recordings of this channel’s streams appear here.' })}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), { title: 'No clips yet', text: 'Clips cut from this channel appear here.' })}</section>` : ''}
${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), { title: 'No clips yet', text: 'Clips cut from this channel appear here.' })}</section>` : ''}
@@ -1910,7 +1910,7 @@ ${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2
@@ -1910,7 +1910,7 @@ ${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2
crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists' }],
body: html`<h1>Your playlists</h1>
body: html`<h1>Your playlists</h1>
<section class="ovv-section" aria-labelledby="h-create"><h2 id="h-create">New playlist</h2>
<section class="ovv-section" aria-labelledby="h-create"><h2 id="h-create">New playlist</h2>
-<form class="ovv-save-form ovv-panel" method="post" action="/playlists"><label for="new-title">Title</label> <input id="new-title" name="title" type="text" maxlength="${playlists.TITLE_MAX}" required placeholder="Watch later"> <label for="new-vis">Who can see it</label> <select id="new-vis" name="visibility">${playlists.VISIBILITIES.map((v) => html`<option value="${v}"${v === 'private' ? ' selected' : ''}>${visibilityLabel(v)}</option>`)}</select> <button class="ovv-btn ovv-btn--primary" type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Create</button></form></section>
+<form class="ovv-save-form ovv-panel" method="post" action="/playlists"><label for="new-title">Title</label> <input id="new-title" name="title" type="text" maxlength="${playlists.TITLE_MAX}" required placeholder="Watch later"> <label for="new-vis">Who can see it</label> <select id="new-vis" name="visibility">${playlists.VISIBILITIES.map((v) => html`<option value="${v}"${v === 'private' ? ' selected' : ''}>${visibilityLabel(v)}</option>`)}</select> <button class="ovv-btn ovv-btn--primary" type="submit"><i class="ovg-fill" data-ov-icon="plus" aria-hidden="true"></i> Create</button></form></section>
<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2>
<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2>
${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>` : raw(kit.empty({ icon: 'list', title: 'No playlists yet', text: 'Save a recording or clip from its watch page.', action: { href: '/', label: 'Find something to watch', icon: 'play' } }))}</section>`,
${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>` : raw(kit.empty({ icon: 'list', title: 'No playlists yet', text: 'Save a recording or clip from its watch page.', action: { href: '/', label: 'Find something to watch', icon: 'play' } }))}</section>`,
});
});
@@ -1994,7 +1994,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
@@ -1994,7 +1994,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
const { href, thumb, title, channel, duration: dur } = info;
const { href, thumb, title, channel, duration: dur } = info;
listLd.push({ '@type': 'ListItem', position: listLd.length + 1, url: `${base}${href}`, name: String(title).slice(0, 110) });
listLd.push({ '@type': 'ListItem', position: listLd.length + 1, url: `${base}${href}`, name: String(title).slice(0, 110) });
rows.push(html`<li class="ovv-item-row">
rows.push(html`<li class="ovv-item-row">
-<a class="ovv-item-media" href="${href}" tabindex="-1" aria-hidden="true">${thumb ? thumbImg(thumb, '132px') : html`<span class="ovv-card-placeholder"><i class="fa-solid ${info.icon}"></i></span>`}${dur != null ? html`<span class="ovv-dur">${durationLabel(dur)}</span>` : ''}</a>
+<a class="ovv-item-media" href="${href}" tabindex="-1" aria-hidden="true">${thumb ? thumbImg(thumb, '132px') : html`<span class="ovv-card-placeholder"><i class="ovg-fill" data-ov-icon="${info.icon}"></i></span>`}${dur != null ? html`<span class="ovv-dur">${durationLabel(dur)}</span>` : ''}</a>
<div class="ovv-playlist-main"><a class="ovv-item-title" href="${href}">${title}</a>${channel ? html`<span class="ovv-meta">@${channel}</span>` : ''}</div>
<div class="ovv-playlist-main"><a class="ovv-item-title" href="${href}">${title}</a>${channel ? html`<span class="ovv-meta">@${channel}</span>` : ''}</div>
${isOwner ? itemControls(pl.id, it) : ''}
${isOwner ? itemControls(pl.id, it) : ''}
</li>`);
</li>`);
@@ -2015,7 +2015,7 @@ ${isOwner ? itemControls(pl.id, it) : ''}
@@ -2015,7 +2015,7 @@ ${isOwner ? itemControls(pl.id, it) : ''}
${pl.description ? html`<p class="ovv-lede">${pl.description}</p>` : ''}
${pl.description ? html`<p class="ovv-lede">${pl.description}</p>` : ''}
<p class="ovv-meta">${visibilityLabel(pl.visibility)} · ${items.length} item${items.length === 1 ? '' : 's'} · updated ${dateLabel(pl.updated_at)}</p>
<p class="ovv-meta">${visibilityLabel(pl.visibility)} · ${items.length} item${items.length === 1 ? '' : 's'} · updated ${dateLabel(pl.updated_at)}</p>
<p class="ovv-meta">Curated by ${ownerBy}</p>
<p class="ovv-meta">Curated by ${ownerBy}</p>
-${playAllHref ? html`<p><a class="ovv-btn ovv-btn--primary" href="${playAllHref}"><i class="fa-solid fa-play" aria-hidden="true"></i> Play all</a></p>` : ''}</header>
+${playAllHref ? html`<p><a class="ovv-btn ovv-btn--primary" href="${playAllHref}"><i class="ovg-fill" data-ov-icon="play" aria-hidden="true"></i> Play all</a></p>` : ''}</header>
${items.length ? html`<ol class="ovv-item-list">${rows}</ol>` : html`<p class="muted">This playlist is empty.</p>`}
${items.length ? html`<ol class="ovv-item-list">${rows}</ol>` : html`<p class="muted">This playlist is empty.</p>`}
${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down buttons, or remove them. <a href="/playlists">Back to your playlists</a>.</p>` : ''}`,
${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down buttons, or remove them. <a href="/playlists">Back to your playlists</a>.</p>` : ''}`,
});
});
@@ -18,15 +18,15 @@ const PAGE = 24;
@@ -18,15 +18,15 @@ const PAGE = 24;
// Live's stream categories (OpenVibe.Live server/ai/ai-analysis.js CATEGORIES), as people read them, with an icon each.
// Live's stream categories (OpenVibe.Live server/ai/ai-analysis.js CATEGORIES), as people read them, with an icon each.
const CATEGORIES = {
const CATEGORIES = {
- gaming: { label: 'Gaming', icon: 'fa-gamepad', blurb: 'Games played live, from battle royales to sports games.' },
- irl: { label: 'IRL and chatting', icon: 'fa-comments', blurb: 'Talking, cooking and everyday life, on camera.' },
- desktop: { label: 'Desktop and tech', icon: 'fa-desktop', blurb: 'Screens shared live: coding, building software and tech.' },
- robot: { label: 'Robots', icon: 'fa-robot', blurb: 'Robots driven, built and tested live.' },
- outdoors: { label: 'Outdoors', icon: 'fa-mountain-sun', blurb: 'Streams from outside: walks, hikes and time in the open air.' },
- travel: { label: 'Travel and driving', icon: 'fa-route', blurb: 'On the road, in the air and out exploring.' },
- building: { label: 'Making and crafts', icon: 'fa-hammer', blurb: 'Building, fixing and making things by hand.' },
- music: { label: 'Music', icon: 'fa-music', blurb: 'Playing, mixing and making music live.' },
- other: { label: 'Everything else', icon: 'fa-shapes', blurb: 'Streams that fit no other shelf.' },
+ gaming: { label: 'Gaming', icon: 'game', blurb: 'Games played live, from battle royales to sports games.' },
+ irl: { label: 'IRL and chatting', icon: 'chat', blurb: 'Talking, cooking and everyday life, on camera.' },
+ desktop: { label: 'Desktop and tech', icon: 'computer', blurb: 'Screens shared live: coding, building software and tech.' },
+ robot: { label: 'Robots', icon: 'robot', blurb: 'Robots driven, built and tested live.' },
+ outdoors: { label: 'Outdoors', icon: 'mountain', blurb: 'Streams from outside: walks, hikes and time in the open air.' },
+ travel: { label: 'Travel and driving', icon: 'route', blurb: 'On the road, in the air and out exploring.' },
+ building: { label: 'Making and crafts', icon: 'hammer', blurb: 'Building, fixing and making things by hand.' },
+ music: { label: 'Music', icon: 'music', blurb: 'Playing, mixing and making music live.' },
+ other: { label: 'Everything else', icon: 'shapes', blurb: 'Streams that fit no other shelf.' },
};
};
const isCategory = (v) => Object.prototype.hasOwnProperty.call(CATEGORIES, String(v || ''));
const isCategory = (v) => Object.prototype.hasOwnProperty.call(CATEGORIES, String(v || ''));
const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
@@ -201,7 +201,7 @@ function mountUploadPages(r, h) {
@@ -201,7 +201,7 @@ function mountUploadPages(r, h) {
const done = Number(row.parts_received) * Number(row.part_size);
const done = Number(row.parts_received) * Number(row.part_size);
const total = Number(row.size_bytes);
const total = Number(row.size_bytes);
return html`<li class="ovv-myrow" data-state="${state}">
return html`<li class="ovv-myrow" data-state="${state}">
-<a class="ovv-mythumb" href="${watch}" tabindex="-1" aria-hidden="true">${row.thumbnail_url ? html`<img src="${row.thumbnail_url}" alt="" loading="lazy">` : html`<i class="fa-solid fa-film"></i>`}</a>
+<a class="ovv-mythumb" href="${watch}" tabindex="-1" aria-hidden="true">${row.thumbnail_url ? html`<img src="${row.thumbnail_url}" alt="" loading="lazy">` : html`<i class="ovg-fill" data-ov-icon="film"></i>`}</a>
<div class="ovv-mybody">
<div class="ovv-mybody">
<div class="ovv-mytitleline"><a class="ovv-mytitle" href="${watch}">${row.title}</a><span class="ovv-mystate">${statusText(row)}</span></div>
<div class="ovv-mytitleline"><a class="ovv-mytitle" href="${watch}">${row.title}</a><span class="ovv-mystate">${statusText(row)}</span></div>
<p class="ovv-mymeta">${meta}</p>
<p class="ovv-mymeta">${meta}</p>
@@ -312,9 +312,9 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
@@ -312,9 +312,9 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
<div class="ovv-my-head"><p class="ovv-lede">${fresh.length
<div class="ovv-my-head"><p class="ovv-lede">${fresh.length
? `${fresh.length} video${fresh.length === 1 ? '' : 's'}, newest first. A processing video finishes in the background, so you can close the page.`
? `${fresh.length} video${fresh.length === 1 ? '' : 's'}, newest first. A processing video finishes in the background, so you can close the page.`
: 'Everything you upload to OpenVibe.Video appears here, with its state and who can see it.'}</p>
: 'Everything you upload to OpenVibe.Video appears here, with its state and who can see it.'}</p>
-<a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a></div>
+<a class="ovv-btn ovv-btn--primary" href="/upload"><i class="ovg-fill" data-ov-icon="upload" aria-hidden="true"></i> Upload a video</a></div>
<form class="ovv-panel" method="post" action="/me/videos/share-default"><label>New videos <select name="audience">${audienceOptions(defaultAudience)}</select></label> <label><input type="checkbox" name="apply" value="1"> Apply to all my existing videos</label> <button class="ovv-btn" type="submit">Save default</button></form>
<form class="ovv-panel" method="post" action="/me/videos/share-default"><label>New videos <select name="audience">${audienceOptions(defaultAudience)}</select></label> <label><input type="checkbox" name="apply" value="1"> Apply to all my existing videos</label> <button class="ovv-btn" type="submit">Save default</button></form>
-${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : html`<div class="ovv-my-empty"><i class="fa-solid fa-film" aria-hidden="true"></i><h2>No videos yet</h2><p>Choose a video and the upload starts while you fill in the title.</p><a class="ovv-btn ovv-btn--primary" href="/upload">Upload your first video</a></div>`}
+${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : html`<div class="ovv-my-empty"><i class="ovg-fill" data-ov-icon="film" aria-hidden="true"></i><h2>No videos yet</h2><p>Choose a video and the upload starts while you fill in the title.</p><a class="ovv-btn ovv-btn--primary" href="/upload">Upload your first video</a></div>`}
<dialog class="ovv-rules" data-delete-sheet aria-label="Delete this video">
<dialog class="ovv-rules" data-delete-sheet aria-label="Delete this video">
<h2>Delete this video?</h2>
<h2>Delete this video?</h2>
<p data-delete-message>Its file is removed from OpenVibe.Media too. This cannot be undone.</p>
<p data-delete-message>Its file is removed from OpenVibe.Media too. This cannot be undone.</p>
@@ -505,7 +505,7 @@ ${what}
@@ -505,7 +505,7 @@ ${what}
${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}${/^[A-Za-z0-9_]{1,32}$/.test(channel) ? html`<img src="${ctx.config.networkUrl}/avatar/${channel}" alt="" loading="lazy">` : ''}</a>` : ''}
${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}${/^[A-Za-z0-9_]{1,32}$/.test(channel) ? html`<img src="${ctx.config.networkUrl}/avatar/${channel}" alt="" loading="lazy">` : ''}</a>` : ''}
<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
</div>
</div>
-<div class="ovv-byline-actions">${saveForm}${ready && vod && fileOf(vod) ? html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(fileOf(vod))}&source_url=${encodeURIComponent(`${String(ctx.config.video.url).replace(/\/+$/, '')}${watchPath}`)}&title=${encodeURIComponent(String(row.title || 'Watch party'))}"><i class="fa-solid fa-users" aria-hidden="true"></i> Watch together</a>` : ''}${own && ready && row.state === 'active' && studioVideoEdit ? html`<a class="ovk-btn ovk-btn--quiet" href="${studioEditUrl(row)}">Edit in Studio</a>` : ''}${raw(shareButton({ cls: 'ovv-btn', url: `${ctx.config.video.url}${watchPath}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null, kind: 'video', id: row.id }))}${raw(askAgent({ text: `About the video “${row.title}” on OpenVibe.Video (${ctx.config.video.url}${watchPath}): ` }))}</div>
+<div class="ovv-byline-actions">${saveForm}${ready && vod && fileOf(vod) ? html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(fileOf(vod))}&source_url=${encodeURIComponent(`${String(ctx.config.video.url).replace(/\/+$/, '')}${watchPath}`)}&title=${encodeURIComponent(String(row.title || 'Watch party'))}"><i class="ovg-fill" data-ov-icon="members" aria-hidden="true"></i> Watch together</a>` : ''}${own && ready && row.state === 'active' && studioVideoEdit ? html`<a class="ovk-btn ovk-btn--quiet" href="${studioEditUrl(row)}">Edit in Studio</a>` : ''}${raw(shareButton({ cls: 'ovv-btn', url: `${ctx.config.video.url}${watchPath}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null, kind: 'video', id: row.id }))}${raw(askAgent({ text: `About the video “${row.title}” on OpenVibe.Video (${ctx.config.video.url}${watchPath}): ` }))}</div>
</div>
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>
${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>
@@ -64,9 +64,9 @@ function streamDate(v) {
@@ -64,9 +64,9 @@ function streamDate(v) {
/** What a kind of video is called on the page. */
/** What a kind of video is called on the page. */
const KINDS = {
const KINDS = {
- recording: { icon: 'fa-solid fa-tower-broadcast', label: 'Stream recording' },
- clip: { icon: 'fa-solid fa-scissors', label: 'Clip' },
- upload: { icon: 'fa-solid fa-upload', label: 'Upload' },
+ recording: { icon: 'live', label: 'Stream recording' },
+ clip: { icon: 'scissors', label: 'Clip' },
+ upload: { icon: 'upload', label: 'Upload' },
};
};
/**
/**
@@ -94,7 +94,7 @@ function whatItIs({ kind, label = null, streamedAt = null, publishedAt = null, d
@@ -94,7 +94,7 @@ function whatItIs({ kind, label = null, streamedAt = null, publishedAt = null, d
if (len) facts.push(len);
if (len) facts.push(len);
const v = Number(views);
const v = Number(views);
if (Number.isSafeInteger(v) && v > 0) facts.push(`${v.toLocaleString('en-US')} ${v === 1 ? 'view' : 'views'}`);
if (Number.isSafeInteger(v) && v > 0) facts.push(`${v.toLocaleString('en-US')} ${v === 1 ? 'view' : 'views'}`);
- return html`<p class="ovv-what"><span class="ovv-what-kind"><i class="${k.icon}" aria-hidden="true"></i> ${label || k.label}</span>${facts.length ? html`<span class="ovv-what-facts">${facts.map((f, i) => (i === 0 ? f : [html` · `, f]))}</span>` : ''}</p>`;
+ return html`<p class="ovv-what"><span class="ovv-what-kind"><i class="ovg-fill" data-ov-icon="${k.icon}" aria-hidden="true"></i> ${label || k.label}</span>${facts.length ? html`<span class="ovv-what-facts">${facts.map((f, i) => (i === 0 ? f : [html` · `, f]))}</span>` : ''}</p>`;
}
}
/** A date as the value of a <time> element's datetime. */
/** A date as the value of a <time> element's datetime. */
@@ -107,20 +107,20 @@ function isoOf(v) {
@@ -107,20 +107,20 @@ function isoOf(v) {
/** A button of the action row that opens a menu or a small form. `body` is the panel's content (html). */
/** 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 }) {
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>
+ 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="ovg-fill" data-ov-icon="${icon}" aria-hidden="true"></i>${iconOnly ? '' : html` ${label}`}</summary>
<div class="ovv-pop-panel" role="group" aria-label="${label}">${body}</div></details>`;
<div class="ovv-pop-panel" role="group" aria-label="${label}">${body}</div></details>`;
}
}
/** The owner's Delete in the action row: one tap opens the question, one more deletes (the route checks confirm=1). */
/** The owner's Delete in the action row: one tap opens the question, one more deletes (the route checks confirm=1). */
function deleteAction({ action, what }) {
function deleteAction({ action, what }) {
- return actionMenu({ icon: 'fa-solid fa-trash-can', label: 'Delete', title: `Delete this ${what}`, cls: 'ovv-deleter', body: html`<p class="ovv-pop-title">Delete this ${what}?</p>
+ return actionMenu({ icon: 'trash', label: 'Delete', title: `Delete this ${what}`, cls: 'ovv-deleter', body: html`<p class="ovv-pop-title">Delete this ${what}?</p>
<p class="ovv-fine">It goes for good: nobody can watch it again, and it cannot be brought back.</p>
<p class="ovv-fine">It goes for good: nobody can watch it again, and it cannot be brought back.</p>
-<form method="post" action="${action}/delete"><input type="hidden" name="confirm" value="1"><button class="ovv-btn ovv-btn--danger" type="submit"><i class="fa-solid fa-trash-can" aria-hidden="true"></i> Delete for good</button></form>` });
+<form method="post" action="${action}/delete"><input type="hidden" name="confirm" value="1"><button class="ovv-btn ovv-btn--danger" type="submit"><i class="ovg-fill" data-ov-icon="trash" aria-hidden="true"></i> Delete for good</button></form>` });
}
}
/** The More menu's rows: [{ href, icon, label, hint? }]. */
/** The More menu's rows: [{ href, icon, label, hint? }]. */
function menuLinks(items) {
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>`;
+ 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="ovg-fill" data-ov-icon="${it.icon}" aria-hidden="true"></i><span>${it.label}${it.hint ? html`<small>${it.hint}</small>` : ''}</span></a></li>`)}</ul>`;
}
}
const FORMATS = {
const FORMATS = {
@@ -136,10 +136,10 @@ const FORMATS = {
@@ -136,10 +136,10 @@ const FORMATS = {
* optional `copy` ({ label, hint, href }) shown first, only with JavaScript (it copies the file's text).
* optional `copy` ({ label, hint, href }) shown first, only with JavaScript (it copies the file's text).
*/
*/
function cornerMenu({ at, what, items, copy = null }) {
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>
+ 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="ovg-fill" data-ov-icon="download" aria-hidden="true"></i></summary>
<div class="ovv-mini-menu" role="group" aria-label="Download the ${what}">
<div class="ovv-mini-menu" role="group" aria-label="Download the ${what}">
<p class="ovv-mini-head">Download the ${what}</p>
<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>` : ''}
+${copy ? html`<button type="button" class="ovv-mini-item" data-ovv-copy="${copy.href}" hidden><i data-ov-icon="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>`)}
${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>`;
</div></details>`;
}
}
@@ -200,7 +200,7 @@ function companion({ kind, id, watchId = null, chat, chapters, transcript, long
@@ -200,7 +200,7 @@ function companion({ kind, id, watchId = null, chat, chapters, transcript, long
${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>`}
${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}">
${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>
<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.search ? html`<label class="ovv-tp-search" hidden data-ovv-search-wrap><i class="ovg-fill" data-ov-icon="search" aria-hidden="true"></i><input type="search" placeholder="Search the transcript" aria-label="Search the transcript" data-ovv-search></label>` : ''}
${t.body}
${t.body}
</section>`)}
</section>`)}
</section>`;
</section>`;
@@ -15,7 +15,7 @@ const fn = src.slice(src.indexOf('function cardPlaceholder('), src.indexOf('func
@@ -15,7 +15,7 @@ const fn = src.slice(src.indexOf('function cardPlaceholder('), src.indexOf('func
assert.ok(fn.includes("/^[A-Za-z0-9_]{1,32}$/.test(name)"), 'only a username becomes an avatar address');
assert.ok(fn.includes("/^[A-Za-z0-9_]{1,32}$/.test(name)"), 'only a username becomes an avatar address');
assert.ok(fn.includes('${faceBase}/avatar/${name}?s=160'), 'Network\'s avatar, at the size it is drawn');
assert.ok(fn.includes('${faceBase}/avatar/${name}?s=160'), 'Network\'s avatar, at the size it is drawn');
assert.ok(fn.includes('class="ovv-who-bg"') && fn.includes('class="ovv-who-face"'), 'the face over a blur of it');
assert.ok(fn.includes('class="ovv-who-bg"') && fn.includes('class="ovv-who-face"'), 'the face over a blur of it');
-assert.ok(fn.includes('<i class="fa-solid ${icon}"></i>'), 'the icon when there is no username');
+assert.ok(fn.includes('<i class="ovg-fill" data-ov-icon="${icon}"></i>'), 'the icon when there is no username');
assert.ok(src.includes('${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : cardPlaceholder(o, icon)}'), 'cards use it');
assert.ok(src.includes('${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : cardPlaceholder(o, icon)}'), 'cards use it');
assert.ok(/faceBase = \/\^https\?:\\\/\\\/\/\.test\(String\(config\.networkUrl/.test(src), 'the origin comes from the config, and only an http(s) one');
assert.ok(/faceBase = \/\^https\?:\\\/\\\/\/\.test\(String\(config\.networkUrl/.test(src), 'the origin comes from the config, and only an http(s) one');
for (const sel of ['.ovv-card-who .ovv-who-bg', '.ovv-card-who .ovv-who-face']) assert.ok(css.includes(sel), sel);
for (const sel of ['.ovv-card-who .ovv-who-bg', '.ovv-card-who .ovv-who-face']) assert.ok(css.includes(sel), sel);
@@ -62,18 +62,21 @@ for (const f of LAYOUTS) {
@@ -62,18 +62,21 @@ for (const f of LAYOUTS) {
assert.ok(/shell\.page|ov-glyphs\.js|\/shared\/navbar/.test(t), `${f} renders pages without the navbar or /shared/ov-glyphs.js`);
assert.ok(/shell\.page|ov-glyphs\.js|\/shared\/navbar/.test(t), `${f} renders pages without the navbar or /shared/ov-glyphs.js`);
}
}
-// Every fa- name in the pages, the scripts and the server views is drawn by a glyph or a brand.
-const MOD = /^(solid|regular|light|thin|duotone|brands|sharp|classic|fw|lg|xl|xs|sm|2xs|2xl|\d+x|li|ul|border|inverse|pull-left|pull-right|rotate-\d+|rotate-by|flip-horizontal|flip-vertical|flip-both|width-auto|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|stack|stack-1x|stack-2x|layers|layers-text|layers-counter|swap-opacity|stack-text)$/;
+// No Font Awesome markup is left: every icon names an OpenVibe glyph or brand, and the name is one that is drawn.
const files = [];
const files = [];
for (const r of CODE_ROOTS) files.push(...walk(path.join(ROOT, r), /\.(html|js)$/));
for (const r of CODE_ROOTS) files.push(...walk(path.join(ROOT, r), /\.(html|js)$/));
const missing = new Map();
const missing = new Map();
for (const f of files) {
for (const f of files) {
- for (const m of fs.readFileSync(f, 'utf8').matchAll(/\bfa-([a-z0-9]+(?:-[a-z0-9]+)*)\b/g)) {
- const n = m[1];
- if (MOD.test(n) || glyphs.fromFa(n) || brands.fromFa(n)) continue;
- if (!missing.has(n)) missing.set(n, rel(f));
+ const t = fs.readFileSync(f, 'utf8');
+ const fa = t.match(/class=["'][^"']*\bfa-[a-z][^"']*["']/);
+ assert.ok(!fa, `${rel(f)} still uses Font Awesome markup: ${fa ? fa[0] : ''}`);
+ assert.ok(!/\bfa-(solid|regular|brands|light)\b/.test(t), `${rel(f)} still names a Font Awesome style class`);
+ for (const m of t.matchAll(/data-ov-(icon|brand)="([a-z0-9-]+)"/g)) {
+ const [kind, name] = [m[1], m[2]];
+ const drawn = kind === 'icon' ? glyphs.NAMES.includes(name) : Boolean(brands.BRANDS[name]);
+ if (!drawn && !missing.has(`${kind}:${name}`)) missing.set(`${kind}:${name}`, rel(f));
}
}
}
}
-assert.deepStrictEqual([...missing].map(([n, f]) => `fa-${n} (${f})`), [], 'every icon this site names is drawn by the OpenVibe glyphs or brands');
+assert.deepStrictEqual([...missing].map(([n, f]) => `${n} (${f})`), [], 'every icon this site names is drawn by the OpenVibe glyphs or brands');
-console.log(`no font awesome: ${pages.length} pages, ${files.length} files, every fa- name drawn`);
+console.log(`no font awesome: ${pages.length} pages, ${files.length} files, every icon named is drawn`);
@@ -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(/<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(/<a class="ovv-btn" href="\/auth\/login\?next=%2Fv%2F101" title="Sign in to make a clip of up to 60 seconds">[^<]*<i class="ovg-fill" data-ov-icon="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');
@@ -625,7 +625,7 @@ function countOccurrences(haystack, needle) {
@@ -625,7 +625,7 @@ function countOccurrences(haystack, needle) {
// (this site's search first); the page without JavaScript keeps its Search link.
// (this site's search first); the page without JavaScript keeps its Search link.
await check('the navbar names the site\'s places: Videos, Upload, Record, Your videos, and Playlists under More; Search is the magnifier', async () => {
await check('the navbar names the site\'s places: Videos, Upload, Record, Your videos, and Playlists under More; Search is the magnifier', async () => {
const home = (await getVideo('/')).text;
const home = (await getVideo('/')).text;
- assert.match(home, /"links":\[\{"label":"Videos","href":"\/","icon":"fa-play","active":true\},\{"label":"Search","href":"\/search","icon":"fa-magnifying-glass","hidden":true,"active":false\},\{"label":"Upload","href":"\/upload","icon":"fa-upload","active":false\},\{"label":"Record","href":"\/record","icon":"fa-video","active":false\},\{"label":"Your videos","href":"\/me\/videos","icon":"fa-photo-film","active":false\},\{"label":"Playlists","href":"\/playlists","icon":"fa-list-check","more":true,"active":false\}\]/);
+ assert.match(home, /"links":\[\{"label":"Videos","href":"\/","icon":"play","active":true\},\{"label":"Search","href":"\/search","icon":"search","hidden":true,"active":false\},\{"label":"Upload","href":"\/upload","icon":"upload","active":false\},\{"label":"Record","href":"\/record","icon":"video","active":false\},\{"label":"Your videos","href":"\/me\/videos","icon":"film","active":false\},\{"label":"Playlists","href":"\/playlists","icon":"steps","more":true,"active":false\}\]/);
assert.ok(/"appBar":true/.test(home) && /"search":\{"page":"\/search","label":"OpenVibe.Video"\}/.test(home), 'the app bar, and the magnifier offers Video\'s search first');
assert.ok(/"appBar":true/.test(home) && /"search":\{"page":"\/search","label":"OpenVibe.Video"\}/.test(home), 'the app bar, and the magnifier offers Video\'s search first');
assert.ok(home.includes('data-ov-server-nav="true"') && home.includes('href="/playlists"') && home.includes('href="/search"'), 'the no-JS server nav links Playlists and Search too');
assert.ok(home.includes('data-ov-server-nav="true"') && home.includes('href="/playlists"') && home.includes('href="/search"'), 'the no-JS server nav links Playlists and Search too');
});
});