openvibers/openvibe.mediahub Commits
Watch pages: More from the channel beside the player (#348)
Changes
@@@ -1024,6 +1024,24 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}return `/${kind === 'vod' ? 'v' : 'c'}/${itemId}`;}++ /**++ * More from the channel, beside the player (11-09: the watch page's right side stood empty on a computer): its other++ * public recordings and clips, newest first, never the one playing. Live not answering leaves the section out.++ */++ async function moreFromChannel(username, name, kind, id) {++ if (!username) return '';++ const pub = (x) => x && x.visibility !== 'private' && x.visibility !== 'unlisted';++ const [v, c] = await Promise.all([++ Promise.resolve().then(() => live.vodsByUser(username, { limit: 7 })).catch(() => null),++ Promise.resolve().then(() => live.clipsByUser(username, { limit: 7 })).catch(() => null),++ ]);++ const vods = (Array.isArray(v && v.vods) ? v.vods : []).filter(pub).filter((x) => !(kind === 'vod' && Number(x.id) === Number(id))).slice(0, 4);++ // On a recording, its own clips are in "Clips from this stream" already: only the channel's other clips here.++ const clipsOf = (Array.isArray(c && c.clips) ? c.clips : []).filter(pub).filter((x) => !(kind === 'clip' && Number(x.id) === Number(id)) && !(kind === 'vod' && Number(x.vod_id) === Number(id))).slice(0, 4);++ if (!vods.length && !clipsOf.length) return '';++ return html`<section class="ovv-panel ovv-more" aria-labelledby="h-more"><h2 id="h-more">More from ${name || username}</h2><div class="ovv-grid ovv-grid--side">${vods.map(vodCard)}${clipsOf.map(clipCard)}</div></section>`;++ }++async function upNext(req, kind, id) {const listId = String(req.query.list || '');if (!playlists.isPlaylistId(listId)) return { html: '', nextUrl: null };@@@ -1397,7 +1415,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv// Chapters, comments, chat replay, the playlist form and up next do not depend on each other: asked together// (one after another, five services' answers in turn, the page's p95 was 581 ms on 2026-10-17).const fail = Symbol('failed');-- const [m, cm, replay, saveForm, up, hist] = await Promise.all([++ const [m, cm, replay, saveForm, up, hist, more] = await Promise.all([// A private recording's moments are Live's answer to its owner only.Promise.resolve().then(() => (ownOnly ? asPerson(req, res, (token) => live.vodMemoriesAs(token, id)) : live.vodMemories(id))).catch(() => fail),Promise.resolve().then(() => live.comments('vod', id)).catch(() => fail),@@@ -1407,6 +1425,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv// The stream's history: the picture's shape where this video starts, and for one part of a stream recorded in// parts (its broadcaster reconnected) the other parts, to move between.Promise.resolve().then(() => (live.isId(vod.stream_id) ? live.streamHistory(vod.stream_id) : null)).catch(() => fail),++ moreFromChannel(vod.username, vod.display_name, 'vod', id).catch(() => ''),]);let mems = [];try { if (m !== fail) mems = chapters(moments(m && m.memories), vod.duration_seconds); } catch { mems = []; }@@@ -1493,6 +1512,7 @@ ${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-c${side || ''}${up.html}${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-clips"><h2 id="h-clips">Clips from this stream</h2><div class="ovv-grid ovv-grid--side">${clips.map(clipCard)}</div></section>` : ''}++${more || ''}</aside></div>`,});@@@ -1532,11 +1552,12 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-clconst sourceAnswer = clip.vod_id && live.isId(String(clip.vod_id))? live.vod(String(clip.vod_id)).then((s) => (s && s.vod) || null).catch(() => 'down'): Promise.resolve('none');-- const [cm, saveForm, up, sourceVod] = await Promise.all([++ const [cm, saveForm, up, sourceVod, more] = await Promise.all([Promise.resolve().then(() => live.comments('clip', id)).catch(() => null),playlistForm(req, 'clip', id),upNext(req, 'clip', id),sourceAnswer,++ moreFromChannel(clip.username, clip.display_name, 'clip', id).catch(() => ''),]);let threadUrl = null;try { threadUrl = httpsUrl(cm && cm.thread && cm.thread.url); } catch { threadUrl = null; }@@@ -1615,6 +1636,7 @@ ${commentRows ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-<aside class="ovv-col ovv-col--side">${side || ''}${up.html}++${more || ''}</aside></div>`,});@@@ -141,6 +141,17 @@ function hreq(base, host, path, { method = 'GET', cookie, form, body, type, sameassert.ok(!r.text.includes('name="labels"'), 'no picker for a viewer');});++ await check('beside the player: more from the channel, never the one playing', async () => {++ const v = await video('/v/301');++ const more = (v.text.split('<section class="ovv-panel ovv-more"')[1] || '').split('</section>')[0];++ assert.ok(!/Kim labelled/.test(more), 'never the recording playing');++ assert.ok(!/Kim clip/.test(more), 'its own clips are in Clips from this stream, not again here');++ const c = await video('/c/401');++ const moreC = (c.text.split('<section class="ovv-panel ovv-more"')[1] || '').split('</section>')[0];++ assert.ok(moreC.includes('More from Kim'), 'the section names the channel');++ assert.ok(/Kim labelled/.test(moreC) && !/Kim clip/.test(moreC), 'on a clip: the channel\'s recording, not the clip playing');++ });++await check('cards: the picture in a cover outside the hidden link, the chips under the title', async () => {const r = await video('/@kim');assert.strictEqual(r.status, 200, r.text.slice(0, 300));
@@ -1024,6 +1024,24 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -1024,6 +1024,24 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
return `/${kind === 'vod' ? 'v' : 'c'}/${itemId}`;
return `/${kind === 'vod' ? 'v' : 'c'}/${itemId}`;
}
}
+ /**
+ * More from the channel, beside the player (11-09: the watch page's right side stood empty on a computer): its other
+ * public recordings and clips, newest first, never the one playing. Live not answering leaves the section out.
+ */
+ async function moreFromChannel(username, name, kind, id) {
+ if (!username) return '';
+ const pub = (x) => x && x.visibility !== 'private' && x.visibility !== 'unlisted';
+ const [v, c] = await Promise.all([
+ Promise.resolve().then(() => live.vodsByUser(username, { limit: 7 })).catch(() => null),
+ Promise.resolve().then(() => live.clipsByUser(username, { limit: 7 })).catch(() => null),
+ ]);
+ const vods = (Array.isArray(v && v.vods) ? v.vods : []).filter(pub).filter((x) => !(kind === 'vod' && Number(x.id) === Number(id))).slice(0, 4);
+ // On a recording, its own clips are in "Clips from this stream" already: only the channel's other clips here.
+ const clipsOf = (Array.isArray(c && c.clips) ? c.clips : []).filter(pub).filter((x) => !(kind === 'clip' && Number(x.id) === Number(id)) && !(kind === 'vod' && Number(x.vod_id) === Number(id))).slice(0, 4);
+ if (!vods.length && !clipsOf.length) return '';
+ return html`<section class="ovv-panel ovv-more" aria-labelledby="h-more"><h2 id="h-more">More from ${name || username}</h2><div class="ovv-grid ovv-grid--side">${vods.map(vodCard)}${clipsOf.map(clipCard)}</div></section>`;
+ }
+
async function upNext(req, kind, id) {
async function upNext(req, kind, id) {
const listId = String(req.query.list || '');
const listId = String(req.query.list || '');
if (!playlists.isPlaylistId(listId)) return { html: '', nextUrl: null };
if (!playlists.isPlaylistId(listId)) return { html: '', nextUrl: null };
@@ -1397,7 +1415,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1397,7 +1415,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
// Chapters, comments, chat replay, the playlist form and up next do not depend on each other: asked together
// Chapters, comments, chat replay, the playlist form and up next do not depend on each other: asked together
// (one after another, five services' answers in turn, the page's p95 was 581 ms on 2026-10-17).
// (one after another, five services' answers in turn, the page's p95 was 581 ms on 2026-10-17).
const fail = Symbol('failed');
const fail = Symbol('failed');
- const [m, cm, replay, saveForm, up, hist] = await Promise.all([
+ const [m, cm, replay, saveForm, up, hist, more] = await Promise.all([
// A private recording's moments are Live's answer to its owner only.
// A private recording's moments are Live's answer to its owner only.
Promise.resolve().then(() => (ownOnly ? asPerson(req, res, (token) => live.vodMemoriesAs(token, id)) : live.vodMemories(id))).catch(() => fail),
Promise.resolve().then(() => (ownOnly ? asPerson(req, res, (token) => live.vodMemoriesAs(token, id)) : live.vodMemories(id))).catch(() => fail),
Promise.resolve().then(() => live.comments('vod', id)).catch(() => fail),
Promise.resolve().then(() => live.comments('vod', id)).catch(() => fail),
@@ -1407,6 +1425,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1407,6 +1425,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
// The stream's history: the picture's shape where this video starts, and for one part of a stream recorded in
// The stream's history: the picture's shape where this video starts, and for one part of a stream recorded in
// parts (its broadcaster reconnected) the other parts, to move between.
// parts (its broadcaster reconnected) the other parts, to move between.
Promise.resolve().then(() => (live.isId(vod.stream_id) ? live.streamHistory(vod.stream_id) : null)).catch(() => fail),
Promise.resolve().then(() => (live.isId(vod.stream_id) ? live.streamHistory(vod.stream_id) : null)).catch(() => fail),
+ moreFromChannel(vod.username, vod.display_name, 'vod', id).catch(() => ''),
]);
]);
let mems = [];
let mems = [];
try { if (m !== fail) mems = chapters(moments(m && m.memories), vod.duration_seconds); } catch { mems = []; }
try { if (m !== fail) mems = chapters(moments(m && m.memories), vod.duration_seconds); } catch { mems = []; }
@@ -1493,6 +1512,7 @@ ${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-c
@@ -1493,6 +1512,7 @@ ${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-c
${side || ''}
${side || ''}
${up.html}
${up.html}
${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-clips"><h2 id="h-clips">Clips from this stream</h2><div class="ovv-grid ovv-grid--side">${clips.map(clipCard)}</div></section>` : ''}
${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-clips"><h2 id="h-clips">Clips from this stream</h2><div class="ovv-grid ovv-grid--side">${clips.map(clipCard)}</div></section>` : ''}
+${more || ''}
</aside>
</aside>
</div>`,
</div>`,
});
});
@@ -1532,11 +1552,12 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
@@ -1532,11 +1552,12 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
const sourceAnswer = clip.vod_id && live.isId(String(clip.vod_id))
const sourceAnswer = clip.vod_id && live.isId(String(clip.vod_id))
? live.vod(String(clip.vod_id)).then((s) => (s && s.vod) || null).catch(() => 'down')
? live.vod(String(clip.vod_id)).then((s) => (s && s.vod) || null).catch(() => 'down')
: Promise.resolve('none');
: Promise.resolve('none');
- const [cm, saveForm, up, sourceVod] = await Promise.all([
+ const [cm, saveForm, up, sourceVod, more] = await Promise.all([
Promise.resolve().then(() => live.comments('clip', id)).catch(() => null),
Promise.resolve().then(() => live.comments('clip', id)).catch(() => null),
playlistForm(req, 'clip', id),
playlistForm(req, 'clip', id),
upNext(req, 'clip', id),
upNext(req, 'clip', id),
sourceAnswer,
sourceAnswer,
+ moreFromChannel(clip.username, clip.display_name, 'clip', id).catch(() => ''),
]);
]);
let threadUrl = null;
let threadUrl = null;
try { threadUrl = httpsUrl(cm && cm.thread && cm.thread.url); } catch { threadUrl = null; }
try { threadUrl = httpsUrl(cm && cm.thread && cm.thread.url); } catch { threadUrl = null; }
@@ -1615,6 +1636,7 @@ ${commentRows ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-
@@ -1615,6 +1636,7 @@ ${commentRows ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-
<aside class="ovv-col ovv-col--side">
<aside class="ovv-col ovv-col--side">
${side || ''}
${side || ''}
${up.html}
${up.html}
+${more || ''}
</aside>
</aside>
</div>`,
</div>`,
});
});
@@ -141,6 +141,17 @@ function hreq(base, host, path, { method = 'GET', cookie, form, body, type, same
@@ -141,6 +141,17 @@ function hreq(base, host, path, { method = 'GET', cookie, form, body, type, same
assert.ok(!r.text.includes('name="labels"'), 'no picker for a viewer');
assert.ok(!r.text.includes('name="labels"'), 'no picker for a viewer');
});
});
+ await check('beside the player: more from the channel, never the one playing', async () => {
+ const v = await video('/v/301');
+ const more = (v.text.split('<section class="ovv-panel ovv-more"')[1] || '').split('</section>')[0];
+ assert.ok(!/Kim labelled/.test(more), 'never the recording playing');
+ assert.ok(!/Kim clip/.test(more), 'its own clips are in Clips from this stream, not again here');
+ const c = await video('/c/401');
+ const moreC = (c.text.split('<section class="ovv-panel ovv-more"')[1] || '').split('</section>')[0];
+ assert.ok(moreC.includes('More from Kim'), 'the section names the channel');
+ assert.ok(/Kim labelled/.test(moreC) && !/Kim clip/.test(moreC), 'on a clip: the channel\'s recording, not the clip playing');
+ });
+
await check('cards: the picture in a cover outside the hidden link, the chips under the title', async () => {
await check('cards: the picture in a cover outside the hidden link, the chips under the title', async () => {
const r = await video('/@kim');
const r = await video('/@kim');
assert.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.strictEqual(r.status, 200, r.text.slice(0, 300));