openvibers/openvibe.live Commits
Carry playing live streams directly across sites (#802)
Changes
@@@ -6,15 +6,24 @@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;++ let requestedSound = false;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;++ requestedSound = !wanted;if (state.muted !== wanted) document.getElementById('btn-volume')?.click();window.OVChannelEmbedForceMuted = wanted;video.muted = wanted;-- if (!wanted) video.play().catch(() => {});++ if (!wanted && (video.srcObject || video.currentSrc)) video.play().catch(() => {++ // A newly loaded cross-site document may need another tap for sound.++ requestedSound = false;++ window.OVChannelEmbedForceMuted = true;++ video.muted = true;++ state.muted = true;++ send();++ });state.muted = wanted;send();};@@@ -39,10 +48,10 @@});const start = (stream) => {currentStreamId = stream.id;-- state.muted = true;++ state.muted = !requestedSound;state.playing = true;-- window.OVChannelEmbedForceMuted = true;-- video.muted = true;++ window.OVChannelEmbedForceMuted = !requestedSound;++ video.muted = !requestedSound;document.getElementById('offline').style.display = 'none';initPlayer(stream);};@@@ -415,12 +415,31 @@if (!on || !st.user) { O.declare('live', null); return; }const u = encodeURIComponent(st.user);O.declare('live', {-- src: `${location.origin}/embed/${u}`, title: st.title, live: true, href: `${location.origin}/@${u}`,++ src: `${location.origin}/embed/${u}`, title: st.title, live: true, sound: !!(st.video || document.getElementById('video-element')) && !(st.video || document.getElementById('video-element')).muted,++ href: `${location.origin}/@${u}`,side: { src: `${location.origin}/popout/${u}`, title: `${st.title} \u00b7 chat` },});}).catch(() => { /* the overlay host could not load: the mini player still works here */ });}++ // A direct jump from a playing channel to another OpenVibe site also carries the++ // stream. The mini player is only created for routes within Live.++ document.addEventListener('playing', (event) => {++ if (event.target?.id !== 'video-element' || st.box || !isChannelPath(location.pathname)) return;++ const match = /^\/@([^/?#]+)/.exec(location.pathname);++ if (!match) return;++ st.user = decodeURIComponent(match[1]);++ st.title = (document.getElementById('ch-display-name')?.textContent || '').trim()++ || (document.getElementById('ch-stream-title')?.textContent || '').trim() || st.user;++ declare(true);++ }, true);++ document.addEventListener('pause', (event) => {++ if (event.target?.id === 'video-element' && !st.box) declare(false);++ }, true);++ document.addEventListener('volumechange', (event) => {++ if (event.target?.id === 'video-element' && st.user && isPlaying()) declare(true);++ }, true);++/** The viewer closed it: the player goes away for good. */function closeForGood() {if (typeof destroyPlayer === 'function') { try { destroyPlayer(); } catch { /* */ } }@@@ -56,24 +56,29 @@ assert.strictEqual(video.muted, false, 'allowlisted parent can control playback'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 simulate = async (payload, preSound = false) => {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 = [];++ let message;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); },++ document: { getElementById(id) { return elements[id]; } }, addEventListener(type, fn) { if (type === 'message') message = fn; }, setInterval() {},++ fetch: async () => ({ ok: true, json: async () => payload }), initPlayer(stream) { playerCalls.push({ stream, muted: elements['video-element'].muted, forceMuted: browser.window.OVChannelEmbedForceMuted }); },};browser.window.parent = browser.parent;vm.runInNewContext(clientSource, browser);++ if (preSound) message({ origin: 'https://openvibe.live', source: browser.parent, data: { type: 'ov-embed', cmd: 'unmute' } });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 carried = await simulate({ channel: { username: 'alice' }, streams: [{ id: 4, is_live: true }] }, true);++ assert.strictEqual(carried.playerCalls[0].forceMuted, false, 'sound intent received before the new stream starts is preserved');++ assert.strictEqual(carried.playerCalls[0].muted, false, 'the carried stream starts with sound when allowed');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 }] });
@@ -6,15 +6,24 @@
@@ -6,15 +6,24 @@
const allowedOrigin = (origin) => safeOrigins.has(origin) || /^https:\/\/(?:[a-z0-9-]+\.)+openvibe\.website$/.test(origin);
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 state = { live: false, muted: true, playing: false, title: '', username: user, viewers: 0 };
let currentStreamId = null;
let currentStreamId = null;
+ let requestedSound = false;
const approved18 = new Set();
const approved18 = new Set();
const send = () => { if (parent !== window) parent.postMessage({ type: 'ov-embed', event: 'state', ...state }, '*'); };
const send = () => { if (parent !== window) parent.postMessage({ type: 'ov-embed', event: 'state', ...state }, '*'); };
const paint = () => { document.getElementById('live').hidden = !state.live; send(); };
const paint = () => { document.getElementById('live').hidden = !state.live; send(); };
const setMuted = (muted) => {
const setMuted = (muted) => {
const wanted = !!muted;
const wanted = !!muted;
+ requestedSound = !wanted;
if (state.muted !== wanted) document.getElementById('btn-volume')?.click();
if (state.muted !== wanted) document.getElementById('btn-volume')?.click();
window.OVChannelEmbedForceMuted = wanted;
window.OVChannelEmbedForceMuted = wanted;
video.muted = wanted;
video.muted = wanted;
- if (!wanted) video.play().catch(() => {});
+ if (!wanted && (video.srcObject || video.currentSrc)) video.play().catch(() => {
+ // A newly loaded cross-site document may need another tap for sound.
+ requestedSound = false;
+ window.OVChannelEmbedForceMuted = true;
+ video.muted = true;
+ state.muted = true;
+ send();
+ });
state.muted = wanted;
state.muted = wanted;
send();
send();
};
};
@@ -39,10 +48,10 @@
@@ -39,10 +48,10 @@
});
});
const start = (stream) => {
const start = (stream) => {
currentStreamId = stream.id;
currentStreamId = stream.id;
- state.muted = true;
+ state.muted = !requestedSound;
state.playing = true;
state.playing = true;
- window.OVChannelEmbedForceMuted = true;
- video.muted = true;
+ window.OVChannelEmbedForceMuted = !requestedSound;
+ video.muted = !requestedSound;
document.getElementById('offline').style.display = 'none';
document.getElementById('offline').style.display = 'none';
initPlayer(stream);
initPlayer(stream);
};
};
@@ -415,12 +415,31 @@
@@ -415,12 +415,31 @@
if (!on || !st.user) { O.declare('live', null); return; }
if (!on || !st.user) { O.declare('live', null); return; }
const u = encodeURIComponent(st.user);
const u = encodeURIComponent(st.user);
O.declare('live', {
O.declare('live', {
- src: `${location.origin}/embed/${u}`, title: st.title, live: true, href: `${location.origin}/@${u}`,
+ src: `${location.origin}/embed/${u}`, title: st.title, live: true, sound: !!(st.video || document.getElementById('video-element')) && !(st.video || document.getElementById('video-element')).muted,
+ href: `${location.origin}/@${u}`,
side: { src: `${location.origin}/popout/${u}`, title: `${st.title} \u00b7 chat` },
side: { src: `${location.origin}/popout/${u}`, title: `${st.title} \u00b7 chat` },
});
});
}).catch(() => { /* the overlay host could not load: the mini player still works here */ });
}).catch(() => { /* the overlay host could not load: the mini player still works here */ });
}
}
+ // A direct jump from a playing channel to another OpenVibe site also carries the
+ // stream. The mini player is only created for routes within Live.
+ document.addEventListener('playing', (event) => {
+ if (event.target?.id !== 'video-element' || st.box || !isChannelPath(location.pathname)) return;
+ const match = /^\/@([^/?#]+)/.exec(location.pathname);
+ if (!match) return;
+ st.user = decodeURIComponent(match[1]);
+ st.title = (document.getElementById('ch-display-name')?.textContent || '').trim()
+ || (document.getElementById('ch-stream-title')?.textContent || '').trim() || st.user;
+ declare(true);
+ }, true);
+ document.addEventListener('pause', (event) => {
+ if (event.target?.id === 'video-element' && !st.box) declare(false);
+ }, true);
+ document.addEventListener('volumechange', (event) => {
+ if (event.target?.id === 'video-element' && st.user && isPlaying()) declare(true);
+ }, true);
+
/** The viewer closed it: the player goes away for good. */
/** The viewer closed it: the player goes away for good. */
function closeForGood() {
function closeForGood() {
if (typeof destroyPlayer === 'function') { try { destroyPlayer(); } catch { /* */ } }
if (typeof destroyPlayer === 'function') { try { destroyPlayer(); } catch { /* */ } }
@@ -56,24 +56,29 @@ assert.strictEqual(video.muted, false, 'allowlisted parent can control playback'
@@ -56,24 +56,29 @@ assert.strictEqual(video.muted, false, 'allowlisted parent can control playback'
if (oldDbModule) require.cache[dbPath] = oldDbModule; else delete require.cache[dbPath];
if (oldDbModule) require.cache[dbPath] = oldDbModule; else delete require.cache[dbPath];
if (oldEgressModule) require.cache[egressPath] = oldEgressModule; else delete require.cache[egressPath];
if (oldEgressModule) require.cache[egressPath] = oldEgressModule; else delete require.cache[egressPath];
- const simulate = async (payload) => {
+ const simulate = async (payload, preSound = false) => {
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 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 playerCalls = [];
const sent = [];
const sent = [];
+ let message;
const browser = {
const browser = {
window: { __OV_EMBED_ORIGINS: ['https://openvibe.live'] },
window: { __OV_EMBED_ORIGINS: ['https://openvibe.live'] },
parent: { postMessage(data, target) { sent.push({ data, target }); } }, location: { pathname: '/embed/alice' },
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); },
+ document: { getElementById(id) { return elements[id]; } }, addEventListener(type, fn) { if (type === 'message') message = fn; }, setInterval() {},
+ fetch: async () => ({ ok: true, json: async () => payload }), initPlayer(stream) { playerCalls.push({ stream, muted: elements['video-element'].muted, forceMuted: browser.window.OVChannelEmbedForceMuted }); },
};
};
browser.window.parent = browser.parent;
browser.window.parent = browser.parent;
vm.runInNewContext(clientSource, browser);
vm.runInNewContext(clientSource, browser);
+ if (preSound) message({ origin: 'https://openvibe.live', source: browser.parent, data: { type: 'ov-embed', cmd: 'unmute' } });
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
return { elements, playerCalls, sent };
return { elements, playerCalls, sent };
};
};
const liveResult = await simulate({ channel: { username: 'alice', display_name: 'Alice' }, streams: [{ id: 2, is_live: true, title: 'Public show' }] });
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.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');
assert.strictEqual(liveResult.sent.at(-1).data.live, true, 'live state is announced to the parent');
+ const carried = await simulate({ channel: { username: 'alice' }, streams: [{ id: 4, is_live: true }] }, true);
+ assert.strictEqual(carried.playerCalls[0].forceMuted, false, 'sound intent received before the new stream starts is preserved');
+ assert.strictEqual(carried.playerCalls[0].muted, false, 'the carried stream starts with sound when allowed');
const offlineResult = await simulate({ channel: { username: 'alice', display_name: 'Alice' }, streams: [] });
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');
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 }] });
const gatedResult = await simulate({ channel: { username: 'alice' }, streams: [{ id: 3, is_live: true, is_nsfw: true }] });