openvibers/openvibe.mediahub Commits

Watch together: a party link on the watch pages (#325)

OP OpenVibers committed 6 days ago · 64e6b88edb · 3 files, +19 −3

* Watch together: the recordings, clips and uploads link to a party on openvibe.party

Party watch v1 (plan R1): each watch page carries a "Watch together" link beside Share, in
the page's own button class, to openvibe.party/new with the page's address as the media and
the item's title. It covers Live's recordings (/v/:id) and clips (/c/:id), which openvibe.video
serves now, and OpenVibe.Video's uploads (/u/:id).

Checked: test/video-pages.test.js, test/layout.test.js, the upload suites.

* Watch together plays the item's file: media is the openvibe.media file, the watch page is the source

A party's player sets the media as the video's src, so the watch page's address played nothing. No file yet
(still cutting or processing), no button.

Changes

ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex bd31668..8d054d0 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -656,6 +656,15 @@ function createVideoRoutes(ctx) {
const base = String(config.video.url).replace(/\/+$/, '');
const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');
const videoHostname = (() => { try { return new URL(config.video.url).hostname; } catch { return 'openvibe.video'; } })();
++ // Watch together (Party watch v1, plan R1): the same page as a watch party on openvibe.party, which plays OpenVibe
++ // media in step for everyone there. The party takes the page's own address as its media and the item's title.
++ // The party plays the item's own file (on openvibe.media, a host the Contracts manifests name); the watch page goes
++ // with it as the party's source. No file yet (still cutting or processing), no button.
++ 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>`;
++ };
const hasSessionCookie = (req) => Boolean(req.cookies && (req.cookies[ACCESS_COOKIE] || req.cookies[REFRESH_COOKIE]));
const page = (req, res, o, status = 200) => send(res, status, {
@@@ -1375,7 +1384,7 @@ ${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">${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 } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${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}${partyLink(watchPath, vod.title || 'Stream recording', vod)}${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 } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${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>
@@@ -1513,7 +1522,7 @@ ${clipCredit}
<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>${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 } })}${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' }))}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${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('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 ? '' : partyLink(watchPath, clip.title || 'Clip', clip)}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${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' }))}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
</div>
</div>
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex d746a53..10918bc 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -363,7 +363,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}${raw(shareButton({ cls: 'ovv-btn', url: `${ctx.config.video.url}${watchPath}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null }))}${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="fa-solid fa-users" aria-hidden="true"></i> Watch together</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 }))}${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>
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex 13a4469..9bcd3a1 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -384,6 +384,13 @@ function countOccurrences(haystack, needle) {
assert.strictEqual(asks.length, 1, 'one Ask your agent chip: ' + asks.join(' | '));
assert.ok(asks[0].includes('data-ov-ask="About the video “A public recording” on OpenVibe.Video (https://openvibe.video/v/101): "'), asks[0]);
assert.ok(asks[0].includes('class="ovk-ask ovk-btn'), 'the shared kit chip');
++ // Watch together (Party watch v1): the party plays the recording's own file and keeps the page as its source.
++ const party = (r.text.match(/<a class="ovv-btn" href="https:\/\/openvibe\.party\/new\?[^"]*"/) || [''])[0];
++ assert.ok(party, 'a Watch together link');
++ const q = new URL(party.slice(party.indexOf('https://'), -1).replace(/&amp;/g, '&')).searchParams;
++ assert.strictEqual(q.get('media'), 'https://openvibe.media/v/101', 'the party plays the file, not the page');
++ assert.strictEqual(q.get('source_url'), 'https://openvibe.video/v/101', 'the page goes with it');
++ assert.strictEqual(q.get('title'), 'A public recording');
});
await check('the clip watch page attributes its maker and links the full recording', async () => {