openvibers/openvibe.mediahub Commits
Video: Edit first in the owner's action row, opening the panel under the player; who can see it as three choices and a badge in the meta; parts numbered by place; 1 view; categories capitalized; chat, chapters and transcript sized to what they hold on phones (#159)
Changes
@@@ -509,3 +509,40 @@ a.ovv-channel-name:hover { text-decoration: underline; }.ovv-pip-pills { position: absolute; right: 12px; top: 12px; z-index: 2; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; max-width: calc(100% - 24px); }.ovv-pip-pills button { padding: 5px 10px; border: 1px solid rgba(255, 255, 255, .2); border-radius: 999px; background: rgba(0, 0, 0, .6); color: #fff; font-size: .74rem; cursor: pointer; }@media (hover: none) { .ovv-pip-bar { opacity: 1; } }++++/* ── The owner's tools (2026-10-27: "I don't see any way to edit things … I just had to scroll way down") ──++ Edit is first in the action row; the panel opens right under the player and stays out of the way until then (a++ link to #manage without JavaScript still shows it). Who can see it is three choices, and the meta line says which. */++.ovv-btn--edit { border-color: color-mix(in srgb, var(--ovv-accent) 55%, var(--ovv-line-strong)); color: var(--text-primary, #e6edf7); }++.ovv-btn--edit i { color: var(--ovv-accent); }++.ovv-owner:not([open]):not(:target) { display: none; }++.ovv-owner[open] { animation: ovv-owner-in 0.18s ease-out; }++@keyframes ovv-owner-in { from { opacity: 0; transform: translateY(-4px); } }++.ovv-owner h2 i { font-size: 0.85em; color: var(--ovv-accent); }++.ovv-vis { border: 0; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }++.ovv-vis legend { padding: 0; margin-bottom: 6px; font-size: .85rem; font-weight: 700; }++.ovv-vis-opt { position: relative; display: block; cursor: pointer; }++.ovv-vis-opt input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }++.ovv-vis-card {++ display: grid; justify-items: center; gap: 3px; height: 100%; box-sizing: border-box; padding: 12px 8px; border-radius: 12px; text-align: center;++ border: 1px solid var(--ovv-line-strong); background: color-mix(in srgb, var(--ovv-card) 70%, transparent); transition: border-color .15s, background .15s;++}++.ovv-vis-card i { font-size: 1.05rem; color: var(--ovv-dim); }++.ovv-vis-card b { font-size: .9rem; }++.ovv-vis-card small { font-size: .74rem; color: var(--ovv-faint); line-height: 1.25; }++.ovv-vis-opt input:checked + .ovv-vis-card { border-color: var(--ovv-accent); background: color-mix(in srgb, var(--ovv-accent) 14%, var(--ovv-card)); }++.ovv-vis-opt input:checked + .ovv-vis-card i { color: var(--ovv-accent); }++.ovv-vis-opt input:focus-visible + .ovv-vis-card { outline: 2px solid var(--ovv-accent); outline-offset: 2px; }++.ovv-vis-badge { display: inline-flex; align-items: center; gap: 4px; padding: 0 7px; border-radius: 999px; font-size: .74rem; font-weight: 700; border: 1px solid var(--ovv-line-strong); }++.ovv-vis-badge--unlisted { color: #fbbf24; border-color: color-mix(in srgb, #fbbf24 40%, transparent); }++.ovv-vis-badge--private { color: #f87171; border-color: color-mix(in srgb, #f87171 40%, transparent); }++@media (max-width: 1000px) {++ /* Under the player, where Edit was tapped (it was seventh, under the comments). */++ .ovv-owner { order: 1; }++ /* Chat, chapters and the transcript take the room they need, up to most of the screen: one message no longer sat++ at the top of an empty 560 px box. */++ .ovv-companion--tabs, .ovv-companion--one { height: auto; max-height: min(560px, 68vh); max-height: min(560px, 68dvh); }++ .ovv-companion .ovv-tp-body { flex: 0 1 auto; }++}++@media (max-width: 420px) { .ovv-vis-card small { display: none; } }++@media (prefers-reduced-motion: reduce) { .ovv-owner[open] { animation: none; } }@@@ -647,6 +647,18 @@ function createVideoRoutes(ctx) {}const savedNotice = (req) => (req.query.saved === '1' ? notice('Saved on OpenVibe.Live.', 'ok') : '');const visibilityOptions = (current) => VISIBILITIES.map((v) => html`<option value="${v}"${v === current ? ' selected' : ''}>${visibilityLabel(v)}</option>`);++ // 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)'],++ };++ const visibilityChoices = (current) => html`<fieldset class="ovv-vis"><legend>Who can see it</legend>${VISIBILITIES.map((v) => html`<label class="ovv-vis-opt"><input type="radio" name="visibility" value="${v}"${v === current ? ' checked' : ''}><span class="ovv-vis-card"><i class="${VISIBILITY_LOOK[v][0]}" aria-hidden="true"></i><b>${visibilityLabel(v)}</b><small>${VISIBILITY_LOOK[v][1]}</small></span></label>`)}</fieldset>`;++ /** The owner's badge in the meta line: who can see it now. */++ const visibilityBadge = (v) => html`<span class="ovv-vis-badge ovv-vis-badge--${VISIBILITIES.includes(v) ? v : 'public'}"><i class="${(VISIBILITY_LOOK[v] || VISIBILITY_LOOK.public)[0]}" aria-hidden="true"></i> ${visibilityLabel(v || 'public')}</span>`;++ /** The owner's Edit button, first in the action row: opens the edit panel under the player. */++ 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, delete"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</a>`;/*** "Clip" in the action row: a finished recording, any signed-in viewer who can see it (Live's rule, as on Live). A* small form under the button; signed out, the button signs in first and comes back.@@@ -677,12 +689,11 @@ ${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">Manage this ${what}</h2><span class="ovv-fine">Title, ${kind === 'vod' ? 'description, ' : ''}who can see it, delete</span></summary>++ 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, delete</span></summary><form class="ovv-owner-form" method="post" action="${action}/manage"><label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="200" value="${item.title || ''}" required></label>${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textarea name="description" rows="3" maxlength="2000">${item.description || ''}</textarea></label>` : ''}--<label class="ovv-field"><span>Visibility</span><select name="visibility">${visibilityOptions(item.visibility || 'public')}</select></label>--<p class="ovv-fine">Public is listed and searchable; unlisted opens only by its link; private only for you and staff.</p>++${visibilityChoices(item.visibility || 'public')}<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button></form><details class="ovv-danger"><summary>Delete this ${what}</summary>@@@ -1157,8 +1168,9 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovvconst metaParts = [];if (vod.created_at) metaParts.push(html`<time datetime="${vod.created_at}">${dateLabel(vod.created_at)}</time>`);if (vod.duration_seconds != null) metaParts.push(durationLabel(vod.duration_seconds));-- if (vod.stream_category) metaParts.push(vod.stream_category);-- if (vod.view_count != null) metaParts.push(`${Number(vod.view_count) || 0} views`);++ if (vod.stream_category) metaParts.push(String(vod.stream_category).charAt(0).toUpperCase() + String(vod.stream_category).slice(1));++ if (viewsLabel(vod.view_count)) metaParts.push(viewsLabel(vod.view_count));++ if (manageable) metaParts.push(visibilityBadge(vod.visibility));const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';const viewAttr = html` data-ovv-view="${livePublic}/api/views/vod/${id}"`;@@@ -1169,13 +1181,15 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovvconst about = vod.description || vod.ai_overview || factualDescription('vod', vod, segs.slice(0, 6).map((s) => s.text).join(' '));// The other parts of a stream recorded in parts, in order: Part 1 · Part 2 (this one).-- const parts = hist && hist !== fail && Array.isArray(hist.parts) ? hist.parts.filter((p) => p && live.isId(p.vod_id)) : [];-- const partNav = parts.length > 1 ? html`<p class="ovv-about-parts"><i class="fa-solid fa-layer-group" aria-hidden="true"></i> The stream was recorded in ${parts.length} parts: ${parts.map((p, i) => [i ? ' · ' : '', String(p.vod_id) === id ? html`<b>Part ${p.part} (this one)</b>` : html`<a href="/v/${p.vod_id}">Part ${p.part}</a>`])}</p>` : '';++ // Numbered by their place in the list, each recording once: the stored part numbers repeat after a restart++ // ("Part 1 (this one) · Part 2 · Part 1", 2026-10-27).++ const seenParts = new Set();++ const parts = 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>` : '';// The More menu: where else this lives, and the owner's tools.const moreItems = [];if (channelName) moreItems.push({ href: liveChannel, icon: 'fa-solid fa-tower-broadcast', label: 'Channel on OpenVibe.Live', hint: 'Live streams and chat' });if (threadUrl) moreItems.push({ href: threadUrl, icon: 'fa-regular fa-comments', label: 'Discuss on OpenVibe.Community' });-- if (manageable) moreItems.push({ href: '#manage', icon: 'fa-solid fa-pen', label: 'Manage this recording', hint: 'Title, description, who can see it', data: raw('data-ovv-open="manage"') });// Chat replay, chapters and the transcript, beside the player.const side = companion({ kind: 'vod', id, chat: chatRows, chapters: mems, transcript: segs, long: Number(vod.duration_seconds) >= 3600 });return page(req, res, {@@@ -1203,11 +1217,12 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div></div>--<div class="ovv-actions">${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}<button type="button" class="ovv-btn" hidden data-ovv-share="${base}/v/${id}" title="Share a link (at the current time once it plays)"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>++<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}<button type="button" class="ovv-btn" hidden data-ovv-share="${base}/v/${id}" title="Share a link (at the current time once it plays)"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div></div>${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}</div></div>++${manageable ? ownerPanel('vod', id, vod) : ''}<section class="ovv-about" aria-labelledby="h-about"><h2 id="h-about" class="ovv-about-title">About</h2>${vod.description ? html`<p class="ovv-about-text">${vod.description}</p>` : ''}@@@ -1215,7 +1230,6 @@ ${vod.ai_overview ? html`<p class="ovv-about-text"><span class="ovv-ai-chip">AI${partNav}${channelName ? html`<p class="ovv-about-from"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Streamed live on <a href="${liveChannel}">${channelName}'s OpenVibe.Live channel</a>${vod.stream_category ? html`, in ${String(vod.stream_category).charAt(0).toUpperCase()}${String(vod.stream_category).slice(1)}` : ''}.</p>` : ''}</section>--${manageable ? ownerPanel('vod', id, vod) : ''}${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}</div><aside class="ovv-col ovv-col--side">@@@ -1270,7 +1284,6 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-clconst moreItems = [];if (clip.source_streamer_username) moreItems.push({ href: `${livePublic}/@${clip.source_streamer_username}`, icon: 'fa-solid fa-tower-broadcast', label: 'Channel on OpenVibe.Live', hint: 'Live streams and chat' });if (threadUrl) moreItems.push({ href: threadUrl, icon: 'fa-regular fa-comments', label: 'Discuss on OpenVibe.Community' });-- if (manageable) moreItems.push({ href: '#manage', icon: 'fa-solid fa-pen', label: 'Manage this clip', hint: 'Title, who can see it', data: raw('data-ovv-open="manage"') });// A clip's transcript, when it has one, beside the player as on a recording.const side = companion({ kind: 'clip', id, chat: null, chapters: [], transcript: segs, long: false });@@@ -1296,13 +1309,13 @@ ${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv<h1 class="ovv-title">${clip.title || 'Clip'}</h1><div class="ovv-head-row"><div class="ovv-byline"><div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>--${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${clip.duration_seconds != null ? html` · ${durationLabel(clip.duration_seconds)}` : ''}</span>` : ''}</div></div>--<div class="ovv-actions">${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}<button type="button" class="ovv-btn" hidden data-ovv-share="${base}/c/${id}" title="Share a link"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>++${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${clip.duration_seconds != null ? html` · ${durationLabel(clip.duration_seconds)}` : ''}${manageable ? html` · ${visibilityBadge(clip.visibility)}` : ''}</span>` : ''}</div></div>++<div class="ovv-actions">${manageable ? editButton('clip') : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}<button type="button" class="ovv-btn" hidden data-ovv-share="${base}/c/${id}" title="Share a link"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div></div></div></div>--${clip.ai_overview ? html`<section class="ovv-about" aria-labelledby="h-about"><h2 id="h-about" class="ovv-about-title">About</h2><p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span> ${clip.ai_overview}</p></section>` : ''}${manageable ? ownerPanel('clip', id, clip) : ''}++${clip.ai_overview ? html`<section class="ovv-about" aria-labelledby="h-about"><h2 id="h-about" class="ovv-about-title">About</h2><p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span> ${clip.ai_overview}</p></section>` : ''}</div><aside class="ovv-col ovv-col--side">${side || ''}@@@ -86,12 +86,19 @@ function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {await check('only the channel sees the Manage panel', async () => {const mine = await vreq(t.base, '/v/101', { cookie: kimCookie });assert.strictEqual(mine.status, 200);-- assert.ok(mine.text.includes('Manage this recording') && mine.text.includes('action="/v/101/manage"'), 'the owner panel');-- assert.ok(mine.text.includes('value="Kim stream"') && mine.text.includes('<option value="public" selected>'), 'prefilled');++ assert.ok(mine.text.includes('Edit this recording') && mine.text.includes('action="/v/101/manage"'), 'the owner panel');++ // 2026-10-27: Edit is first in the action row and opens the panel under the player; who can see it is three++ // choices; the meta line tells the owner which it is now.++ assert.ok(/<div class="ovv-actions"><a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage"/.test(mine.text), 'Edit leads the actions');++ assert.ok(mine.text.indexOf('id="manage"') < mine.text.indexOf('id="h-about"'), 'the panel sits under the player, before About');++ assert.strictEqual((mine.text.match(/<input type="radio" name="visibility" value="(public|unlisted|private)"/g) || []).length, 3, 'three visibility choices');++ assert.ok(/class="ovv-vis-badge ovv-vis-badge--/.test(mine.text), 'the owner sees who can see it');++ assert.ok(mine.text.includes('value="Kim stream"') && mine.text.includes('<input type="radio" name="visibility" value="public" checked>'), 'prefilled');const other = await vreq(t.base, '/v/101', { cookie: samCookie });-- assert.ok(!other.text.includes('Manage this recording'), 'not for someone else');++ assert.ok(!other.text.includes('Edit this recording'), 'not for someone else');++ assert.ok(!/class="ovv-vis-badge/.test(other.text) && !other.text.includes('ovv-btn--edit'), 'no badge or Edit for someone else');const anon = await vreq(t.base, '/v/101');-- assert.ok(!anon.text.includes('Manage this recording'), 'not signed out');++ assert.ok(!anon.text.includes('Edit this recording'), 'not signed out');});await check('an edit is made on Live as the person, then the page shows it', async () => {@@@ -131,7 +138,7 @@ function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {assert.ok(r.text.includes('Private: only you'), 'a private notice');assert.ok(r.text.includes('noindex'), 'noindex');assert.match(String(r.headers['cache-control']), /private/);-- assert.ok(r.text.includes('Manage this recording'), 'so the owner can make it public again');++ assert.ok(r.text.includes('Edit this recording'), 'so the owner can make it public again');});await check('a clip\'s maker changes only what changed; deletes need the box ticked', async () => {@@@ -341,7 +341,7 @@ function countOccurrences(haystack, needle) {assert.ok(r.text.includes('Transcript'));assert.ok(r.text.includes('A public overview'));assert.ok(r.text.includes('/@streamer'));-- assert.ok(!r.text.includes('Manage this recording'), 'owner tools only for the channel (video-owner.test.js)');++ assert.ok(!r.text.includes('Edit this recording'), 'owner tools only for the channel (video-owner.test.js)');assert.ok(r.text.includes('Discuss on OpenVibe.Community'));assert.ok(r.text.includes('data-ovv-chapter'));assert.ok(r.text.includes('data-ovv-seek'));@@@ -360,7 +360,7 @@ function countOccurrences(haystack, needle) {assert.ok(r.text.includes('Full recording</a>') && r.text.includes('title="Watch the recording it was cut from, from this moment"'));assert.ok(r.text.includes('/v/101?t=5'), 'the full recording link carries the clip start');assert.ok(r.text.includes('Discuss on OpenVibe.Community'));-- assert.ok(!r.text.includes('Manage this clip'), 'owner tools only for its maker or channel (video-owner.test.js)');++ assert.ok(!r.text.includes('Edit this clip'), 'owner tools only for its maker or channel (video-owner.test.js)');});await check('a recording in progress plays Media\'s DVR playlist when it is an MP4, the file when it is a WebM', async () => {
@@ -509,3 +509,40 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -509,3 +509,40 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-pip-pills { position: absolute; right: 12px; top: 12px; z-index: 2; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; max-width: calc(100% - 24px); }
.ovv-pip-pills { position: absolute; right: 12px; top: 12px; z-index: 2; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; max-width: calc(100% - 24px); }
.ovv-pip-pills button { padding: 5px 10px; border: 1px solid rgba(255, 255, 255, .2); border-radius: 999px; background: rgba(0, 0, 0, .6); color: #fff; font-size: .74rem; cursor: pointer; }
.ovv-pip-pills button { padding: 5px 10px; border: 1px solid rgba(255, 255, 255, .2); border-radius: 999px; background: rgba(0, 0, 0, .6); color: #fff; font-size: .74rem; cursor: pointer; }
@media (hover: none) { .ovv-pip-bar { opacity: 1; } }
@media (hover: none) { .ovv-pip-bar { opacity: 1; } }
+
+/* ── The owner's tools (2026-10-27: "I don't see any way to edit things … I just had to scroll way down") ──
+ Edit is first in the action row; the panel opens right under the player and stays out of the way until then (a
+ link to #manage without JavaScript still shows it). Who can see it is three choices, and the meta line says which. */
+.ovv-btn--edit { border-color: color-mix(in srgb, var(--ovv-accent) 55%, var(--ovv-line-strong)); color: var(--text-primary, #e6edf7); }
+.ovv-btn--edit i { color: var(--ovv-accent); }
+.ovv-owner:not([open]):not(:target) { display: none; }
+.ovv-owner[open] { animation: ovv-owner-in 0.18s ease-out; }
+@keyframes ovv-owner-in { from { opacity: 0; transform: translateY(-4px); } }
+.ovv-owner h2 i { font-size: 0.85em; color: var(--ovv-accent); }
+.ovv-vis { border: 0; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
+.ovv-vis legend { padding: 0; margin-bottom: 6px; font-size: .85rem; font-weight: 700; }
+.ovv-vis-opt { position: relative; display: block; cursor: pointer; }
+.ovv-vis-opt input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
+.ovv-vis-card {
+ display: grid; justify-items: center; gap: 3px; height: 100%; box-sizing: border-box; padding: 12px 8px; border-radius: 12px; text-align: center;
+ border: 1px solid var(--ovv-line-strong); background: color-mix(in srgb, var(--ovv-card) 70%, transparent); transition: border-color .15s, background .15s;
+}
+.ovv-vis-card i { font-size: 1.05rem; color: var(--ovv-dim); }
+.ovv-vis-card b { font-size: .9rem; }
+.ovv-vis-card small { font-size: .74rem; color: var(--ovv-faint); line-height: 1.25; }
+.ovv-vis-opt input:checked + .ovv-vis-card { border-color: var(--ovv-accent); background: color-mix(in srgb, var(--ovv-accent) 14%, var(--ovv-card)); }
+.ovv-vis-opt input:checked + .ovv-vis-card i { color: var(--ovv-accent); }
+.ovv-vis-opt input:focus-visible + .ovv-vis-card { outline: 2px solid var(--ovv-accent); outline-offset: 2px; }
+.ovv-vis-badge { display: inline-flex; align-items: center; gap: 4px; padding: 0 7px; border-radius: 999px; font-size: .74rem; font-weight: 700; border: 1px solid var(--ovv-line-strong); }
+.ovv-vis-badge--unlisted { color: #fbbf24; border-color: color-mix(in srgb, #fbbf24 40%, transparent); }
+.ovv-vis-badge--private { color: #f87171; border-color: color-mix(in srgb, #f87171 40%, transparent); }
+@media (max-width: 1000px) {
+ /* Under the player, where Edit was tapped (it was seventh, under the comments). */
+ .ovv-owner { order: 1; }
+ /* Chat, chapters and the transcript take the room they need, up to most of the screen: one message no longer sat
+ at the top of an empty 560 px box. */
+ .ovv-companion--tabs, .ovv-companion--one { height: auto; max-height: min(560px, 68vh); max-height: min(560px, 68dvh); }
+ .ovv-companion .ovv-tp-body { flex: 0 1 auto; }
+}
+@media (max-width: 420px) { .ovv-vis-card small { display: none; } }
+@media (prefers-reduced-motion: reduce) { .ovv-owner[open] { animation: none; } }
@@ -647,6 +647,18 @@ function createVideoRoutes(ctx) {
@@ -647,6 +647,18 @@ function createVideoRoutes(ctx) {
}
}
const savedNotice = (req) => (req.query.saved === '1' ? notice('Saved on OpenVibe.Live.', 'ok') : '');
const savedNotice = (req) => (req.query.saved === '1' ? notice('Saved on OpenVibe.Live.', 'ok') : '');
const visibilityOptions = (current) => VISIBILITIES.map((v) => html`<option value="${v}"${v === current ? ' selected' : ''}>${visibilityLabel(v)}</option>`);
const visibilityOptions = (current) => VISIBILITIES.map((v) => html`<option value="${v}"${v === current ? ' selected' : ''}>${visibilityLabel(v)}</option>`);
+ // 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)'],
+ };
+ const visibilityChoices = (current) => html`<fieldset class="ovv-vis"><legend>Who can see it</legend>${VISIBILITIES.map((v) => html`<label class="ovv-vis-opt"><input type="radio" name="visibility" value="${v}"${v === current ? ' checked' : ''}><span class="ovv-vis-card"><i class="${VISIBILITY_LOOK[v][0]}" aria-hidden="true"></i><b>${visibilityLabel(v)}</b><small>${VISIBILITY_LOOK[v][1]}</small></span></label>`)}</fieldset>`;
+ /** The owner's badge in the meta line: who can see it now. */
+ const visibilityBadge = (v) => html`<span class="ovv-vis-badge ovv-vis-badge--${VISIBILITIES.includes(v) ? v : 'public'}"><i class="${(VISIBILITY_LOOK[v] || VISIBILITY_LOOK.public)[0]}" aria-hidden="true"></i> ${visibilityLabel(v || 'public')}</span>`;
+ /** The owner's Edit button, first in the action row: opens the edit panel under the player. */
+ 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, delete"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</a>`;
/**
/**
* "Clip" in the action row: a finished recording, any signed-in viewer who can see it (Live's rule, as on Live). A
* "Clip" in the action row: a finished recording, any signed-in viewer who can see it (Live's rule, as on Live). A
* small form under the button; signed out, the button signs in first and comes back.
* small form under the button; signed out, the button signs in first and comes back.
@@ -677,12 +689,11 @@ ${whole ? html`<input type="hidden" name="recording_time" value="1">` : ''}
@@ -677,12 +689,11 @@ ${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">Manage this ${what}</h2><span class="ovv-fine">Title, ${kind === 'vod' ? 'description, ' : ''}who can see it, delete</span></summary>
+ 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, delete</span></summary>
<form class="ovv-owner-form" method="post" action="${action}/manage">
<form class="ovv-owner-form" method="post" action="${action}/manage">
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="200" value="${item.title || ''}" required></label>
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="200" value="${item.title || ''}" required></label>
${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textarea name="description" rows="3" maxlength="2000">${item.description || ''}</textarea></label>` : ''}
${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textarea name="description" rows="3" maxlength="2000">${item.description || ''}</textarea></label>` : ''}
-<label class="ovv-field"><span>Visibility</span><select name="visibility">${visibilityOptions(item.visibility || 'public')}</select></label>
-<p class="ovv-fine">Public is listed and searchable; unlisted opens only by its link; private only for you and staff.</p>
+${visibilityChoices(item.visibility || 'public')}
<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button>
<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button>
</form>
</form>
<details class="ovv-danger"><summary>Delete this ${what}</summary>
<details class="ovv-danger"><summary>Delete this ${what}</summary>
@@ -1157,8 +1168,9 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1157,8 +1168,9 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
const metaParts = [];
const metaParts = [];
if (vod.created_at) metaParts.push(html`<time datetime="${vod.created_at}">${dateLabel(vod.created_at)}</time>`);
if (vod.created_at) metaParts.push(html`<time datetime="${vod.created_at}">${dateLabel(vod.created_at)}</time>`);
if (vod.duration_seconds != null) metaParts.push(durationLabel(vod.duration_seconds));
if (vod.duration_seconds != null) metaParts.push(durationLabel(vod.duration_seconds));
- if (vod.stream_category) metaParts.push(vod.stream_category);
- if (vod.view_count != null) metaParts.push(`${Number(vod.view_count) || 0} views`);
+ if (vod.stream_category) metaParts.push(String(vod.stream_category).charAt(0).toUpperCase() + String(vod.stream_category).slice(1));
+ if (viewsLabel(vod.view_count)) metaParts.push(viewsLabel(vod.view_count));
+ if (manageable) metaParts.push(visibilityBadge(vod.visibility));
const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const viewAttr = html` data-ovv-view="${livePublic}/api/views/vod/${id}"`;
const viewAttr = html` data-ovv-view="${livePublic}/api/views/vod/${id}"`;
@@ -1169,13 +1181,15 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1169,13 +1181,15 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
const about = vod.description || vod.ai_overview || factualDescription('vod', vod, segs.slice(0, 6).map((s) => s.text).join(' '));
const about = vod.description || vod.ai_overview || factualDescription('vod', vod, segs.slice(0, 6).map((s) => s.text).join(' '));
// The other parts of a stream recorded in parts, in order: Part 1 · Part 2 (this one).
// The other parts of a stream recorded in parts, in order: Part 1 · Part 2 (this one).
- const parts = hist && hist !== fail && Array.isArray(hist.parts) ? hist.parts.filter((p) => p && live.isId(p.vod_id)) : [];
- const partNav = parts.length > 1 ? html`<p class="ovv-about-parts"><i class="fa-solid fa-layer-group" aria-hidden="true"></i> The stream was recorded in ${parts.length} parts: ${parts.map((p, i) => [i ? ' · ' : '', String(p.vod_id) === id ? html`<b>Part ${p.part} (this one)</b>` : html`<a href="/v/${p.vod_id}">Part ${p.part}</a>`])}</p>` : '';
+ // Numbered by their place in the list, each recording once: the stored part numbers repeat after a restart
+ // ("Part 1 (this one) · Part 2 · Part 1", 2026-10-27).
+ const seenParts = new Set();
+ const parts = 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>` : '';
// 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 (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 (threadUrl) moreItems.push({ href: threadUrl, icon: 'fa-regular fa-comments', label: 'Discuss on OpenVibe.Community' });
- if (manageable) moreItems.push({ href: '#manage', icon: 'fa-solid fa-pen', label: 'Manage this recording', hint: 'Title, description, who can see it', data: raw('data-ovv-open="manage"') });
// Chat replay, chapters and the transcript, beside the player.
// Chat replay, chapters and the transcript, beside the player.
const side = companion({ kind: 'vod', id, chat: chatRows, chapters: mems, transcript: segs, long: Number(vod.duration_seconds) >= 3600 });
const side = companion({ kind: 'vod', id, chat: chatRows, chapters: mems, transcript: segs, long: Number(vod.duration_seconds) >= 3600 });
return page(req, res, {
return page(req, res, {
@@ -1203,11 +1217,12 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"
@@ -1203,11 +1217,12 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"
<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
</div>
</div>
-<div class="ovv-actions">${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}<button type="button" class="ovv-btn" hidden data-ovv-share="${base}/v/${id}" title="Share a link (at the current time once it plays)"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
+<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}<button type="button" class="ovv-btn" hidden data-ovv-share="${base}/v/${id}" title="Share a link (at the current time once it plays)"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
</div>
${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
</div>
</div>
</div>
</div>
+${manageable ? ownerPanel('vod', id, vod) : ''}
<section class="ovv-about" aria-labelledby="h-about">
<section class="ovv-about" aria-labelledby="h-about">
<h2 id="h-about" class="ovv-about-title">About</h2>
<h2 id="h-about" class="ovv-about-title">About</h2>
${vod.description ? html`<p class="ovv-about-text">${vod.description}</p>` : ''}
${vod.description ? html`<p class="ovv-about-text">${vod.description}</p>` : ''}
@@ -1215,7 +1230,6 @@ ${vod.ai_overview ? html`<p class="ovv-about-text"><span class="ovv-ai-chip">AI
@@ -1215,7 +1230,6 @@ ${vod.ai_overview ? html`<p class="ovv-about-text"><span class="ovv-ai-chip">AI
${partNav}
${partNav}
${channelName ? html`<p class="ovv-about-from"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Streamed live on <a href="${liveChannel}">${channelName}'s OpenVibe.Live channel</a>${vod.stream_category ? html`, in ${String(vod.stream_category).charAt(0).toUpperCase()}${String(vod.stream_category).slice(1)}` : ''}.</p>` : ''}
${channelName ? html`<p class="ovv-about-from"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Streamed live on <a href="${liveChannel}">${channelName}'s OpenVibe.Live channel</a>${vod.stream_category ? html`, in ${String(vod.stream_category).charAt(0).toUpperCase()}${String(vod.stream_category).slice(1)}` : ''}.</p>` : ''}
</section>
</section>
-${manageable ? ownerPanel('vod', id, vod) : ''}
${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}
${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}
</div>
</div>
<aside class="ovv-col ovv-col--side">
<aside class="ovv-col ovv-col--side">
@@ -1270,7 +1284,6 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
@@ -1270,7 +1284,6 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
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 (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 (threadUrl) moreItems.push({ href: threadUrl, icon: 'fa-regular fa-comments', label: 'Discuss on OpenVibe.Community' });
- if (manageable) moreItems.push({ href: '#manage', icon: 'fa-solid fa-pen', label: 'Manage this clip', hint: 'Title, who can see it', data: raw('data-ovv-open="manage"') });
// A clip's transcript, when it has one, beside the player as on a recording.
// A clip's transcript, when it has one, beside the player as on a recording.
const side = companion({ kind: 'clip', id, chat: null, chapters: [], transcript: segs, long: false });
const side = companion({ kind: 'clip', id, chat: null, chapters: [], transcript: segs, long: false });
@@ -1296,13 +1309,13 @@ ${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv
@@ -1296,13 +1309,13 @@ ${cutting ? html`<div class="ovv-player-wrap ovv-cutting" role="status" data-ovv
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
<div class="ovv-head-row">
<div class="ovv-head-row">
<div class="ovv-byline"><div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
<div class="ovv-byline"><div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
-${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${clip.duration_seconds != null ? html` · ${durationLabel(clip.duration_seconds)}` : ''}</span>` : ''}</div></div>
-<div class="ovv-actions">${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}<button type="button" class="ovv-btn" hidden data-ovv-share="${base}/c/${id}" title="Share a link"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
+${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${clip.duration_seconds != null ? html` · ${durationLabel(clip.duration_seconds)}` : ''}${manageable ? html` · ${visibilityBadge(clip.visibility)}` : ''}</span>` : ''}</div></div>
+<div class="ovv-actions">${manageable ? editButton('clip') : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}<button type="button" class="ovv-btn" hidden data-ovv-share="${base}/c/${id}" title="Share a link"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
</div>
</div>
</div>
</div>
</div>
-${clip.ai_overview ? html`<section class="ovv-about" aria-labelledby="h-about"><h2 id="h-about" class="ovv-about-title">About</h2><p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span> ${clip.ai_overview}</p></section>` : ''}
${manageable ? ownerPanel('clip', id, clip) : ''}
${manageable ? ownerPanel('clip', id, clip) : ''}
+${clip.ai_overview ? html`<section class="ovv-about" aria-labelledby="h-about"><h2 id="h-about" class="ovv-about-title">About</h2><p class="ovv-about-text"><span class="ovv-ai-chip">AI summary</span> ${clip.ai_overview}</p></section>` : ''}
</div>
</div>
<aside class="ovv-col ovv-col--side">
<aside class="ovv-col ovv-col--side">
${side || ''}
${side || ''}
@@ -86,12 +86,19 @@ function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {
@@ -86,12 +86,19 @@ function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {
await check('only the channel sees the Manage panel', async () => {
await check('only the channel sees the Manage panel', async () => {
const mine = await vreq(t.base, '/v/101', { cookie: kimCookie });
const mine = await vreq(t.base, '/v/101', { cookie: kimCookie });
assert.strictEqual(mine.status, 200);
assert.strictEqual(mine.status, 200);
- assert.ok(mine.text.includes('Manage this recording') && mine.text.includes('action="/v/101/manage"'), 'the owner panel');
- assert.ok(mine.text.includes('value="Kim stream"') && mine.text.includes('<option value="public" selected>'), 'prefilled');
+ assert.ok(mine.text.includes('Edit this recording') && mine.text.includes('action="/v/101/manage"'), 'the owner panel');
+ // 2026-10-27: Edit is first in the action row and opens the panel under the player; who can see it is three
+ // choices; the meta line tells the owner which it is now.
+ assert.ok(/<div class="ovv-actions"><a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage"/.test(mine.text), 'Edit leads the actions');
+ assert.ok(mine.text.indexOf('id="manage"') < mine.text.indexOf('id="h-about"'), 'the panel sits under the player, before About');
+ assert.strictEqual((mine.text.match(/<input type="radio" name="visibility" value="(public|unlisted|private)"/g) || []).length, 3, 'three visibility choices');
+ assert.ok(/class="ovv-vis-badge ovv-vis-badge--/.test(mine.text), 'the owner sees who can see it');
+ assert.ok(mine.text.includes('value="Kim stream"') && mine.text.includes('<input type="radio" name="visibility" value="public" checked>'), 'prefilled');
const other = await vreq(t.base, '/v/101', { cookie: samCookie });
const other = await vreq(t.base, '/v/101', { cookie: samCookie });
- assert.ok(!other.text.includes('Manage this recording'), 'not for someone else');
+ assert.ok(!other.text.includes('Edit this recording'), 'not for someone else');
+ assert.ok(!/class="ovv-vis-badge/.test(other.text) && !other.text.includes('ovv-btn--edit'), 'no badge or Edit for someone else');
const anon = await vreq(t.base, '/v/101');
const anon = await vreq(t.base, '/v/101');
- assert.ok(!anon.text.includes('Manage this recording'), 'not signed out');
+ assert.ok(!anon.text.includes('Edit this recording'), 'not signed out');
});
});
await check('an edit is made on Live as the person, then the page shows it', async () => {
await check('an edit is made on Live as the person, then the page shows it', async () => {
@@ -131,7 +138,7 @@ function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {
@@ -131,7 +138,7 @@ function vreq(base, path, { method = 'GET', cookie, form, same = true } = {}) {
assert.ok(r.text.includes('Private: only you'), 'a private notice');
assert.ok(r.text.includes('Private: only you'), 'a private notice');
assert.ok(r.text.includes('noindex'), 'noindex');
assert.ok(r.text.includes('noindex'), 'noindex');
assert.match(String(r.headers['cache-control']), /private/);
assert.match(String(r.headers['cache-control']), /private/);
- assert.ok(r.text.includes('Manage this recording'), 'so the owner can make it public again');
+ assert.ok(r.text.includes('Edit this recording'), 'so the owner can make it public again');
});
});
await check('a clip\'s maker changes only what changed; deletes need the box ticked', async () => {
await check('a clip\'s maker changes only what changed; deletes need the box ticked', async () => {
@@ -341,7 +341,7 @@ function countOccurrences(haystack, needle) {
@@ -341,7 +341,7 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('Transcript'));
assert.ok(r.text.includes('Transcript'));
assert.ok(r.text.includes('A public overview'));
assert.ok(r.text.includes('A public overview'));
assert.ok(r.text.includes('/@streamer'));
assert.ok(r.text.includes('/@streamer'));
- assert.ok(!r.text.includes('Manage this recording'), 'owner tools only for the channel (video-owner.test.js)');
+ assert.ok(!r.text.includes('Edit this recording'), 'owner tools only for the channel (video-owner.test.js)');
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('data-ovv-chapter'));
assert.ok(r.text.includes('data-ovv-chapter'));
assert.ok(r.text.includes('data-ovv-seek'));
assert.ok(r.text.includes('data-ovv-seek'));
@@ -360,7 +360,7 @@ function countOccurrences(haystack, needle) {
@@ -360,7 +360,7 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('Full recording</a>') && r.text.includes('title="Watch the recording it was cut from, from this moment"'));
assert.ok(r.text.includes('Full recording</a>') && r.text.includes('title="Watch the recording it was cut from, from this moment"'));
assert.ok(r.text.includes('/v/101?t=5'), 'the full recording link carries the clip start');
assert.ok(r.text.includes('/v/101?t=5'), 'the full recording link carries the clip start');
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
- assert.ok(!r.text.includes('Manage this clip'), 'owner tools only for its maker or channel (video-owner.test.js)');
+ assert.ok(!r.text.includes('Edit this clip'), 'owner tools only for its maker or channel (video-owner.test.js)');
});
});
await check('a recording in progress plays Media\'s DVR playlist when it is an MP4, the file when it is a WebM', async () => {
await check('a recording in progress plays Media\'s DVR playlist when it is an MP4, the file when it is a WebM', async () => {