openvibers/openvibe.live Commits

Add floating channel player embed (#702)

OP OpenVibers committed 5 days ago · a6930a7c2b · 8 files, +231 −0

Changes

ddiff --git a/docs/broadcasting.md b/docs/broadcasting.md
iindex 164b8466..2dfeb531 100644
---- a/docs/broadcasting.md
++++ b/docs/broadcasting.md
@@@ -2,6 +2,8 @@
OpenVibe.Live supports four live broadcast methods, each suited to different use cases.
++For a chat-free channel player that can float across OpenVibe sites, see [Channel embeds](embed.md) or visit `/docs/embed`.
++
## Broadcast Methods
### WebRTC (Browser)
ddiff --git a/docs/embed.md b/docs/embed.md
nnew file mode 100644
iindex 00000000..dcb92539
---- /dev/null
++++ b/docs/embed.md
@@@ -0,0 +1,15 @@
++# Channel embeds
++
++`GET /embed/<username>` (also `/embed/@<username>`) renders a small, chat-free player suitable for a floating mini player on another OpenVibe site. It uses the same `/ws/broadcast` stream player as the channel page. Playback starts muted; the parent can control it with `postMessage`.
++
++Commands sent by the parent:
++
++```js
++frame.contentWindow.postMessage({ type: 'ov-embed', cmd: 'unmute' }, 'https://openvibe.live');
++```
++
++`cmd` may be `mute`, `unmute`, `play`, or `pause`. The frame accepts commands only from origins in Network's `/api/frame` list (cached for five minutes), plus `*.openvibe.website`; a checked-in first-party list is used if Network is unavailable.
++
++The frame sends `{type:'ov-embed', event:'state', live, muted, playing, title, username, viewers}` on load and whenever playback or channel state changes. The payload contains only public channel data. Listen for it with `event.data.type === 'ov-embed' && event.data.event === 'state'`.
++
++Channels marked 18+ show an age confirmation before the player starts, matching the channel page's gate. Offline channels show their public avatar and an offline label.
ddiff --git a/public/js/channel-embed.js b/public/js/channel-embed.js
nnew file mode 100644
iindex 00000000..4fe5dc89
---- /dev/null
++++ b/public/js/channel-embed.js
@@@ -0,0 +1,77 @@
++'use strict';
++(() => {
++ const user = decodeURIComponent(location.pathname.split('/').pop()).replace(/^@/, '');
++ const video = document.getElementById('video-element');
++ const safeOrigins = new Set(window.__OV_EMBED_ORIGINS || []);
++ const allowedOrigin = (origin) => safeOrigins.has(origin) || /^https:\/\/(?:[a-z0-9-]+\.)+openvibe\.website$/.test(origin);
++ let state = { live: false, muted: true, playing: false, title: '', username: user, viewers: 0 };
++ let currentStreamId = null;
++ const approved18 = new Set();
++ const send = () => { if (parent !== window) parent.postMessage({ type: 'ov-embed', event: 'state', ...state }, '*'); };
++ const paint = () => { document.getElementById('live').hidden = !state.live; send(); };
++ const setMuted = (muted) => {
++ const wanted = !!muted;
++ if (state.muted !== wanted) document.getElementById('btn-volume')?.click();
++ window.OVChannelEmbedForceMuted = wanted;
++ video.muted = wanted;
++ if (!wanted) video.play().catch(() => {});
++ state.muted = wanted;
++ send();
++ };
++ const setPlaying = (playing) => {
++ const wanted = !!playing;
++ if (state.playing !== wanted) document.getElementById('btn-play-pause')?.click();
++ state.playing = wanted;
++ send();
++ };
++ addEventListener('message', (event) => {
++ if (!allowedOrigin(event.origin) || event.source !== parent || event.data?.type !== 'ov-embed') return;
++ if (event.data.cmd === 'mute') setMuted(true);
++ if (event.data.cmd === 'unmute') setMuted(false);
++ if (event.data.cmd === 'play') setPlaying(true);
++ if (event.data.cmd === 'pause') setPlaying(false);
++ });
++ video.addEventListener('volumechange', () => { state.muted = video.muted; send(); });
++ video.addEventListener('playing', () => { state.playing = true; send(); });
++ video.addEventListener('pause', () => { state.playing = false; send(); });
++ document.getElementById('video-container').addEventListener('click', (event) => {
++ if (state.live && !event.target.closest('#gate')) setMuted(!state.muted);
++ });
++ const start = (stream) => {
++ currentStreamId = stream.id;
++ state.muted = true;
++ state.playing = true;
++ window.OVChannelEmbedForceMuted = true;
++ video.muted = true;
++ document.getElementById('offline').style.display = 'none';
++ initPlayer(stream);
++ };
++ const load = async () => {
++ try {
++ const r = await fetch(`/api/streams/channel/${encodeURIComponent(user)}?pollOnly=1`);
++ if (!r.ok) throw new Error('offline');
++ const data = await r.json();
++ const stream = (data.liveStreams || data.streams || []).find((s) => s.is_live) || data.liveStream;
++ const channel = data.channel || data;
++ state.username = channel.username || user; state.title = stream?.title || ''; state.viewers = Number(stream?.viewer_count || 0); state.live = !!stream;
++ if (!stream) {
++ if (currentStreamId !== null) { destroyPlayer(); currentStreamId = null; state.playing = false; }
++ document.getElementById('gate').style.display = 'none';
++ const box = document.getElementById('offline');
++ box.style.display = 'block'; box.innerHTML = `<img alt="" src="${String(channel.avatar_url || '').replace(/["<>]/g,'')}"><strong>${String(channel.display_name || user).replace(/[<>]/g,'')} is offline</strong>`;
++ } else if ((stream.is_nsfw || data.channel?.force_nsfw || data.channel?.is_nsfw) && !approved18.has(String(stream.id))) {
++ document.getElementById('gate').style.display = 'grid';
++ if (currentStreamId !== null) { destroyPlayer(); currentStreamId = null; state.playing = false; }
++ document.getElementById('continue').onclick = () => { approved18.add(String(stream.id)); document.getElementById('gate').style.display = 'none'; start(stream); };
++ } else {
++ document.getElementById('gate').style.display = 'none';
++ if (String(currentStreamId) !== String(stream.id)) start(stream);
++ }
++ paint();
++ } catch {
++ if (currentStreamId !== null) { destroyPlayer(); currentStreamId = null; state.playing = false; }
++ document.getElementById('offline').textContent = `${user} is offline`; document.getElementById('offline').style.display = 'block'; state.live = false; paint();
++ }
++ };
++ paint(); load(); setInterval(load, 15000);
++})();
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex e85d61d5..8fca34bc 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -3594,6 +3594,7 @@ function getSavedVolume() {
}
function getSavedMuted() {
++ if (window.OVChannelEmbedForceMuted) return true;
try {
return localStorage.getItem(PLAYER_MUTED_KEY) === '1';
} catch {
ddiff --git a/server/index.js b/server/index.js
iindex b896414d..73b1fcde 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -878,6 +878,7 @@ app.use('/api/social', require('./social/routes').router); // channel social l
app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);
// An agent stream's embed for the task page and the Dock (Agent Streams S1): standalone, first-party frames only.
app.get('/embed/agent/:id', require('./agent-streams/pages').embed);
++app.get('/embed/:username', require('./streaming/channel-embed').page);
app.use('/api/cam', require('./cam/routes')); // the cam button: a viewer takes a still of a live stream
app.use('/api/comments', commentRoutes);
app.use('/api/admin/openre', require('./openre/routes'));
ddiff --git a/server/streaming/channel-embed.js b/server/streaming/channel-embed.js
nnew file mode 100644
iindex 00000000..e827ab5a
---- /dev/null
++++ b/server/streaming/channel-embed.js
@@@ -0,0 +1,49 @@
++'use strict';
++
++// Snapshot of Network /api/frame nav hosts (2026-11-08), plus the wildcard host rule.
++const FALLBACK_SITES = [
++ 'https://openvibe.live', 'https://openvibe.community', 'https://openvibe.chat', 'https://openvibe.video',
++ 'https://openvibe.pics', 'https://openvibe.download', 'https://openvibe.tools', 'https://openvibe.bot',
++ 'https://openvibe.space', 'https://openvibe.news', 'https://openvibe.tips', 'https://openvibe.vip',
++ 'https://openvibe.games', 'https://openvibe.blog', 'https://openvibe.coupons', 'https://openvibe.deals',
++ 'https://openvibe.trade', 'https://openvibe.report', 'https://openvibe.wiki', 'https://openvibe.exchange',
++ 'https://openvibe.actor', 'https://openvibe.network', 'https://openvibe.media', 'https://openvibe.host',
++ 'https://openvibe.reviews', 'https://openvibe.design', 'https://openvibe.work', 'https://openvibe.domains',
++ 'https://openvibe.today', 'https://openvibe.fun', 'https://open.shoes', 'https://openvibe.casino',
++ 'https://openvibe.tours', 'https://openvibe.meme', 'https://openvibe.vision', 'https://openvibe.cam',
++ 'https://openvibe.services', 'https://connect.openvibe.website', 'https://openvibe.run', 'https://openvibe.help',
++ 'https://openvibe.zone', 'https://openvibe.surf', 'https://openvibe.codes', 'https://openvibe.quest',
++ 'https://openvibe.food', 'https://openvibe.rent', 'https://openvibe.homes', 'https://openvibe.watch',
++ 'https://openvibe.events', 'https://openre.stream', 'https://search.openvibe.network', 'https://openvibe.email',
++ 'https://node.openvibe.network', 'https://openvibe.website',
++];
++let cachedSites = null;
++let cachedAt = 0;
++async function frameSites() {
++ if (cachedSites && Date.now() - cachedAt < 5 * 60_000) return cachedSites;
++ try {
++ const response = await require('../net/egress').fetchText('https://openvibe.network/api/frame', { timeoutMs: 1500, maxBytes: 256 * 1024 });
++ if (response.status !== 200) throw new Error('frame list unavailable');
++ const body = JSON.parse(response.text);
++ const values = Array.isArray(body) ? body : (body.sites || body.origins || body.frames || body.nav || []);
++ const sites = values.map((v) => typeof v === 'string' ? v : v.origin || v.url || (v.host && `https://${v.host}`)).filter((v) => typeof v === 'string').map((v) => v.replace(/\/$/, '')).filter((v) => /^https:\/\/[a-z0-9.-]+(?::\d+)?$/.test(v));
++ if (!sites.length) throw new Error('empty frame list');
++ cachedSites = [...new Set(["'self'", ...sites, 'https://*.openvibe.website'])];
++ } catch { cachedSites ||= ["'self'", ...FALLBACK_SITES, 'https://*.openvibe.website']; }
++ cachedAt = Date.now();
++ return cachedSites;
++}
++function html(origins = FALLBACK_SITES) {
++ const originData = JSON.stringify(origins.filter((o) => o !== "'self'")).replace(/</g, '\\u003c');
++ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>Live channel</title><style>*{box-sizing:border-box}html,body,#video-container{margin:0;width:100%;height:100%;background:#000;color:#fff;font:14px system-ui;overflow:hidden}#video-container{position:relative;display:grid;place-items:center}#video-canvas,#video-element{position:absolute;width:100%;height:100%;inset:0;object-fit:contain}.video-placeholder{position:absolute;inset:0;z-index:1}.badge{position:absolute;top:10px;left:10px;background:#e11d48;border-radius:4px;padding:3px 7px;font-size:11px;font-weight:700;z-index:5}#offline{display:none;text-align:center;padding:24px}#offline img{width:64px;height:64px;border-radius:50%;object-fit:cover}#offline strong{display:block;margin-top:12px}#gate{display:none;position:absolute;inset:0;z-index:10;background:#111;place-content:center;text-align:center;padding:22px}#gate button{padding:10px 16px}.player-internals{display:none!important}</style></head><body><main id="video-container" class="video-container"><div class="video-placeholder" style="display:none"></div><canvas id="video-canvas" style="display:none"></canvas><video id="video-element" playsinline muted autoplay style="display:none"></video><span id="live" class="badge" hidden>LIVE</span><div id="offline"></div><section id="gate"><h2>18+ content</h2><p>You must be 18 or older to view this stream.</p><button id="continue">I'm 18+ — Continue</button></section><div class="player-internals" aria-hidden="true"><button id="btn-play-pause"></button><button id="btn-volume" class="volume"></button><input id="volume-slider" type="range" value="75"><button id="btn-fullscreen"></button><button id="btn-clip"></button></div></main><script>window.__OV_EMBED_ORIGINS=${originData};window.OVChannelEmbedForceMuted=true</script><script src="/js/stream-player.js" defer></script><script src="/js/channel-embed.js" defer></script></body></html>`;
++}
++async function page(req, res) {
++ const username = String(req.params.username || '').replace(/^@/, '');
++ if (!/^[A-Za-z0-9_]{3,24}$/.test(username)) return res.sendStatus(404);
++ const db = require('../db/database');
++ const channel = await db.getChannelByUsername(username).catch(() => null) || await db.getUserByUsername(username).catch(() => null);
++ if (!channel) return res.sendStatus(404);
++ res.set('Content-Security-Policy', `default-src 'self'; script-src 'self' 'unsafe-inline' https://esm.sh; style-src 'self' 'unsafe-inline'; img-src 'self' https: data: blob:; media-src 'self' blob:; connect-src 'self' wss: https://esm.sh; frame-ancestors ${(await frameSites()).join(' ')}; base-uri 'none'; form-action 'none'`);
++ res.set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').type('html').send(html(await frameSites()));
++}
++module.exports = { page, frameSites, html, FALLBACK_SITES };
ddiff --git a/server/web/page-status.js b/server/web/page-status.js
iindex e4010444..107e255e 100644
---- a/server/web/page-status.js
++++ b/server/web/page-status.js
@@@ -167,6 +167,7 @@ async function statusFor(req) {
const segs = String(req.path || '/').split('/').filter(Boolean);
if (!segs.length) return OK;
const [first, second] = segs;
++ if (first === 'embed' && segs.length === 2) return await channelStatus(second.startsWith('@') ? second : `@${second}`);
if (segs.length === 1 && EXACT.has(first)) return OK;
if (PREFIX.has(first)) return OK;
if (first.startsWith('@')) {
ddiff --git a/test/channel-embed.test.js b/test/channel-embed.test.js
nnew file mode 100644
iindex 00000000..ed8f2ffe
---- /dev/null
++++ b/test/channel-embed.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const vm = require('vm');
++const zlib = require('zlib');
++const embed = require('../server/streaming/channel-embed');
++
++assert.ok(embed.html().includes('/js/stream-player.js'));
++assert.ok(embed.html().includes('/js/channel-embed.js'));
++assert.ok(embed.html(['https://openvibe.live']).includes('https://openvibe.live'));
++const clientSource = fs.readFileSync(require('path').join(__dirname, '../public/js/channel-embed.js'), 'utf8');
++assert.match(clientSource, /if \(!stream\)/, 'offline channels render an offline state');
++assert.match(clientSource, /stream\.is_nsfw/, '18+ streams stop at the age gate');
++const scripts = ['public/js/stream-player.js', 'public/js/channel-embed.js'];
++const bytes = scripts.reduce((n, f) => n + zlib.brotliCompressSync(fs.readFileSync(require('path').join(__dirname, '..', f))).length, 0);
++assert.ok(bytes < 60 * 1024, `embed scripts are ${bytes} bytes brotli`);
++
++let onMessage;
++const video = { muted: true, paused: true, play() { this.paused = false; return Promise.resolve(); }, pause() { this.paused = true; }, addEventListener() {} };
++const context = {
++ window: { __OV_EMBED_ORIGINS: ['https://openvibe.live'] }, parent: { postMessage() {} }, location: { pathname: '/embed/alice' },
++ document: { getElementById(id) { return id === 'video-element' ? video : { addEventListener() {}, click() {}, style: {}, set textContent(v) {}, set innerHTML(v) {} }; } },
++ addEventListener(type, fn) { if (type === 'message') onMessage = fn; }, setInterval() {}, fetch: async () => ({ ok: false }),
++};
++vm.runInNewContext(clientSource, context);
++onMessage({ origin: 'https://evil.example', source: context.parent, data: { type: 'ov-embed', cmd: 'unmute' } });
++assert.strictEqual(video.muted, true, 'untrusted origin cannot control playback');
++onMessage({ origin: 'https://openvibe.live', source: context.parent, data: { type: 'ov-embed', cmd: 'unmute' } });
++assert.strictEqual(video.muted, false, 'allowlisted parent can control playback');
++(async () => {
++ const dbPath = require.resolve('../server/db/database');
++ const egressPath = require.resolve('../server/net/egress');
++ const oldDbModule = require.cache[dbPath];
++ const oldEgressModule = require.cache[egressPath];
++ require.cache[dbPath] = { id: dbPath, filename: dbPath, loaded: true, exports: {
++ getChannelByUsername: async (name) => name.toLowerCase() === 'embedcheck' ? { username: name } : null,
++ getUserByUsername: async (name) => name.toLowerCase() === 'embedcheck' ? { username: name } : null,
++ } };
++ require.cache[egressPath] = { id: egressPath, filename: egressPath, loaded: true, exports: { fetchText: async () => ({ status: 200, text: JSON.stringify({ nav: [{ url: 'https://openvibe.live/' }, { url: 'https://openvibe.network/' }] }) }) } };
++ const previousFetch = global.fetch;
++ const response = () => ({ headers: {}, statusCode: 200, set(k, v) { this.headers[k] = v; return this; }, status(n) { this.statusCode = n; return this; }, sendStatus(n) { this.statusCode = n; return this; }, type() { return this; }, send(body) { this.body = body; return this; } });
++ const known = response();
++ await embed.page({ params: { username: '@embedcheck' } }, known);
++ assert.strictEqual(known.statusCode, 200, 'known channel route is served');
++ assert.match(known.headers['Content-Security-Policy'], /frame-ancestors 'self' https:\/\/openvibe\.live https:\/\/openvibe\.network https:\/\/\*\.openvibe\.website/);
++ const missing = response();
++ await embed.page({ params: { username: 'missing' } }, missing);
++ assert.strictEqual(missing.statusCode, 404, 'unknown channel route is 404');
++ global.fetch = previousFetch;
++
++ delete require.cache[require.resolve('../server/web/page-status')];
++ const status = require('../server/web/page-status').statusFor;
++ assert.strictEqual(await status({ path: '/embed/embedcheck' }), 200, 'known channels reach the embed route');
++ assert.strictEqual(await status({ path: '/embed/no-such-channel' }), 404, 'unknown channels remain 404');
++ delete require.cache[require.resolve('../server/web/page-status')];
++ if (oldDbModule) require.cache[dbPath] = oldDbModule; else delete require.cache[dbPath];
++ if (oldEgressModule) require.cache[egressPath] = oldEgressModule; else delete require.cache[egressPath];
++
++ const simulate = async (payload) => {
++ const elements = Object.fromEntries(['video-element', 'live', 'offline', 'gate', 'continue', 'video-container', 'btn-volume', 'btn-play-pause'].map((id) => [id, { hidden: false, style: {}, addEventListener() {}, click() {} }]));
++ const playerCalls = [];
++ const sent = [];
++ const browser = {
++ window: { __OV_EMBED_ORIGINS: ['https://openvibe.live'] },
++ parent: { postMessage(data, target) { sent.push({ data, target }); } }, location: { pathname: '/embed/alice' },
++ document: { getElementById(id) { return elements[id]; } }, addEventListener() {}, setInterval() {},
++ fetch: async () => ({ ok: true, json: async () => payload }), initPlayer(stream) { playerCalls.push(stream); },
++ };
++ browser.window.parent = browser.parent;
++ vm.runInNewContext(clientSource, browser);
++ await new Promise((resolve) => setImmediate(resolve));
++ return { elements, playerCalls, sent };
++ };
++ const liveResult = await simulate({ channel: { username: 'alice', display_name: 'Alice' }, streams: [{ id: 2, is_live: true, title: 'Public show' }] });
++ assert.strictEqual(liveResult.playerCalls.length, 1, 'live channel starts the shared player');
++ assert.strictEqual(liveResult.sent.at(-1).data.live, true, 'live state is announced to the parent');
++ const offlineResult = await simulate({ channel: { username: 'alice', display_name: 'Alice' }, streams: [] });
++ assert.strictEqual(offlineResult.elements.offline.style.display, 'block', 'offline channel shows the offline card');
++ const gatedResult = await simulate({ channel: { username: 'alice' }, streams: [{ id: 3, is_live: true, is_nsfw: true }] });
++ assert.strictEqual(gatedResult.elements.gate.style.display, 'grid', '18+ channel shows the age gate');
++ assert.strictEqual(gatedResult.playerCalls.length, 0, '18+ player waits for confirmation');
++ gatedResult.elements.continue.onclick();
++ assert.strictEqual(gatedResult.playerCalls.length, 1, '18+ confirmation starts the player');
++ console.log('channel embed route, origin, and size checks passed');
++})().catch((err) => { console.error(err); process.exitCode = 1; });