openvibers/openvibe.live Commits

PiP camera: a slot that overlays another slot, movable per viewer

OP OpenVibers committed 19 Aug 2026 · bc1ee5a041 · 9 files, +675 −1

Adds a picture-in-picture camera that is an ordinary stream in its own right
rather than a second track welded into the screen share. A slot can point at
another of the owner's slots (managed_streams.pip_source_msid), and viewers of
the first stream get the second one as a draggable, resizable overlay.

Modelling it as a slot is the whole point: the camera inherits everything the
platform already does per stream — its own VOD, its own clips, its own
transcript, its own restreams — with no special-casing anywhere, and viewers can
move and resize it independently because it is a separate video element rather
than pixels burned into the screen-share canvas. It also means the source does
not have to be the streamer's own webcam: any slot they own works, so a second
angle or a co-host's slot can be overlaid the same way.

Server:
  - pip_source_msid + pip_defaults on managed_streams, with the resolver
    returning the CURRENTLY LIVE session of the target slot or null.
  - PUT /streams/managed/:id validates the reference: self-reference is refused
    (it would ask the player to render a stream inside itself) and cross-owner
    references are refused.
  - GET /streams/:id exposes the resolved overlay, so the player needs no extra
    round trip and simply renders nothing when the camera is offline.

Viewer (stream-pip.js):
  - Its own websocket, mediasoup recv transport and consumers. It deliberately
    does not reuse stream-player.js, which is built around module-level
    singletons and cannot be instantiated twice.
  - Geometry is stored as fractions of the player box, so the overlay keeps its
    relative place at any window size and inside fullscreen, and is remembered
    per stream so a viewer who drags it aside finds it there next time.
  - Defaults to muted: the stream underneath already carries the broadcast
    audio and two live tracks would double every word.
  - A camera going offline degrades to a badge; hiding it leaves a restore pill.
    Neither can interrupt the stream underneath.

Broadcaster: a "Picture-in-picture camera" picker in slot settings listing the
owner's other slots.

Co-Authored-By: Claude Opus 5 <[email protected]>

Changes

ddiff --git a/public/css/broadcast.css b/public/css/broadcast.css
iindex 1989279e..ebc7c300 100644
---- a/public/css/broadcast.css
++++ b/public/css/broadcast.css
@@@ -4152,3 +4152,11 @@
color: #f59e0b; display: flex; gap: 7px; align-items: flex-start;
}
.bc-cap-warn i { margin-top: 2px; flex-shrink: 0; }
++
++/* Explanatory hint under a slot setting (e.g. the picture-in-picture camera picker). */
++.bc-ws-hint {
++ margin: 5px 0 0;
++ font-size: .76rem;
++ line-height: 1.45;
++ color: var(--text-muted, #9aa3b2);
++}
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 14ea2114..cfd59c38 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -13564,3 +13564,87 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
.paste-md-body h1.md-h { font-size: 1.5rem; }
.paste-md-body h2.md-h { font-size: 1.25rem; }
}
++
++/* ── Picture-in-picture camera overlay (stream-pip.js) ──────────────
++ Positioned in FRACTIONS of .video-container so the overlay keeps its relative
++ place at any window size and inside fullscreen, where the container is the
++ fullscreen element and these percentages still resolve against it. */
++
++.ov-pip {
++ position: absolute;
++ z-index: 25; /* above the video, below the NSFW gate (30) */
++ aspect-ratio: 16 / 9;
++ border-radius: 10px;
++ overflow: hidden;
++ background: #000;
++ border: 1px solid rgba(255, 255, 255, .22);
++ box-shadow: 0 6px 22px rgba(0, 0, 0, .55);
++ cursor: grab;
++ touch-action: none; /* pointer events drive drag/resize ourselves */
++ transition: box-shadow .15s ease, border-color .15s ease;
++}
++.ov-pip:hover { border-color: rgba(255, 255, 255, .38); }
++.ov-pip.is-dragging { cursor: grabbing; box-shadow: 0 10px 30px rgba(0, 0, 0, .7); }
++.ov-pip.is-dragging, .ov-pip.is-resizing { user-select: none; }
++.ov-pip.is-hidden { display: none; }
++
++.ov-pip-video { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
++
++.ov-pip-bar {
++ position: absolute; inset: 0 0 auto 0;
++ display: flex; align-items: center; justify-content: space-between; gap: 6px;
++ padding: 5px 7px;
++ background: linear-gradient(180deg, rgba(0,0,0,.72), rgba(0,0,0,0));
++ opacity: 0; transition: opacity .15s ease;
++ pointer-events: none;
++}
++.ov-pip:hover .ov-pip-bar, .ov-pip.is-dragging .ov-pip-bar { opacity: 1; pointer-events: auto; }
++.ov-pip-title {
++ font-size: .72rem; color: #fff; font-weight: 600;
++ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
++ text-shadow: 0 1px 3px rgba(0,0,0,.8);
++}
++.ov-pip-title i { opacity: .85; margin-right: 4px; }
++.ov-pip-actions { display: flex; gap: 3px; flex-shrink: 0; }
++.ov-pip-actions button {
++ background: rgba(0,0,0,.45); border: none; color: #fff;
++ width: 22px; height: 22px; border-radius: 5px; cursor: pointer;
++ font-size: .68rem; display: grid; place-items: center;
++ transition: background .12s ease;
++}
++.ov-pip-actions button:hover { background: rgba(255,255,255,.24); }
++
++.ov-pip-resize {
++ position: absolute; right: 0; bottom: 0;
++ width: 18px; height: 18px; cursor: nwse-resize;
++ background:
++ linear-gradient(135deg, transparent 50%, rgba(255,255,255,.55) 50%, rgba(255,255,255,.55) 62%,
++ transparent 62%, transparent 74%, rgba(255,255,255,.55) 74%, rgba(255,255,255,.55) 86%, transparent 86%);
++ opacity: 0; transition: opacity .15s ease;
++}
++.ov-pip:hover .ov-pip-resize { opacity: 1; }
++
++.ov-pip-offline {
++ position: absolute; inset: 0;
++ display: none; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
++ background: rgba(10,12,20,.9); color: var(--text-muted, #9aa3b2); font-size: .72rem;
++}
++.ov-pip.is-offline .ov-pip-offline { display: flex; }
++.ov-pip-offline i { font-size: 1.1rem; opacity: .7; }
++
++/* Shown in place of the overlay once the viewer hides it, so it is recoverable. */
++.ov-pip-restore {
++ position: absolute; right: 12px; bottom: 12px; z-index: 25;
++ display: flex; align-items: center; gap: 6px;
++ padding: 6px 11px; border-radius: 999px; cursor: pointer;
++ background: rgba(0,0,0,.62); border: 1px solid rgba(255,255,255,.18);
++ color: #fff; font-size: .74rem;
++ backdrop-filter: blur(3px);
++}
++.ov-pip-restore:hover { background: rgba(0,0,0,.8); }
++
++@media (max-width: 640px) {
++ /* Fingers need a bigger target than a hover-revealed 18px corner. */
++ .ov-pip-resize { width: 26px; height: 26px; opacity: .8; }
++ .ov-pip-bar { opacity: 1; pointer-events: auto; }
++}
ddiff --git a/public/index.html b/public/index.html
iindex 0f64e7b7..95648552 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -4972,6 +4972,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/app.js?v=125" defer></script>
<script src="/js/stream-player.js?v=20" defer></script>
++<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex d335f584..23e90a3b 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -944,6 +944,23 @@ function _wsRenderPanel() {
</label>
</div>
</div>
++ <div class="bc-ws-row">
++ <div class="form-group" style="flex:1">
++ <label>Picture-in-picture camera</label>
++ <select id="bc-ws-pip-source" class="form-input" onchange="_wsSlotSettingChanged()">
++ <option value="">None</option>
++ ${(_wsState.managedStreams || []).filter(o => o.id !== ms.id).map(o =>
++ `<option value="${o.id}"${String(ms.pip_source_msid || '') === String(o.id) ? ' selected' : ''}>${esc(o.title || ('Slot ' + o.id))}</option>`
++ ).join('')}
++ </select>
++ <p class="bc-ws-hint">
++ Viewers of this stream see the chosen slot's live stream as a movable,
++ resizable overlay. It stays a stream in its own right — its own VOD,
++ clips and restreams — so it can be your webcam, a second angle, or
++ someone else's slot entirely.
++ </p>
++ </div>
++ </div>
<div class="bc-ws-row">
<div class="form-group" style="flex:1">
<label>Default VOD Visibility</label>
@@@ -1869,6 +1886,8 @@ async function _wsSaveAll() {
weather_zip: ms.weather_zip || null,
weather_detail: ms.weather_detail || 'basic',
weather_show_location: ms.weather_show_location || 0,
++ // '' clears the overlay; the server validates ownership and rejects self-reference.
++ pip_source_msid: document.getElementById('bc-ws-pip-source')?.value || null,
};
const profileBlob = {
ddiff --git a/public/js/stream-pip.js b/public/js/stream-pip.js
nnew file mode 100644
iindex 00000000..0acf130c
---- /dev/null
++++ b/public/js/stream-pip.js
@@@ -0,0 +1,337 @@
++/* ───────────────────────────────────────────────────────────────
++ stream-pip.js — viewer-side picture-in-picture camera overlay.
++
++ The camera is NOT a second track inside the screen-share stream. It is an
++ ordinary stream published to its own slot, which is what lets it inherit
++ everything the platform already does per stream — its own VOD, its own clips,
++ its own transcript, its own restreams — and what lets the viewer move and
++ resize it independently of the screen share underneath.
++
++ So this module is a second, self-contained viewer session: its own websocket,
++ its own mediasoup recv transport, its own consumers. It deliberately does not
++ reuse stream-player.js's machinery, because that is written around
++ module-level singletons (player / playerType / streamRef) and cannot be
++ instantiated twice.
++
++ Geometry is stored as FRACTIONS of the player box so the overlay lands in the
++ same relative place on any window size, and is remembered per stream so a
++ viewer who drags it out of the way finds it there next time.
++ ─────────────────────────────────────────────────────────────── */
++
++(function () {
++ 'use strict';
++
++ const LS_KEY = (streamId) => `ov-pip-geom-${streamId}`;
++ const MIN_W = 0.08, MAX_W = 0.60;
++
++ let state = null; // { streamId, srcStreamId, ws, device, transport, consumers, el, geom, gen }
++ let gen = 0;
++
++ /* ── geometry ─────────────────────────────────────────────── */
++
++ function loadGeom(streamId, defaults) {
++ const fallback = {
++ x: clamp(defaults?.x, 0, 1, 0.72),
++ y: clamp(defaults?.y, 0, 1, 0.70),
++ w: clamp(defaults?.w, MIN_W, MAX_W, 0.25),
++ hidden: false, muted: true,
++ };
++ try {
++ const raw = JSON.parse(localStorage.getItem(LS_KEY(streamId)) || 'null');
++ if (!raw) return fallback;
++ return {
++ x: clamp(raw.x, 0, 1, fallback.x),
++ y: clamp(raw.y, 0, 1, fallback.y),
++ w: clamp(raw.w, MIN_W, MAX_W, fallback.w),
++ hidden: !!raw.hidden,
++ // Default to muted: the screen share underneath already carries the
++ // broadcast audio, and two live audio tracks would double every word.
++ muted: raw.muted !== false,
++ };
++ } catch { return fallback; }
++ }
++
++ function saveGeom() {
++ if (!state) return;
++ try { localStorage.setItem(LS_KEY(state.streamId), JSON.stringify(state.geom)); } catch { /* */ }
++ }
++
++ function clamp(v, lo, hi, dflt) {
++ const n = Number(v);
++ if (!Number.isFinite(n)) return dflt;
++ return Math.min(hi, Math.max(lo, n));
++ }
++
++ function applyGeom() {
++ if (!state?.el) return;
++ const { x, y, w, hidden } = state.geom;
++ const box = state.el;
++ box.style.left = `${x * 100}%`;
++ box.style.top = `${y * 100}%`;
++ box.style.width = `${w * 100}%`;
++ box.classList.toggle('is-hidden', !!hidden);
++ }
++
++ /* ── DOM ──────────────────────────────────────────────────── */
++
++ function build(container, label) {
++ const el = document.createElement('div');
++ el.className = 'ov-pip';
++ el.innerHTML = `
++ <video class="ov-pip-video" autoplay playsinline muted></video>
++ <div class="ov-pip-bar">
++ <span class="ov-pip-title" title="${escapeHtml(label)}"><i class="fa-solid fa-video"></i> ${escapeHtml(label)}</span>
++ <span class="ov-pip-actions">
++ <button type="button" data-act="mute" title="Unmute camera"><i class="fa-solid fa-volume-xmark"></i></button>
++ <button type="button" data-act="reset" title="Reset position"><i class="fa-solid fa-arrows-to-dot"></i></button>
++ <button type="button" data-act="hide" title="Hide camera"><i class="fa-solid fa-eye-slash"></i></button>
++ </span>
++ </div>
++ <div class="ov-pip-resize" title="Drag to resize"></div>
++ <div class="ov-pip-offline"><i class="fa-solid fa-video-slash"></i><span>Camera offline</span></div>`;
++ container.appendChild(el);
++
++ const showRestore = () => {
++ let pill = container.querySelector('.ov-pip-restore');
++ if (pill) return pill;
++ pill = document.createElement('button');
++ pill.type = 'button';
++ pill.className = 'ov-pip-restore';
++ pill.innerHTML = '<i class="fa-solid fa-video"></i> Show camera';
++ pill.addEventListener('click', () => { state.geom.hidden = false; saveGeom(); applyGeom(); pill.remove(); });
++ container.appendChild(pill);
++ return pill;
++ };
++
++ el.querySelector('.ov-pip-actions').addEventListener('click', (e) => {
++ const act = e.target.closest('[data-act]')?.dataset.act;
++ if (!act) return;
++ e.stopPropagation();
++ if (act === 'hide') { state.geom.hidden = true; saveGeom(); applyGeom(); showRestore(); }
++ else if (act === 'reset') {
++ const d = state.defaults || {};
++ state.geom.x = clamp(d.x, 0, 1, 0.72);
++ state.geom.y = clamp(d.y, 0, 1, 0.70);
++ state.geom.w = clamp(d.w, MIN_W, MAX_W, 0.25);
++ saveGeom(); applyGeom();
++ } else if (act === 'mute') {
++ state.geom.muted = !state.geom.muted;
++ const v = el.querySelector('.ov-pip-video');
++ v.muted = state.geom.muted;
++ if (!state.geom.muted) v.play().catch(() => {});
++ const btn = e.target.closest('[data-act="mute"]');
++ btn.title = state.geom.muted ? 'Unmute camera' : 'Mute camera';
++ btn.innerHTML = `<i class="fa-solid ${state.geom.muted ? 'fa-volume-xmark' : 'fa-volume-high'}"></i>`;
++ saveGeom();
++ }
++ });
++
++ wireDrag(el, container);
++ wireResize(el, container);
++ if (state?.geom?.hidden) showRestore();
++ return el;
++ }
++
++ /** Pointer-driven move, constrained so the overlay can never be dragged off-screen. */
++ function wireDrag(el, container) {
++ let start = null;
++ const onDown = (e) => {
++ if (e.target.closest('[data-act]') || e.target.closest('.ov-pip-resize')) return;
++ const r = container.getBoundingClientRect();
++ start = { px: e.clientX, py: e.clientY, x: state.geom.x, y: state.geom.y, rw: r.width, rh: r.height };
++ el.classList.add('is-dragging');
++ el.setPointerCapture?.(e.pointerId);
++ e.preventDefault();
++ };
++ const onMove = (e) => {
++ if (!start) return;
++ const box = el.getBoundingClientRect();
++ const r = container.getBoundingClientRect();
++ const maxX = 1 - (box.width / r.width);
++ const maxY = 1 - (box.height / r.height);
++ state.geom.x = Math.min(Math.max(0, start.x + (e.clientX - start.px) / start.rw), Math.max(0, maxX));
++ state.geom.y = Math.min(Math.max(0, start.y + (e.clientY - start.py) / start.rh), Math.max(0, maxY));
++ applyGeom();
++ };
++ const onUp = (e) => {
++ if (!start) return;
++ start = null;
++ el.classList.remove('is-dragging');
++ el.releasePointerCapture?.(e.pointerId);
++ saveGeom();
++ };
++ el.addEventListener('pointerdown', onDown);
++ el.addEventListener('pointermove', onMove);
++ el.addEventListener('pointerup', onUp);
++ el.addEventListener('pointercancel', onUp);
++ }
++
++ function wireResize(el, container) {
++ const handle = el.querySelector('.ov-pip-resize');
++ let start = null;
++ handle.addEventListener('pointerdown', (e) => {
++ const r = container.getBoundingClientRect();
++ start = { px: e.clientX, w: state.geom.w, rw: r.width };
++ el.classList.add('is-resizing');
++ handle.setPointerCapture?.(e.pointerId);
++ e.preventDefault(); e.stopPropagation();
++ });
++ handle.addEventListener('pointermove', (e) => {
++ if (!start) return;
++ state.geom.w = clamp(start.w + (e.clientX - start.px) / start.rw, MIN_W, MAX_W, start.w);
++ // Keep it inside the player as it grows.
++ const box = el.getBoundingClientRect(), r = container.getBoundingClientRect();
++ state.geom.x = Math.min(state.geom.x, Math.max(0, 1 - box.width / r.width));
++ state.geom.y = Math.min(state.geom.y, Math.max(0, 1 - box.height / r.height));
++ applyGeom();
++ });
++ const end = (e) => {
++ if (!start) return;
++ start = null;
++ el.classList.remove('is-resizing');
++ handle.releasePointerCapture?.(e.pointerId);
++ saveGeom();
++ };
++ handle.addEventListener('pointerup', end);
++ handle.addEventListener('pointercancel', end);
++ }
++
++ function escapeHtml(s) {
++ return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++ }
++
++ /* ── viewer session ───────────────────────────────────────── */
++
++ function waitFor(ws, type, timeoutMs = 15000) {
++ return new Promise((resolve, reject) => {
++ const timer = setTimeout(() => { ws.removeEventListener('message', on); reject(new Error(`pip: timeout ${type}`)); }, timeoutMs);
++ 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' || m.type === 'sfu-source-unavailable') {
++ clearTimeout(timer); ws.removeEventListener('message', on); reject(new Error(m.error || 'pip: source unavailable'));
++ }
++ }
++ ws.addEventListener('message', on);
++ });
++ }
++
++ async function connect(srcStreamId, videoEl, myGen) {
++ const host = window.location.hostname;
++ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
++ const port = window.location.port ? `:${window.location.port}` : '';
++ const token = localStorage.getItem('token') || '';
++ const ws = new WebSocket(`${proto}://${host}${port}/ws/broadcast?streamId=${srcStreamId}&role=viewer&token=${token}`);
++ if (state) state.ws = ws;
++
++ await new Promise((res, rej) => {
++ ws.addEventListener('open', res, { once: true });
++ ws.addEventListener('error', () => rej(new Error('pip: websocket failed')), { once: true });
++ });
++ if (myGen !== gen) { try { ws.close(); } catch { /* */ } return; }
++
++ ws.send(JSON.stringify({ type: 'watch' }));
++ const ready = await waitFor(ws, 'sfu-viewer-ready', 20000);
++ if (myGen !== gen) return;
++
++ const mod = await (window.loadMediasoupClient ? window.loadMediasoupClient() : Promise.reject(new Error('pip: mediasoup-client unavailable')));
++ const Device = mod.Device || mod.default?.Device;
++ const device = new Device();
++ await device.load({ routerRtpCapabilities: ready.rtpCapabilities });
++ if (myGen !== gen) return;
++
++ ws.send(JSON.stringify({ type: 'sfu-viewer-create-transport' }));
++ const tp = await waitFor(ws, 'sfu-viewer-transport-created');
++ if (myGen !== gen) return;
++
++ const iceServers = (typeof sanitizeIceServers === 'function' ? sanitizeIceServers(tp.iceServers) : tp.iceServers) || [];
++ const transport = device.createRecvTransport({
++ id: tp.id, iceParameters: tp.iceParameters, iceCandidates: tp.iceCandidates,
++ dtlsParameters: tp.dtlsParameters,
++ iceServers: iceServers.length ? iceServers : [{ urls: 'stun:stun.l.google.com:19302' }],
++ });
++ if (state) { state.device = device; state.transport = transport; }
++
++ transport.on('connect', ({ dtlsParameters }, cb, errb) => {
++ ws.send(JSON.stringify({ type: 'sfu-viewer-connect-transport', transportId: transport.id, dtlsParameters }));
++ waitFor(ws, 'sfu-viewer-transport-connected').then(() => cb()).catch(errb);
++ });
++ transport.on('connectionstatechange', (st) => {
++ if (st === 'failed' || st === 'closed') markOffline(true);
++ });
++
++ const media = new MediaStream();
++ for (const prod of (ready.producers || [])) {
++ ws.send(JSON.stringify({
++ type: 'sfu-viewer-consume', transportId: transport.id,
++ producerId: prod.id, rtpCapabilities: device.rtpCapabilities,
++ }));
++ const cp = await waitFor(ws, 'sfu-viewer-consumed');
++ if (myGen !== gen) return;
++ const consumer = await transport.consume({
++ id: cp.id, producerId: cp.producerId, kind: cp.kind, rtpParameters: cp.rtpParameters,
++ });
++ state?.consumers.push(consumer);
++ media.addTrack(consumer.track);
++ ws.send(JSON.stringify({ type: 'sfu-viewer-resume', consumerId: cp.id }));
++ }
++
++ videoEl.srcObject = media;
++ videoEl.muted = state ? state.geom.muted : true;
++ videoEl.play().catch(() => {});
++ markOffline(false);
++
++ // The camera slot going away should read as "camera offline", not as a broken
++ // overlay — the screen share underneath is unaffected and must keep playing.
++ ws.addEventListener('close', () => { if (myGen === gen) markOffline(true); });
++ }
++
++ function markOffline(off) {
++ if (!state?.el) return;
++ state.el.classList.toggle('is-offline', !!off);
++ }
++
++ /* ── public API ───────────────────────────────────────────── */
++
++ /**
++ * Attach the overlay for a stream, if it has a live PiP camera configured.
++ * Safe to call on every player init; it tears down any previous overlay first.
++ */
++ async function attach(stream) {
++ detach();
++ const pip = stream?.pip_overlay;
++ if (!pip || !pip.live || !pip.stream_id) return;
++
++ const container = document.getElementById('video-container');
++ if (!container) return;
++
++ const myGen = ++gen;
++ state = {
++ streamId: stream.id, srcStreamId: pip.stream_id, ws: null, device: null,
++ transport: null, consumers: [], el: null,
++ defaults: pip.defaults || {}, geom: loadGeom(stream.id, pip.defaults),
++ };
++ state.el = build(container, pip.title || 'Camera');
++ applyGeom();
++
++ try {
++ await connect(pip.stream_id, state.el.querySelector('.ov-pip-video'), myGen);
++ } catch (err) {
++ console.warn('[PiP] Camera overlay unavailable:', err.message);
++ if (myGen === gen) markOffline(true);
++ }
++ }
++
++ function detach() {
++ gen++;
++ if (!state) return;
++ try { state.consumers.forEach(c => { try { c.close(); } catch { /* */ } }); } catch { /* */ }
++ try { state.transport?.close(); } catch { /* */ }
++ try { state.ws?.close(); } catch { /* */ }
++ try { state.el?.remove(); } catch { /* */ }
++ try { document.querySelector('.ov-pip-restore')?.remove(); } catch { /* */ }
++ state = null;
++ }
++
++ window.streamPip = { attach, detach };
++})();
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex 09b66e38..822e57ba 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -900,6 +900,10 @@ function _applyLoaderThumb(stream) {
function initPlayer(stream) {
destroyPlayer();
++ // Camera overlay is a separate viewer session against another slot's live stream;
++ // attach it alongside whichever protocol the main stream uses, and let it fail
++ // quietly — a camera that is offline must never stop the screen share playing.
++ try { window.streamPip?.attach(stream); } catch (e) { console.warn('[PiP] attach failed:', e.message); }
setViewerPausedIntent(false); // fresh stream — always start playing
streamRef = stream; // Store for clip recording across all protocols
_resetPlayerRevealState();
@@@ -3687,6 +3691,9 @@ function getSavedPlayerAudioState() {
/* ── Cleanup ──────────────────────────────────────────────────── */
function destroyPlayer() {
_playerGen++; // invalidate any in-flight WebRTC reconnect/rewatch closures from the old session
++ // The overlay owns a websocket and a mediasoup transport of its own, so it has to be
++ // torn down explicitly — leaving it running would keep consuming a second stream.
++ try { window.streamPip?.detach(); } catch { /* */ }
stopClipRecording();
destroyDVR();
_hideReconnectingIndicator();
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 2e9cfe16..6c679197 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -2294,6 +2294,24 @@ function initDb() {
database.exec("ALTER TABLE managed_streams ADD COLUMN browser_mode TEXT DEFAULT 'camera'");
console.log('[DB] Added browser_mode column to managed_streams');
}
++ if (!msCols2.includes('pip_source_msid')) {
++ // The slot whose live stream should appear as a picture-in-picture overlay on
++ // THIS slot. Modelling the camera as an ordinary slot rather than a second
++ // track inside one stream is what lets it inherit everything the platform
++ // already does per stream — its own VOD, clips, transcript, restreams — and
++ // lets viewers move and resize it independently of the screen share. It is
++ // deliberately a plain slot reference, not "the owner's webcam", so a
++ // streamer can point at a co-host's or moderator's slot too.
++ database.exec('ALTER TABLE managed_streams ADD COLUMN pip_source_msid INTEGER');
++ console.log('[DB] Added pip_source_msid column to managed_streams');
++ }
++ if (!msCols2.includes('pip_defaults')) {
++ // Broadcaster-chosen STARTING geometry for the overlay, as JSON
++ // {x,y,w} in fractions of the player. Viewers can move/resize from there and
++ // their own choice is remembered locally; this is only the default they land on.
++ database.exec("ALTER TABLE managed_streams ADD COLUMN pip_defaults TEXT DEFAULT '{}'");
++ console.log('[DB] Added pip_defaults column to managed_streams');
++ }
if (!msCols2.includes('default_vod_visibility')) {
database.exec("ALTER TABLE managed_streams ADD COLUMN default_vod_visibility TEXT DEFAULT 'public'");
console.log('[DB] Added default_vod_visibility column to managed_streams');
@@@ -3366,13 +3384,16 @@ function updateManagedStream(managedStreamId, userId, fields) {
'default_vod_visibility', 'default_clip_visibility', 'slot_vod_recording_enabled', 'slot_clip_recording_enabled',
'slot_clip_notify_enabled',
'weather_zip', 'weather_detail', 'weather_show_location', 'mic_only_image',
++ 'pip_source_msid', 'pip_defaults',
]);
const updates = [];
const params = [];
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined && allowed.has(key)) {
updates.push(`${key} = ?`);
-- params.push(['tags'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);
++ params.push(['tags', 'pip_defaults'].includes(key)
++ ? (typeof val === 'string' ? val : JSON.stringify(val))
++ : val);
}
}
if (updates.length === 0) return;
@@@ -3381,6 +3402,49 @@ function updateManagedStream(managedStreamId, userId, fields) {
return run(`UPDATE managed_streams SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`, params);
}
++/**
++ * Resolve the picture-in-picture camera overlay for a slot.
++ *
++ * Returns the CURRENTLY LIVE session of the slot this one points at, or null. The
++ * camera is an ordinary slot publishing an ordinary stream, so it already has its own
++ * VOD, clips, transcript and restreams; all the viewer needs is which live stream to
++ * play in the overlay and where to put it by default.
++ *
++ * Self-reference is rejected: a slot pointing at itself would ask the player to render
++ * a stream inside itself.
++ */
++function getPipOverlayForManagedStream(managedStreamId) {
++ try {
++ const ms = get('SELECT id, pip_source_msid, pip_defaults FROM managed_streams WHERE id = ?', [managedStreamId]);
++ if (!ms || !ms.pip_source_msid || ms.pip_source_msid === ms.id) return null;
++ const src = get(`SELECT m.id AS msid, m.title, m.slug, m.user_id,
++ s.id AS stream_id, s.is_live
++ FROM managed_streams m
++ LEFT JOIN streams s ON s.managed_stream_id = m.id AND s.is_live = 1
++ WHERE m.id = ?`, [ms.pip_source_msid]);
++ if (!src) return null;
++ let defaults = {};
++ try { defaults = ms.pip_defaults ? JSON.parse(ms.pip_defaults) : {}; } catch { defaults = {}; }
++ return {
++ source_msid: src.msid,
++ title: src.title || 'Camera',
++ slug: src.slug || null,
++ stream_id: src.stream_id || null,
++ live: !!src.stream_id,
++ defaults,
++ };
++ } catch { return null; }
++}
++
++/** Slots that could serve as a PiP source for this user (everything except `excludeId`). */
++function getPipCandidateSlots(userId, excludeId = null) {
++ try {
++ return all(`SELECT id, title, slug FROM managed_streams
++ WHERE user_id = ? AND (? IS NULL OR id != ?)
++ ORDER BY sort_order ASC, id ASC`, [userId, excludeId, excludeId]);
++ } catch { return []; }
++}
++
function deleteManagedStream(managedStreamId, userId) {
// Unlink sessions first (don't delete them — they're historical)
run('UPDATE streams SET managed_stream_id = NULL WHERE managed_stream_id = ?', [managedStreamId]);
@@@ -7445,6 +7509,7 @@ module.exports = {
getManagedStreamBySlug, getManagedStreamByStreamKey, getManagedStreamByIdOrSlug,
updateManagedStream, deleteManagedStream,
getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,
++ getPipOverlayForManagedStream, getPipCandidateSlots,
countManagedStreamsByUser, getManagedStreamLimit,
isValidManagedStreamSlug, isManagedStreamSlugTaken,
ensureStreamerRoleOnFeed,
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex abb29df2..c8bf16ef 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1571,6 +1571,35 @@ router.put('/managed/:id', requireAuth, (req, res) => {
if (hasOwn(req.body, 'control_config_id')) {
fields.control_config_id = req.body.control_config_id === null ? null : parseInt(req.body.control_config_id);
}
++ if (hasOwn(req.body, 'pip_source_msid')) {
++ // null / '' clears the overlay. Anything else must be one of this user's own
++ // slots and must not be this slot, which would ask the player to render a
++ // stream inside itself.
++ const raw = req.body.pip_source_msid;
++ if (raw === null || raw === '' || raw === undefined) {
++ fields.pip_source_msid = null;
++ } else {
++ const srcId = parseInt(raw, 10);
++ if (!Number.isFinite(srcId)) return res.status(400).json({ error: 'Invalid pip_source_msid' });
++ if (srcId === msId) return res.status(400).json({ error: 'A slot cannot be its own picture-in-picture camera' });
++ const src = db.getManagedStreamById(srcId);
++ if (!src) return res.status(404).json({ error: 'Picture-in-picture slot not found' });
++ if (src.user_id !== ms.user_id) return res.status(403).json({ error: 'That slot belongs to someone else' });
++ fields.pip_source_msid = srcId;
++ }
++ }
++ if (hasOwn(req.body, 'pip_defaults')) {
++ const d = req.body.pip_defaults || {};
++ const num = (v, lo, hi, dflt) => {
++ const n = Number(v);
++ return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt;
++ };
++ // Fractions of the player box, so the overlay lands in the same relative
++ // place regardless of the viewer's window size.
++ fields.pip_defaults = JSON.stringify({
++ x: num(d.x, 0, 1, 0.72), y: num(d.y, 0, 1, 0.70), w: num(d.w, 0.08, 0.6, 0.25),
++ });
++ }
if (hasOwn(req.body, 'sort_order')) {
fields.sort_order = parseInt(req.body.sort_order) || 0;
}
@@@ -1728,6 +1757,16 @@ router.get('/:id', optionalAuth, (req, res) => {
stream.controls = db.getStreamControls(stream.id);
stream.channel = db.getChannelByUserId(stream.user_id) || null;
++ // Picture-in-picture camera overlay: another SLOT of this owner's whose live
++ // stream should be drawn on top of this one. It is a normal stream in its own
++ // right (own VOD, clips, transcript, restreams) — the player only needs to know
++ // which live stream to put in the overlay and where to start it. Null whenever
++ // nothing is configured or the camera slot is not currently live, so the player
++ // simply renders no overlay.
++ stream.pip_overlay = stream.managed_stream_id
++ ? db.getPipOverlayForManagedStream(stream.managed_stream_id)
++ : null;
++
if (req.user) stream.isFollowing = db.isFollowing(req.user.id, stream.user_id);
stream.follower_count = db.getFollowerCount(stream.user_id);
ddiff --git a/test/pip-camera-slot.test.js b/test/pip-camera-slot.test.js
nnew file mode 100644
iindex 00000000..df3c1418
---- /dev/null
++++ b/test/pip-camera-slot.test.js
@@@ -0,0 +1,114 @@
++/**
++ * Tranche 3 contract: the picture-in-picture camera is a SLOT, not a second track.
++ *
++ * Modelling the camera as an ordinary slot publishing an ordinary stream is what lets it
++ * inherit everything the platform already does per stream — its own VOD, clips,
++ * transcript and restreams — and what lets a viewer move and resize it independently of
++ * the screen share underneath. It also means a streamer can point at someone else's slot
++ * (a co-host, a moderator), which a camera welded into their own capture could not do.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const root = path.join(__dirname, '..');
++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
++
++const tmp = path.join(os.tmpdir(), `ov-pip-${Date.now()}.db`);
++process.env.DB_PATH = tmp;
++const db = require('../server/db/database');
++db.initDb();
++
++const Database = require('better-sqlite3');
++{
++ const w = new Database(tmp);
++ w.pragma('foreign_keys = OFF');
++ w.prepare('INSERT OR IGNORE INTO users (id, username) VALUES (1, ?)').run('owner');
++ w.prepare('INSERT OR IGNORE INTO users (id, username) VALUES (2, ?)').run('someone-else');
++ const ms = w.prepare('INSERT INTO managed_streams (id, user_id, title, stream_key) VALUES (?,?,?,?)');
++ ms.run(10, 1, 'Screen', 'key-screen');
++ ms.run(11, 1, 'Webcam', 'key-cam');
++ ms.run(12, 2, 'Other user slot', 'key-other');
++ w.close();
++}
++
++// ── A: schema carries the linkage ────────────────────────────────────────────────────
++const cols = new Database(tmp, { readonly: true }).prepare('PRAGMA table_info(managed_streams)').all().map(c => c.name);
++assert.ok(cols.includes('pip_source_msid'), 'managed_streams needs pip_source_msid');
++assert.ok(cols.includes('pip_defaults'), 'managed_streams needs pip_defaults for the starting geometry');
++console.log('OK A: slot linkage and default geometry columns exist');
++
++// ── B: no overlay until one is configured ────────────────────────────────────────────
++assert.strictEqual(db.getPipOverlayForManagedStream(10), null, 'unconfigured slot must report no overlay');
++console.log('OK B: unconfigured slot reports no overlay');
++
++// ── C: configured but the camera is offline -> resolves, marked not live ─────────────
++db.updateManagedStream(10, 1, { pip_source_msid: 11, pip_defaults: { x: 0.7, y: 0.6, w: 0.3 } });
++let ov = db.getPipOverlayForManagedStream(10);
++assert.ok(ov, 'a configured overlay must resolve');
++assert.strictEqual(ov.source_msid, 11);
++assert.strictEqual(ov.live, false, 'no live session yet');
++assert.strictEqual(ov.stream_id, null, 'nothing for the player to consume yet');
++assert.deepStrictEqual(ov.defaults, { x: 0.7, y: 0.6, w: 0.3 }, 'broadcaster defaults must round-trip');
++console.log('OK C: configured-but-offline resolves with live=false so the player renders nothing');
++
++// ── D: camera goes live -> the player gets a stream id ───────────────────────────────
++{
++ const w = new Database(tmp);
++ w.pragma('foreign_keys = OFF');
++ w.prepare('INSERT INTO streams (id, user_id, managed_stream_id, is_live) VALUES (?,?,?,1)').run(500, 1, 11);
++ w.close();
++}
++ov = db.getPipOverlayForManagedStream(10);
++assert.strictEqual(ov.live, true, 'a live camera slot must report live');
++assert.strictEqual(ov.stream_id, 500, 'the player needs the live stream id to consume');
++console.log('OK D: a live camera slot hands the player a stream id to consume');
++
++// ── E: self-reference is refused — it would ask the player to render itself ──────────
++db.updateManagedStream(11, 1, { pip_source_msid: 11 });
++assert.strictEqual(db.getPipOverlayForManagedStream(11), null, 'a slot pointing at itself must resolve to nothing');
++console.log('OK E: self-reference resolves to no overlay');
++
++// ── F: candidate list excludes the slot being configured ─────────────────────────────
++const cands = db.getPipCandidateSlots(1, 10).map(c => c.id);
++assert.ok(cands.includes(11), 'the owner\'s other slots are candidates');
++assert.ok(!cands.includes(10), 'a slot must not offer itself as its own camera');
++assert.ok(!cands.includes(12), 'another user\'s slot must not appear in this owner\'s list');
++console.log('OK F: candidate list excludes self and other owners');
++
++// ── G: the API validates ownership and self-reference ────────────────────────────────
++const routes = read('server/streaming/routes.js');
++const put = routes.slice(routes.indexOf("router.put('/managed/:id'"), routes.indexOf("router.put('/managed/:id'") + 6000);
++assert.ok(/cannot be its own picture-in-picture camera/.test(put), 'self-reference must be rejected with a clear error');
++assert.ok(/That slot belongs to someone else/.test(put), 'cross-owner references must be rejected');
++assert.ok(/stream\.pip_overlay/.test(routes), 'the viewer endpoint must expose the resolved overlay');
++console.log('OK G: API rejects self-reference and cross-owner slots, and exposes the overlay');
++
++// ── H: the viewer overlay is an independent session, torn down with the player ───────
++const pip = read('public/js/stream-pip.js');
++const player = read('public/js/stream-player.js');
++const html = read('public/index.html');
++assert.ok(html.includes('/js/stream-pip.js'), 'index.html must load stream-pip.js');
++assert.ok(html.indexOf('/js/stream-player.js') < html.indexOf('/js/stream-pip.js'),
++ 'stream-pip.js relies on loadMediasoupClient/sanitizeIceServers from stream-player.js');
++assert.ok(/streamPip\?\.attach\(stream\)/.test(player), 'initPlayer must attach the overlay');
++assert.ok(/streamPip\?\.detach\(\)/.test(player),
++ 'destroyPlayer must detach it — the overlay owns a websocket and a transport of its own');
++for (const msg of ['sfu-viewer-create-transport', 'sfu-viewer-consume', 'sfu-viewer-connect-transport', 'sfu-viewer-resume']) {
++ assert.ok(pip.includes(msg), `the overlay must speak ${msg} like any other viewer`);
++}
++console.log('OK H: overlay is a full independent viewer session, attached and detached with the player');
++
++// ── I: viewer control — geometry persists, and hiding stays recoverable ──────────────
++assert.ok(/localStorage\.setItem\(LS_KEY\(state\.streamId\)/.test(pip), 'geometry must persist per stream');
++assert.ok(/ov-pip-restore/.test(pip) && read('public/css/style.css').includes('.ov-pip-restore'),
++ 'hiding the overlay must leave a way to bring it back');
++assert.ok(/raw\.muted !== false/.test(pip),
++ 'the overlay must default to muted — the stream underneath already carries the audio');
++assert.ok(/is-offline/.test(pip), 'a camera going offline must degrade to a badge, not a broken overlay');
++console.log('OK I: geometry persists, hide is recoverable, audio defaults to muted, offline degrades gracefully');
++
++try { fs.unlinkSync(tmp); } catch { /* */ }
++for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
++console.log('✅ PiP camera slot test passed');