openvibers/openvibe.live Commits
Add floating channel player embed (#702)
Changes
@@@ -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)nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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);++})();@@@ -3594,6 +3594,7 @@ function getSavedVolume() {}function getSavedMuted() {++ if (window.OVChannelEmbedForceMuted) return true;try {return localStorage.getItem(PLAYER_MUTED_KEY) === '1';} catch {@@@ -878,6 +878,7 @@ app.use('/api/social', require('./social/routes').router); // channel social lapp.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 streamapp.use('/api/comments', commentRoutes);app.use('/api/admin/openre', require('./openre/routes'));nnew file mode 100644@@@ -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 };@@@ -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('@')) {nnew file mode 100644@@@ -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; });
@@ -2,6 +2,8 @@
@@ -2,6 +2,8 @@
OpenVibe.Live supports four live broadcast methods, each suited to different use cases.
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
## Broadcast Methods
### WebRTC (Browser)
### WebRTC (Browser)
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -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);
+})();
@@ -3594,6 +3594,7 @@ function getSavedVolume() {
@@ -3594,6 +3594,7 @@ function getSavedVolume() {
}
}
function getSavedMuted() {
function getSavedMuted() {
+ if (window.OVChannelEmbedForceMuted) return true;
try {
try {
return localStorage.getItem(PLAYER_MUTED_KEY) === '1';
return localStorage.getItem(PLAYER_MUTED_KEY) === '1';
} catch {
} catch {
@@ -878,6 +878,7 @@ app.use('/api/social', require('./social/routes').router); // channel social l
@@ -878,6 +878,7 @@ app.use('/api/social', require('./social/routes').router); // channel social l
app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);
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.
// 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/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/cam', require('./cam/routes')); // the cam button: a viewer takes a still of a live stream
app.use('/api/comments', commentRoutes);
app.use('/api/comments', commentRoutes);
app.use('/api/admin/openre', require('./openre/routes'));
app.use('/api/admin/openre', require('./openre/routes'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -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 };
@@ -167,6 +167,7 @@ async function statusFor(req) {
@@ -167,6 +167,7 @@ async function statusFor(req) {
const segs = String(req.path || '/').split('/').filter(Boolean);
const segs = String(req.path || '/').split('/').filter(Boolean);
if (!segs.length) return OK;
if (!segs.length) return OK;
const [first, second] = segs;
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 (segs.length === 1 && EXACT.has(first)) return OK;
if (PREFIX.has(first)) return OK;
if (PREFIX.has(first)) return OK;
if (first.startsWith('@')) {
if (first.startsWith('@')) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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; });