openvibers/openvibe.live Commits

Carry playing live streams directly across sites (#802)

OP OpenVibers committed 2 days ago · 82d3dcbe18 · 3 files, +41 −8

Changes

ddiff --git a/public/js/channel-embed.js b/public/js/channel-embed.js
iindex 4fe5dc89..7a319d63 100644
---- a/public/js/channel-embed.js
++++ b/public/js/channel-embed.js
@@@ -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);
};
ddiff --git a/public/js/ov-mini-player.js b/public/js/ov-mini-player.js
iindex 0efb9bc6..be89c0b7 100644
---- a/public/js/ov-mini-player.js
++++ b/public/js/ov-mini-player.js
@@@ -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 { /* */ } }
ddiff --git a/test/channel-embed.test.js b/test/channel-embed.test.js
iindex ed8f2ffe..37084850 100644
---- a/test/channel-embed.test.js
++++ b/test/channel-embed.test.js
@@@ -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 }] });