openvibers/openvibe.media Commits
Show up to four next-step cards after the watch page overview (#105)
Changes
@@@ -15,6 +15,7 @@const path = require('path');const config = require('../config');const pageFrame = require('./page-frame');++const showcase = require('openvibe-shared/showcase');const { esc, abs, snip, appUrl, isoDate, isoDuration, fmtDuration, fmtDate, SITE_NAME, NETWORK_URL } = pageFrame;@@@ -153,6 +154,15 @@ function renderWatchPage(kind, record, readiness = null) {<p class="state-detail">${esc(stateText)}</p></div>`;++ // Next steps on other OpenVibe products (Shared ADR 0002, D94): real pages that work signed out, no++ // identity in the URL; the kit keeps at most four and appends ?ov_from=media.++ const nextSteps = showcase.nextSteps({ id: 'after-watch', title: 'What next?', items: [++ ownedByLive ? { what: 'Watch on OpenVibe.Live', text: 'Chat, the transcript and the people who were there.', href: canonical, to: 'live', from: 'media' } : null,++ sourceVod ? { what: 'See the VOD this clip came from', text: 'The full stream, auto-recorded.', href: sourceVod, from: 'media' } : null,++ playable && !isVod ? { what: 'Go live from your browser', text: 'No OBS and no follower minimum: stream on OpenVibe.Live and clip the best moments.', href: 'https://openvibe.live/', to: 'live', from: 'media' } : null,++ { what: 'Browse everything on OpenVibe.Media', text: 'Every public video, clip and file on the network.', href: `${config.publicUrl}/`, from: 'media' },++ ].filter(Boolean) });++const body = `<h1>${esc(title)}</h1><p class="meta">${metaBits.map(b => `<span>${b}</span>`).join('<span aria-hidden="true">·</span>')}</p>@@@ -163,7 +173,8 @@ function renderWatchPage(kind, record, readiness = null) {${isVod ? `<a class="btn ghost" href="${esc(`${self}/transcript.json`)}">Transcript (JSON)</a>` : ''}</div>${record.description ? `<p class="desc">${esc(snip(record.description, 2000))}</p>` : ''}-- ${record.ai_overview && record.ai_overview !== record.description ? `<section><h2 class="h2">Overview</h2><p class="desc">${esc(snip(record.ai_overview, 3000))}</p></section>` : ''}`;++ ${record.ai_overview && record.ai_overview !== record.description ? `<section><h2 class="h2">Overview</h2><p class="desc">${esc(snip(record.ai_overview, 3000))}</p></section>` : ''}++ ${nextSteps}`;const css = `.player { background: #000; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); margin: 0 0 1rem; }@@@ -73,6 +73,38 @@ const http = require('http');assert.ok(html.includes('"@type":"WebPage"') && html.includes('"url":"https://openvibe.live/vod/42"'), 'its WebPage names the canonical');assert.ok(!unlisted.includes('name="ai-summary"'), 'unlisted pages carry none');++ // ── Next-step cards (Shared ADR 0002, D94): real, signed-out targets, ov_from=media, at most four ──++ const config = require('../server/config');++ const cards = (page) => [...page.matchAll(/<a class="sc-next-main" href="([^"]+)"/g)].map((m) => m[1].replace(/&/g, '&'));++ const checkCards = (page, label) => {++ const hrefs = cards(page);++ assert.ok(hrefs.length >= 1 && hrefs.length <= 4, `${label}: one to four cards (${hrefs.length})`);++ hrefs.forEach((href) => {++ assert.ok(/^https:\/\/openvibe\.[a-z]+(\/|$)/.test(href) || href.startsWith('/') || href.startsWith(`${config.publicUrl}/`),++ `${label}: ${href} is an OpenVibe zone, site-relative or this site`);++ assert.strictEqual(new URL(href, config.publicUrl).search, '?ov_from=media', `${label}: ${href} carries ov_from=media and no other query`);++ });++ return hrefs;++ };++ // A playable public Live-owned VOD: the Live card (the canonical) and the index; no clip-cutting card on a VOD.++ assert.ok(html.includes('class="sc-sec sc-next"') && html.includes('<ol class="sc-next-list">'), 'a playable public VOD page carries the next-step cards');++ assert.deepStrictEqual(checkCards(html, 'Live VOD'), ['https://openvibe.live/vod/42?ov_from=media', 'https://media.test/?ov_from=media'],++ 'a Live-owned item\'s card is its canonical; the index card is always there');++ // A VOD another app owns: only the index.++ assert.deepStrictEqual(checkCards(pages.renderWatchPage('vod', { ...vod, id: 43, app_id: 'games' }), 'games VOD'), ['https://media.test/?ov_from=media']);++ // A playable person-cut clip on Live: Live, cut your own, the index.++ assert.deepStrictEqual(checkCards(pages.renderWatchPage('clip', { ...vod, id: 9 }), 'Live clip'),++ ['https://openvibe.live/clip/9?ov_from=media', 'https://openvibe.live/?ov_from=media', 'https://media.test/?ov_from=media']);++ // An AI clip with a source VOD: every card applies, and four is the most that render.++ const aiClip = pages.renderWatchPage('clip', { ...vod, id: 10, auto_generated: 1, vod_id: 42 });++ assert.deepStrictEqual(checkCards(aiClip, 'AI clip'), ['https://openvibe.live/clip/10?ov_from=media', 'https://media.test/v/42?ov_from=media',++ 'https://openvibe.live/?ov_from=media', 'https://media.test/?ov_from=media'], 'a clip with a source VOD shows the VOD card');++ assert.ok(aiClip.includes('See the VOD this clip came from'));++ assert.strictEqual((aiClip.match(/class="sc-next-item"/g) || []).length, 4, 'at most four cards');++ // A clip without a player offers no clip-cutting card.++ const pending = pages.renderWatchPage('clip', { ...vod, id: 11 }, { playable: false, reason: 'processing' });++ assert.ok(!cards(pending).includes('https://openvibe.live/?ov_from=media'), 'no "go live" card without a player');++// Paste pages are Community's: there is no viewer here (public/routes.js only redirects).assert.ok(!/\bfree\b/i.test(html + pageFrame.baseCss()), 'no cost claims in served HTML');
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
const path = require('path');
const path = require('path');
const config = require('../config');
const config = require('../config');
const pageFrame = require('./page-frame');
const pageFrame = require('./page-frame');
+const showcase = require('openvibe-shared/showcase');
const { esc, abs, snip, appUrl, isoDate, isoDuration, fmtDuration, fmtDate, SITE_NAME, NETWORK_URL } = pageFrame;
const { esc, abs, snip, appUrl, isoDate, isoDuration, fmtDuration, fmtDate, SITE_NAME, NETWORK_URL } = pageFrame;
@@ -153,6 +154,15 @@ function renderWatchPage(kind, record, readiness = null) {
@@ -153,6 +154,15 @@ function renderWatchPage(kind, record, readiness = null) {
<p class="state-detail">${esc(stateText)}</p>
<p class="state-detail">${esc(stateText)}</p>
</div>`;
</div>`;
+ // Next steps on other OpenVibe products (Shared ADR 0002, D94): real pages that work signed out, no
+ // identity in the URL; the kit keeps at most four and appends ?ov_from=media.
+ const nextSteps = showcase.nextSteps({ id: 'after-watch', title: 'What next?', items: [
+ ownedByLive ? { what: 'Watch on OpenVibe.Live', text: 'Chat, the transcript and the people who were there.', href: canonical, to: 'live', from: 'media' } : null,
+ sourceVod ? { what: 'See the VOD this clip came from', text: 'The full stream, auto-recorded.', href: sourceVod, from: 'media' } : null,
+ playable && !isVod ? { what: 'Go live from your browser', text: 'No OBS and no follower minimum: stream on OpenVibe.Live and clip the best moments.', href: 'https://openvibe.live/', to: 'live', from: 'media' } : null,
+ { what: 'Browse everything on OpenVibe.Media', text: 'Every public video, clip and file on the network.', href: `${config.publicUrl}/`, from: 'media' },
+ ].filter(Boolean) });
+
const body = `
const body = `
<h1>${esc(title)}</h1>
<h1>${esc(title)}</h1>
<p class="meta">${metaBits.map(b => `<span>${b}</span>`).join('<span aria-hidden="true">·</span>')}</p>
<p class="meta">${metaBits.map(b => `<span>${b}</span>`).join('<span aria-hidden="true">·</span>')}</p>
@@ -163,7 +173,8 @@ function renderWatchPage(kind, record, readiness = null) {
@@ -163,7 +173,8 @@ function renderWatchPage(kind, record, readiness = null) {
${isVod ? `<a class="btn ghost" href="${esc(`${self}/transcript.json`)}">Transcript (JSON)</a>` : ''}
${isVod ? `<a class="btn ghost" href="${esc(`${self}/transcript.json`)}">Transcript (JSON)</a>` : ''}
</div>
</div>
${record.description ? `<p class="desc">${esc(snip(record.description, 2000))}</p>` : ''}
${record.description ? `<p class="desc">${esc(snip(record.description, 2000))}</p>` : ''}
- ${record.ai_overview && record.ai_overview !== record.description ? `<section><h2 class="h2">Overview</h2><p class="desc">${esc(snip(record.ai_overview, 3000))}</p></section>` : ''}`;
+ ${record.ai_overview && record.ai_overview !== record.description ? `<section><h2 class="h2">Overview</h2><p class="desc">${esc(snip(record.ai_overview, 3000))}</p></section>` : ''}
+ ${nextSteps}`;
const css = `
const css = `
.player { background: #000; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); margin: 0 0 1rem; }
.player { background: #000; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); margin: 0 0 1rem; }
@@ -73,6 +73,38 @@ const http = require('http');
@@ -73,6 +73,38 @@ const http = require('http');
assert.ok(html.includes('"@type":"WebPage"') && html.includes('"url":"https://openvibe.live/vod/42"'), 'its WebPage names the canonical');
assert.ok(html.includes('"@type":"WebPage"') && html.includes('"url":"https://openvibe.live/vod/42"'), 'its WebPage names the canonical');
assert.ok(!unlisted.includes('name="ai-summary"'), 'unlisted pages carry none');
assert.ok(!unlisted.includes('name="ai-summary"'), 'unlisted pages carry none');
+ // ── Next-step cards (Shared ADR 0002, D94): real, signed-out targets, ov_from=media, at most four ──
+ const config = require('../server/config');
+ const cards = (page) => [...page.matchAll(/<a class="sc-next-main" href="([^"]+)"/g)].map((m) => m[1].replace(/&/g, '&'));
+ const checkCards = (page, label) => {
+ const hrefs = cards(page);
+ assert.ok(hrefs.length >= 1 && hrefs.length <= 4, `${label}: one to four cards (${hrefs.length})`);
+ hrefs.forEach((href) => {
+ assert.ok(/^https:\/\/openvibe\.[a-z]+(\/|$)/.test(href) || href.startsWith('/') || href.startsWith(`${config.publicUrl}/`),
+ `${label}: ${href} is an OpenVibe zone, site-relative or this site`);
+ assert.strictEqual(new URL(href, config.publicUrl).search, '?ov_from=media', `${label}: ${href} carries ov_from=media and no other query`);
+ });
+ return hrefs;
+ };
+ // A playable public Live-owned VOD: the Live card (the canonical) and the index; no clip-cutting card on a VOD.
+ assert.ok(html.includes('class="sc-sec sc-next"') && html.includes('<ol class="sc-next-list">'), 'a playable public VOD page carries the next-step cards');
+ assert.deepStrictEqual(checkCards(html, 'Live VOD'), ['https://openvibe.live/vod/42?ov_from=media', 'https://media.test/?ov_from=media'],
+ 'a Live-owned item\'s card is its canonical; the index card is always there');
+ // A VOD another app owns: only the index.
+ assert.deepStrictEqual(checkCards(pages.renderWatchPage('vod', { ...vod, id: 43, app_id: 'games' }), 'games VOD'), ['https://media.test/?ov_from=media']);
+ // A playable person-cut clip on Live: Live, cut your own, the index.
+ assert.deepStrictEqual(checkCards(pages.renderWatchPage('clip', { ...vod, id: 9 }), 'Live clip'),
+ ['https://openvibe.live/clip/9?ov_from=media', 'https://openvibe.live/?ov_from=media', 'https://media.test/?ov_from=media']);
+ // An AI clip with a source VOD: every card applies, and four is the most that render.
+ const aiClip = pages.renderWatchPage('clip', { ...vod, id: 10, auto_generated: 1, vod_id: 42 });
+ assert.deepStrictEqual(checkCards(aiClip, 'AI clip'), ['https://openvibe.live/clip/10?ov_from=media', 'https://media.test/v/42?ov_from=media',
+ 'https://openvibe.live/?ov_from=media', 'https://media.test/?ov_from=media'], 'a clip with a source VOD shows the VOD card');
+ assert.ok(aiClip.includes('See the VOD this clip came from'));
+ assert.strictEqual((aiClip.match(/class="sc-next-item"/g) || []).length, 4, 'at most four cards');
+ // A clip without a player offers no clip-cutting card.
+ const pending = pages.renderWatchPage('clip', { ...vod, id: 11 }, { playable: false, reason: 'processing' });
+ assert.ok(!cards(pending).includes('https://openvibe.live/?ov_from=media'), 'no "go live" card without a player');
+
// Paste pages are Community's: there is no viewer here (public/routes.js only redirects).
// Paste pages are Community's: there is no viewer here (public/routes.js only redirects).
assert.ok(!/\bfree\b/i.test(html + pageFrame.baseCss()), 'no cost claims in served HTML');
assert.ok(!/\bfree\b/i.test(html + pageFrame.baseCss()), 'no cost claims in served HTML');