openvibers/openvibe.live Commits

A screen share's camera goes out as a stream of its own: its own VOD, shown as the PIP (#393)

OP OpenVibers committed 17 days ago · 2916f325af · 9 files, +409 −4

* A screen share's camera goes out as a stream of its own (its slot, its own VOD, this slot's PIP), or drawn into the screen as before when chosen (for restreams)

* Camera PiP: its slot and go-live are prepared before capture; when either fails the camera is drawn into the screen share as before, never dropped

* The live list leaves out a streamer's own camera as long as it is the PIP of their live stream (it shows inside it); a co-host keeps a tile

Changes

ddiff --git a/docs/broadcasting.md b/docs/broadcasting.md
iindex 9dfdec5e..164b8466 100644
---- a/docs/broadcasting.md
++++ b/docs/broadcasting.md
@@@ -94,6 +94,18 @@ live, and appears within 30 seconds when that stream goes live after the page op
**Limits:** an 18+ stream shows only over another 18+ stream, and a banned streamer's slot shows nowhere.
++**Your camera during a screen share** goes out as a stream of its own by default (screen share settings, **How the
++camera goes out**):
++- It goes live on one of your slots: the first of your idle slots already listed as a PIP here, else a new
++ "<title> camera" slot, added at the corner and size you chose.
++- It gets its own VOD.
++- Viewers can move it or hide it.
++- It plays over the screen share's VOD where it was.
++- It ends with the screen share, or when you turn the camera off.
++
++Restreams to Twitch, YouTube and the like get the screen only. Choose **Drawn into the screen share** to paint the
++camera into the picture as before, so restreams show it too.
++
API: `GET`/`PUT /api/streams/managed/:id/pips` (`{ pips: [{ source_msid, x, y, w, muted }] }`, fractions of the
player), `GET /api/streams/pip-sources?username=` (a streamer's slots), and `GET /api/streams/:id/pips` (a live
stream's PIPs now). The stream objects carry `pip_overlays`.
ddiff --git a/public/features.json b/public/features.json
iindex f6d571e2..9be58311 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -214,6 +214,7 @@
"/js/call.js",
"/js/broadcast-workspace.js",
"/js/broadcast.js",
++ "/js/broadcast-cam-pip.js",
"/js/broadcast-studio.js"
]
},
ddiff --git a/public/js/broadcast-cam-pip.js b/public/js/broadcast-cam-pip.js
nnew file mode 100644
iindex 00000000..5971a327
---- /dev/null
++++ b/public/js/broadcast-cam-pip.js
@@@ -0,0 +1,192 @@
++/* ───────────────────────────────────────────────────────────────
++ broadcast-cam-pip.js — a screen share's camera as a stream of its own (owner, 2026-10-26: "when a browser streamer
++ uses the PIP feature for their camera, the PIP camera should actually be using it's own independent stream … own
++ vods").
++
++ Instead of painting the camera into the screen share (_startPipComposite), the page publishes it to one of the
++ streamer's slots, which is the screen share slot's picture-in-picture (stream_pips). So the camera:
++ - is a live stream of its own: viewers move, resize, mute or hide it over the screen share (stream-pip.js);
++ - is recorded to its own VOD by the server, like any WebRTC stream (recorder.reconcileLiveRecordings);
++ - plays over the screen share's VOD again, where it was (streaming/pip-history.js, openvibe.video).
++ Its slot: the first of the streamer's own slots the screen share slot already lists as a PIP and that is not live,
++ else a new "<title> camera" slot, added as a PIP where the screen share settings put the camera. Restreams (Twitch,
++ YouTube …) get the screen share only; "Drawn into the screen" keeps the old composite for them.
++
++ OVCamPip.wanted(ss) → whether this stream's camera goes out on its own (the setting, a slot, the module)
++ OVCamPip.prepare(ss) → its slot and live stream, before capture; false → broadcast.js draws it in as before
++ OVCamPip.start(ss, camStream) → publish it (or swap its track when already out); resolves true when live
++ OVCamPip.stop(mainStreamId) → end the camera stream and stop its tracks
++ It talks to /ws/broadcast as a broadcaster, like broadcast.js's _handleSfuProduceRequest, with that file's helpers.
++ ─────────────────────────────────────────────────────────────── */
++(function () {
++ 'use strict';
++
++ const SIZES = { small: 0.18, medium: 0.25, large: 0.35 };
++ const MARGIN = 0.02;
++ const sessions = new Map(); // main stream id → { camStreamId, camMsid, ws, transport, producer, stream, closing, retries }
++
++ /** The screen share settings' corner and size as the PIP's place (fractions of the player, the overlay 16:9). */
++ function placeOf(pos, size) {
++ const w = SIZES[size] || SIZES.small;
++ const h = w * 9 / 16;
++ const right = 1 - w - MARGIN, bottom = 1 - h - MARGIN;
++ const p = String(pos || 'bottom-right');
++ return { w, x: p.endsWith('left') ? MARGIN : right, y: p.startsWith('top') ? MARGIN : bottom };
++ }
++
++ function wanted(ss) {
++ const mode = (typeof broadcastState !== 'undefined' && broadcastState.settings && broadcastState.settings.screenCamMode) || 'stream';
++ return mode !== 'overlay' && !!(ss && ss.streamData && ss.streamData.managed_stream_id);
++ }
++
++ /** The camera's slot: an idle one of the streamer's own already listed as a PIP here, else a new one, added as a PIP. */
++ async function cameraSlot(mainMsid, title) {
++ const me = (typeof currentUser !== 'undefined' && currentUser && currentUser.username) || '';
++ const listed = await api(`/streams/managed/${mainMsid}/pips`);
++ const pips = (listed && listed.pips) || [];
++ const own = pips.find((p) => p.username && p.username.toLowerCase() === me.toLowerCase() && !p.live);
++ if (own) return own.source_msid;
++ const created = await api('/streams/managed', {
++ method: 'POST',
++ body: { title: `${String(title || 'Stream').slice(0, 80)} camera`, protocol: 'webrtc', streaming_method: 'browser' },
++ });
++ const msid = created && created.managed_stream && created.managed_stream.id;
++ if (!msid) throw new Error('could not make a camera slot');
++ const s = (typeof broadcastState !== 'undefined' && broadcastState.settings) || {};
++ const rows = pips.map((p) => ({ source_msid: p.source_msid, x: p.defaults.x, y: p.defaults.y, w: p.defaults.w, muted: p.muted }));
++ if (rows.length >= ((listed && listed.max) || 3)) rows.pop(); // the newest camera takes the last place
++ rows.push({ source_msid: msid, ...placeOf(s.screenPipPos, s.screenPipSize), muted: true });
++ await api(`/streams/managed/${mainMsid}/pips`, { method: 'PUT', body: { pips: rows } });
++ return msid;
++ }
++
++ function waitFor(ws, type, ms = 15000) {
++ return new Promise((resolve, reject) => {
++ const timer = setTimeout(() => { ws.removeEventListener('message', on); reject(new Error(`camera: no ${type}`)); }, ms);
++ function on(ev) {
++ let m; try { m = JSON.parse(ev.data); } catch { return; }
++ if (m.type === type) { clearTimeout(timer); ws.removeEventListener('message', on); resolve(m); }
++ else if (m.type === 'sfu-error') { clearTimeout(timer); ws.removeEventListener('message', on); reject(new Error(m.error || 'camera: SFU error')); }
++ }
++ ws.addEventListener('message', on);
++ });
++ }
++
++ /** Broadcaster socket for the camera stream, and its video producer in the SFU. */
++ async function publish(sess) {
++ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
++ const token = localStorage.getItem('token') || '';
++ const ws = new WebSocket(`${proto}://${window.location.host}/ws/broadcast?token=${encodeURIComponent(token)}&streamId=${sess.camStreamId}&role=broadcaster`);
++ sess.ws = ws;
++ await new Promise((res, rej) => { ws.addEventListener('open', res, { once: true }); ws.addEventListener('error', () => rej(new Error('camera: socket failed')), { once: true }); });
++ ws.addEventListener('close', () => { if (!sess.closing) retry(sess); });
++ const send = (m) => ws.readyState === 1 && ws.send(JSON.stringify(m));
++ const mod = await loadRobotStreamerMediasoup();
++ const Device = mod.Device || mod.default?.Device;
++ const device = new Device();
++ send({ type: 'sfu-get-capabilities' });
++ const caps = await waitFor(ws, 'sfu-capabilities');
++ await device.load({ routerRtpCapabilities: caps.rtpCapabilities });
++ send({ type: 'sfu-create-transport' });
++ const tp = await waitFor(ws, 'sfu-transport-created');
++ const transport = device.createSendTransport({ id: tp.id, iceParameters: tp.iceParameters, iceCandidates: tp.iceCandidates,
++ dtlsParameters: tp.dtlsParameters, iceServers: tp.iceServers || [{ urls: 'stun:stun.l.google.com:19302' }] });
++ sess.transport = transport;
++ transport.on('connect', ({ dtlsParameters }, cb, errb) => { send({ type: 'sfu-connect-transport', transportId: transport.id, dtlsParameters }); waitFor(ws, 'sfu-transport-connected').then(() => cb()).catch(errb); });
++ transport.on('produce', ({ kind, rtpParameters }, cb, errb) => { send({ type: 'sfu-produce', transportId: transport.id, kind, rtpParameters }); waitFor(ws, 'sfu-produced').then((m) => cb({ id: m.id })).catch(errb); });
++ const track = sess.stream.getVideoTracks()[0];
++ if (!track) throw new Error('camera: no video track');
++ const opts = typeof sfuVideoProduceOptions === 'function' ? sfuVideoProduceOptions(device, track) : { track, stopTracks: false };
++ try { sess.producer = await transport.produce(opts); } catch (err) {
++ if (!opts.codec) throw err;
++ delete opts.codec;
++ sess.producer = await transport.produce(opts);
++ }
++ if (typeof applyVideoSenderPrefs === 'function') await applyVideoSenderPrefs(sess.producer).catch(() => {});
++ }
++
++ /** A dropped camera socket comes back (three tries, a few seconds apart); the screen share is never touched. */
++ function retry(sess) {
++ if (sess.closing || sess.retries >= 3) return;
++ sess.retries += 1;
++ try { sess.transport && sess.transport.close(); } catch { /* */ }
++ setTimeout(() => { if (!sess.closing) publish(sess).catch((e) => console.warn('[Camera PiP] reconnect failed:', e.message)); }, 2000 * sess.retries);
++ }
++
++ /**
++ * The camera's slot and its live stream, before anything is captured into it: true when the camera can go out on its
++ * own. broadcast.js asks first and draws the camera into the screen share as before when this is false, so a slot or
++ * a go-live that fails never costs the stream its camera.
++ */
++ async function prepare(ss) {
++ const mainId = ss && ss.streamData && ss.streamData.id;
++ if (!mainId) return false;
++ let sess = sessions.get(mainId);
++ if (sess && !sess.closing && sess.camStreamId) return true;
++ if (!sess || sess.closing) {
++ sess = { camStreamId: null, camMsid: null, ws: null, transport: null, producer: null, stream: null, closing: false, retries: 0 };
++ sessions.set(mainId, sess);
++ }
++ try {
++ const title = ss.streamData.title || 'Stream';
++ sess.camMsid = await cameraSlot(ss.streamData.managed_stream_id, title);
++ if (sess.closing) return false;
++ const made = await api('/streams', { method: 'POST', body: { title: `${title} · camera`, protocol: 'webrtc', managed_stream_id: sess.camMsid } });
++ const live = made && (made.stream || made);
++ sess.camStreamId = live && live.id;
++ if (!sess.camStreamId) throw new Error('the camera stream did not start');
++ // Stopped while it was starting: stop() could not end a stream it did not know yet.
++ if (sess.closing) { api(`/streams/${sess.camStreamId}`, { method: 'DELETE' }).catch(() => {}); return false; }
++ return true;
++ } catch (err) {
++ console.warn('[Camera PiP] no stream of its own for the camera:', err.message);
++ if (sessions.get(mainId) === sess) sessions.delete(mainId);
++ return false;
++ }
++ }
++
++ /** Publish the camera into its stream (prepared first if it was not), or swap its track when it already goes out. */
++ async function start(ss, camStream) {
++ const mainId = ss && ss.streamData && ss.streamData.id;
++ if (!mainId || !camStream) return false;
++ let sess = sessions.get(mainId);
++ if (!sess || sess.closing || !sess.camStreamId) {
++ if (!(await prepare(ss))) return false;
++ sess = sessions.get(mainId);
++ }
++ if (sess.producer) {
++ // Already out (a camera switch, the camera turned back on): the same stream, the new track.
++ const track = camStream.getVideoTracks()[0];
++ const old = sess.stream;
++ sess.stream = camStream;
++ try { if (track) await sess.producer.replaceTrack({ track }); } catch (e) { console.warn('[Camera PiP] track swap failed:', e.message); }
++ if (old && old !== camStream) old.getTracks().forEach((t) => t.stop());
++ return true;
++ }
++ sess.stream = camStream;
++ try {
++ await publish(sess);
++ console.log(`[Camera PiP] camera live as stream ${sess.camStreamId} on slot ${sess.camMsid}`);
++ return true;
++ } catch (err) {
++ console.warn('[Camera PiP] the camera could not go out on its own:', err.message);
++ if (typeof toast === 'function') toast(`Your camera could not go live as its own stream (${err.message}). The screen share carries on.`, 'error');
++ stop(mainId);
++ return false;
++ }
++ }
++
++ function stop(mainId) {
++ const sess = sessions.get(mainId);
++ if (!sess) return;
++ sess.closing = true;
++ sessions.delete(mainId);
++ try { sess.producer && sess.producer.close(); } catch { /* */ }
++ try { sess.transport && sess.transport.close(); } catch { /* */ }
++ try { sess.ws && sess.ws.close(); } catch { /* */ }
++ try { sess.stream && sess.stream.getTracks().forEach((t) => t.stop()); } catch { /* */ }
++ if (sess.camStreamId) api(`/streams/${sess.camStreamId}`, { method: 'DELETE' }).catch(() => {});
++ }
++
++ window.OVCamPip = { wanted, prepare, start, stop, placeOf, _sessions: sessions };
++})();
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex eae8115c..ac31bde3 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -447,6 +447,14 @@ function _wsRenderPanel() {
</div>
</div>
<div id="bc-ws-screen-pip-opts" style="${p.screenCam ? '' : 'display:none'}">
++ <div class="form-group" style="margin:6px 0 0">
++ <label style="font-size:0.78rem" for="bc-ws-screen-cam-mode">How the camera goes out</label>
++ <select id="bc-ws-screen-cam-mode" class="form-input form-input-sm" onchange="_wsMarkDirty()">
++ <option value="stream"${(p.screenCamMode || 'stream') === 'stream' ? ' selected' : ''}>Its own stream: its own VOD, viewers move or hide it</option>
++ <option value="overlay"${p.screenCamMode === 'overlay' ? ' selected' : ''}>Drawn into the screen share: restreams show it too</option>
++ </select>
++ <small class="muted" style="font-size:0.74rem">As its own stream, the camera goes live on a slot of its own, shown over this one where you put it below. Twitch, YouTube and other restreams get the screen only.</small>
++ </div>
<div class="bc-ws-row" style="margin-top:6px">
<div class="form-group" style="margin:0;flex:1">
<label style="font-size:0.78rem">Position</label>
@@@ -1553,9 +1561,11 @@ function _wsSyncScreenShareHiddenDefaults(browserMode) {
// Sync PiP position/size to broadcastState for use in _startPipComposite
const pipPos = document.getElementById('bc-ws-screen-pip-pos')?.value || 'bottom-right';
const pipSize = document.getElementById('bc-ws-screen-pip-size')?.value || 'small';
++ const camMode = document.getElementById('bc-ws-screen-cam-mode')?.value || 'stream';
if (typeof broadcastState !== 'undefined') {
broadcastState.settings.screenPipPos = pipPos;
broadcastState.settings.screenPipSize = pipSize;
++ broadcastState.settings.screenCamMode = camMode; // broadcast-cam-pip.js: its own stream, or drawn in
}
}
@@@ -1707,6 +1717,7 @@ async function _wsSaveAll() {
screenSysAudio: document.getElementById('bc-ws-screen-sysaudio')?.checked ?? true,
screenPipPos: document.getElementById('bc-ws-screen-pip-pos')?.value || 'bottom-right',
screenPipSize: document.getElementById('bc-ws-screen-pip-size')?.value || 'small',
++ screenCamMode: document.getElementById('bc-ws-screen-cam-mode')?.value || 'stream',
screenResolution: document.getElementById('bc-ws-screen-resolution')?.value || '1080',
screenFps: document.getElementById('bc-ws-screen-fps')?.value || '30',
screenBitrate: parseInt(document.getElementById('bc-ws-screen-bitrate')?.value) || 3000,
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex c1c0c844..14f4fc0c 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -2126,6 +2126,7 @@ function cleanupStream(streamId) {
stopTrackKeepalive(streamId);
ss._suppressRecovery = true;
_cleanupComposite(ss);
++ if (window.OVCamPip) OVCamPip.stop(streamId); // the camera's own stream ends with this one
if (ss._screenStream) { ss._screenStream.getTracks().forEach(t => t.stop()); ss._screenStream = null; }
if (ss.localStream) { ss.localStream.getTracks().forEach(t => t.stop()); ss.localStream = null; }
_teardownAudioMixer(ss);
@@@ -2874,7 +2875,17 @@ async function startMediaCapture(streamId, opts = {}) {
}
ss._cameraOverlayStream = camStream;
-- if (camStream) {
++ if (camStream && window.OVCamPip && OVCamPip.wanted(ss) && await OVCamPip.prepare(ss)) {
++ // The camera as a stream of its own (broadcast-cam-pip.js): the screen share goes out alone, and the camera
++ // goes live on its own slot, this slot's picture-in-picture, with its own VOD. When its slot or its
++ // go-live fails (prepare), it is drawn into the screen share below, as before.
++ const combined = new MediaStream();
++ screenStream.getVideoTracks().forEach(t => combined.addTrack(t));
++ if (finalAudioTrack) combined.addTrack(finalAudioTrack);
++ ss.localStream = combined;
++ ss._cameraOverlayStream = null; // OVCamPip owns it now, and stops it
++ OVCamPip.start(ss, camStream);
++ } else if (camStream) {
_startPipComposite(ss, screenStream, screenTrack, camStream, finalAudioTrack, res);
} else {
// Camera not available — screen-only
@@@ -2884,7 +2895,8 @@ async function startMediaCapture(streamId, opts = {}) {
ss.localStream = combined;
}
} else {
-- // Screen share without camera overlay
++ // Screen share without camera overlay (a camera that was its own stream ends with it)
++ if (window.OVCamPip) OVCamPip.stop(streamId);
const combined = new MediaStream();
screenStream.getVideoTracks().forEach(t => combined.addTrack(t));
if (finalAudioTrack) combined.addTrack(finalAudioTrack);
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 607f15e1..79a16849 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1257,6 +1257,21 @@ async function setPipsForManagedStream(managedStreamId, pips) {
return list;
}
++/**
++ * Slots live right now as a PIP over another live stream of their own streamer (a screen share's camera,
++ * public/js/broadcast-cam-pip.js): the live list leaves them out, since they show inside that stream. A co-host's stream
++ * shown as a PIP is not among them: it is someone else's channel and keeps its own place.
++ */
++async function getLiveOwnPipSourceSlots() {
++ try {
++ const rows = await all(`SELECT DISTINCT p.source_msid FROM stream_pips p
++ JOIN managed_streams src ON src.id = p.source_msid
++ JOIN streams host ON host.managed_stream_id = p.managed_stream_id AND host.is_live = 1
++ WHERE host.user_id = src.user_id`);
++ return new Set(rows.map((r) => Number(r.source_msid)));
++ } catch { return new Set(); }
++}
++
/** A person's slots, as PIP sources: id, title, slug, and whether each is live now. */
async function getPipSourceSlots(userId) {
try {
@@@ -3657,7 +3672,7 @@ module.exports = {
getManagedStreamBySlug, getManagedStreamByStreamKey, getManagedStreamByIdOrSlug,
updateManagedStream, deleteManagedStream,
getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,
-- getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, MAX_PIPS,
++ getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, getLiveOwnPipSourceSlots, MAX_PIPS,
countManagedStreamsByUser, getManagedStreamLimit,
isValidManagedStreamSlug, isManagedStreamSlugTaken,
ensureStreamerRoleOnFeed,
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 04eba013..6736bdf3 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1143,7 +1143,9 @@ router.get('/', optionalAuth, async (req, res) => {
// the poll period so a card can never look stale for a whole cycle.
res.set('Cache-Control', 'public, max-age=5');
const restreamManager = require('./restream-manager');
-- const streams = await db.getLiveStreams();
++ // A streamer's camera that is the PIP of their own live stream shows inside it, not as a stream of its own.
++ const insidePip = await db.getLiveOwnPipSourceSlots();
++ const streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id))));
const channelMap = await db.getChannelsByUserIds(streams.map(s => s.user_id)); // one query, not N
const enriched = await Promise.all(streams.map(async (s) => {
const channel = channelMap[s.user_id] || null;
ddiff --git a/test/broadcast-cam-pip.test.js b/test/broadcast-cam-pip.test.js
nnew file mode 100644
iindex 00000000..6381f29a
---- /dev/null
++++ b/test/broadcast-cam-pip.test.js
@@@ -0,0 +1,145 @@
++'use strict';
++/**
++ * A screen share's camera as a stream of its own (owner, 2026-10-26: "when a browser streamer uses the PIP feature for
++ * their camera, the PIP camera should actually be using it's own independent stream … own vods"). public/js/
++ * broadcast-cam-pip.js, run here in a VM with a stub api():
++ * - the camera's slot: an idle one of the streamer's own already listed as this slot's PIP, else a new "<title> camera"
++ * slot added as a PIP where the screen share settings put the camera (the newest camera takes the last place when
++ * the list is full);
++ * - it goes live on that slot; if it cannot, the camera stream it started is ended and the streamer is told;
++ * - broadcast.js sends the camera out on its own when the setting says so (the default) and the stream has a slot, and
++ * ends it with the stream or when the camera is turned off.
++ *
++ * node test/broadcast-cam-pip.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
++const SRC = read('public/js/broadcast-cam-pip.js');
++
++function load({ pips = [], max = 3, settings = {}, failSocket = true, onStreamPost = null, fail = null } = {}) {
++ const calls = [];
++ const toasts = [];
++ let nextSlot = 77;
++ const api = async (p, opts = {}) => {
++ calls.push([opts.method || 'GET', p, opts.body ? JSON.parse(JSON.stringify(opts.body)) : null]); // a copy in this realm (the VM's objects compare unequal)
++ if (p.endsWith('/pips') && !opts.method) return { pips, max };
++ if (p.endsWith('/pips') && opts.method === 'PUT') return { pips: opts.body.pips, max };
++ if (p === '/streams/managed' && opts.method === 'POST') { if (fail === 'slot') throw new Error('You have reached your slot limit'); return { managed_stream: { id: nextSlot++ } }; }
++ if (p === '/streams' && opts.method === 'POST') { if (onStreamPost) onStreamPost(); if (fail === 'go-live') return {}; return { stream: { id: 9001 } }; }
++ if (opts.method === 'DELETE') return { ok: true };
++ throw new Error(`unexpected ${p}`);
++ };
++ class FakeSocket {
++ constructor(url) { this.url = url; this.listeners = {}; calls.push(['WS', url]); setTimeout(() => (this.listeners[failSocket ? 'error' : 'open'] || []).forEach((f) => f()), 5); }
++ addEventListener(t, f) { (this.listeners[t] = this.listeners[t] || []).push(f); }
++ send() {} close() {}
++ }
++ const window = { location: { protocol: 'https:', host: 'openvibe.live' } };
++ const ctx = vm.createContext({
++ window, api, WebSocket: FakeSocket, console: { log() {}, warn() {} }, setTimeout, clearTimeout,
++ localStorage: { getItem: () => 'tok_abc' }, toast: (m, k) => toasts.push([m, k]),
++ currentUser: { username: 'Goosely' }, broadcastState: { settings }, encodeURIComponent,
++ });
++ vm.runInContext(SRC, ctx);
++ return { cam: window.OVCamPip, calls, toasts };
++}
++const track = () => ({ stopped: false, stop() { this.stopped = true; } });
++const camStream = () => { const t = track(); return { t, getVideoTracks: () => [t], getTracks: () => [t] }; };
++const ss = (msid = 10) => ({ streamData: { id: 600, managed_stream_id: msid, title: 'Coding' } });
++const tick = () => new Promise((r) => setTimeout(r, 30));
++
++(async () => {
++ // Where it starts: the screen share settings' corner and size, as fractions (16:9, 0.02 from the edges).
++ {
++ const { cam } = load();
++ assert.deepStrictEqual({ ...cam.placeOf('bottom-right', 'small') }, { w: 0.18, x: 0.8, y: 1 - 0.18 * 9 / 16 - 0.02 });
++ assert.deepStrictEqual({ ...cam.placeOf('top-left', 'large') }, { w: 0.35, x: 0.02, y: 0.02 });
++ assert.strictEqual(cam.wanted(ss()), true, 'its own stream by default');
++ assert.strictEqual(cam.wanted({ streamData: { id: 1, managed_stream_id: null } }), false, 'not without a slot');
++ assert.strictEqual(load({ settings: { screenCamMode: 'overlay' } }).cam.wanted(ss()), false, '"drawn into the screen" keeps the composite');
++ }
++
++ // An idle slot of the streamer's own already listed as a PIP is the camera's.
++ {
++ const pips = [{ source_msid: 12, username: 'cohost', live: false, defaults: { x: 0, y: 0, w: 0.2 }, muted: true },
++ { source_msid: 11, username: 'goosely', live: true, defaults: { x: 0, y: 0, w: 0.2 }, muted: true },
++ { source_msid: 13, username: 'goosely', live: false, defaults: { x: 0.5, y: 0.5, w: 0.25 }, muted: true }];
++ const { cam, calls, toasts } = load({ pips });
++ const s = camStream();
++ assert.strictEqual(await cam.start(ss(), s), false, 'the fake socket fails');
++ assert.deepStrictEqual(calls.filter((c) => c[0] !== 'WS').map((c) => [c[0], c[1], c[2] ? c[2].managed_stream_id : undefined]), [
++ ['GET', '/streams/managed/10/pips', undefined],
++ ['POST', '/streams', 13],
++ ['DELETE', '/streams/9001', undefined],
++ ], "its own idle PIP slot (not the live one, not someone else's); no new slot; the camera stream it started is ended when it cannot publish");
++ assert.match(calls.find((c) => c[0] === 'WS')[1], /^wss:\/\/openvibe\.live\/ws\/broadcast\?token=tok_abc&streamId=9001&role=broadcaster$/);
++ assert.ok(s.t.stopped, 'the camera is let go');
++ assert.match(toasts[0][0], /could not go live as its own stream .* The screen share carries on\./);
++ assert.strictEqual(cam._sessions.size, 0);
++ }
++
++ // None: a new "<title> camera" slot, added as a PIP where the settings put the camera.
++ {
++ const pips = [{ source_msid: 12, username: 'cohost', live: false, defaults: { x: 0.02, y: 0.02, w: 0.2 }, muted: false }];
++ const { cam, calls } = load({ pips, settings: { screenPipPos: 'top-right', screenPipSize: 'medium' } });
++ await cam.start(ss(), camStream());
++ const made = calls.find((c) => c[1] === '/streams/managed');
++ assert.deepStrictEqual(made[2], { title: 'Coding camera', protocol: 'webrtc', streaming_method: 'browser' });
++ const put = calls.find((c) => c[0] === 'PUT');
++ assert.deepStrictEqual(put[2].pips.map((p) => [p.source_msid, p.w, p.muted]), [[12, 0.2, false], [77, 0.25, true]], 'the PIPs it had, then the camera');
++ assert.deepStrictEqual([put[2].pips[1].x, put[2].pips[1].y], [1 - 0.25 - 0.02, 0.02], 'top right, medium');
++ assert.deepStrictEqual(calls.find((c) => c[1] === '/streams')[2], { title: 'Coding · camera', protocol: 'webrtc', managed_stream_id: 77 });
++ }
++
++ // Its slot or its go-live fails: prepare says false, nothing is left half started (no socket, no stream to end), and
++ // broadcast.js draws the camera into the screen share as before.
++ for (const fail of ['slot', 'go-live']) {
++ const { cam, calls } = load({ fail });
++ assert.strictEqual(await cam.prepare(ss()), false, `${fail} refused`);
++ assert.strictEqual(cam._sessions.size, 0);
++ assert.ok(!calls.some((c) => c[0] === 'WS' || c[0] === 'DELETE'), `${fail}: nothing to undo`);
++ }
++
++ // A full list: the newest camera takes the last place.
++ {
++ const pips = [1, 2, 3].map((i) => ({ source_msid: 20 + i, username: 'other', live: false, defaults: { x: 0, y: 0, w: 0.2 }, muted: true }));
++ const { cam, calls } = load({ pips });
++ await cam.start(ss(), camStream());
++ assert.deepStrictEqual(calls.find((c) => c[0] === 'PUT')[2].pips.map((p) => p.source_msid), [21, 22, 77]);
++ }
++
++ // Stopped while it was starting: early, no camera stream is started at all; during the go-live request, the stream
++ // it then started is ended.
++ {
++ const early = load({ failSocket: false });
++ const p1 = early.cam.start(ss(), camStream());
++ early.cam.stop(600);
++ assert.strictEqual(await p1, false);
++ assert.ok(!early.calls.some((c) => c[1] === '/streams' && c[0] === 'POST'), 'stopped early: nothing goes live');
++ let late = null;
++ late = load({ failSocket: false, onStreamPost: () => late.cam.stop(600) });
++ assert.strictEqual(await late.cam.start(ss(), camStream()), false);
++ await tick();
++ assert.ok(late.calls.some((c) => c[0] === 'DELETE' && c[1] === '/streams/9001'), 'stopped during go-live: never left live');
++ assert.ok(!late.calls.some((c) => c[0] === 'WS'), 'and never published');
++ }
++
++ // broadcast.js: the camera goes out on its own when wanted, the composite otherwise; it ends with the stream.
++ {
++ const b = read('public/js/broadcast.js');
++ assert.ok(/if \(camStream && window\.OVCamPip && OVCamPip\.wanted\(ss\) && await OVCamPip\.prepare\(ss\)\) \{[\s\S]{0,900}OVCamPip\.start\(ss, camStream\);\n\s+\} else if \(camStream\) \{\n\s+_startPipComposite\(/.test(b),
++ 'its own stream when its slot and go-live are ready, drawn in as before otherwise');
++ assert.ok(/\/\/ Screen share without camera overlay \(a camera that was its own stream ends with it\)\n\s+if \(window\.OVCamPip\) OVCamPip\.stop\(streamId\);/.test(b), 'turning the camera off ends its stream');
++ assert.ok(/_cleanupComposite\(ss\);\n\s+if \(window\.OVCamPip\) OVCamPip\.stop\(streamId\);/.test(b), 'ending the stream ends the camera');
++ const feats = JSON.parse(read('public/features.json')).features.broadcast.js;
++ assert.ok(feats.indexOf('/js/broadcast-cam-pip.js') === feats.indexOf('/js/broadcast.js') + 1, 'loads right after broadcast.js (its helpers)');
++ const ws = read('public/js/broadcast-workspace.js');
++ assert.ok(ws.includes('id="bc-ws-screen-cam-mode"') && ws.includes("screenCamMode: document.getElementById('bc-ws-screen-cam-mode')?.value || 'stream',"), 'the choice is in the screen share settings and saved');
++ }
++
++ console.log('camera PiP as its own stream: slot, go-live, failure and stop paths, and broadcast.js wiring all pass');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/pip-camera-slot.test.js b/test/pip-camera-slot.test.js
iindex 16712008..2dbad814 100644
---- a/test/pip-camera-slot.test.js
++++ b/test/pip-camera-slot.test.js
@@@ -259,6 +259,21 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
console.log('OK O: the viewer draws every live PIP, polls, and plays WebRTC and RTMP sources');
}
++// ── P: the live list: a streamer's camera that is the PIP of their own live stream shows inside it, not as a tile ──
++{
++ // Slot 10 (owner, live as 502) lists 11 (owner's webcam, live as 500) and 12 (someone else's, live as 501).
++ const app = express();
++ app.use('/api/streams', require('../server/streaming/routes'));
++ const server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
++ try {
++ const ids = async () => (await (await fetch(`http://127.0.0.1:${server.address().port}/api/streams`)).json()).streams.map((x) => Number(x.id)).sort();
++ assert.deepStrictEqual(await ids(), [501, 502], "the owner's webcam (500) is inside the screen share; the co-host (501) keeps a tile");
++ await db.run('UPDATE streams SET is_live = 0 WHERE id = 502');
++ assert.deepStrictEqual(await ids(), [500, 501], 'once the screen share ends, the webcam is a stream like any other');
++ console.log("OK P: the live list leaves out a streamer's own camera as long as it is the PIP of their live stream");
++ } finally { server.close(); }
++}
++
console.log('✅ PiP camera slot test passed');
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });