openvibers/openvibe.live Commits
PiP camera: a slot that overlays another slot, movable per viewer
Changes
@@@ -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);++}@@@ -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; }++}@@@ -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>@@@ -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 = {nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };++})();@@@ -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 playingstreamRef = 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();@@@ -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,@@@ -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);nnew file mode 100644@@@ -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');
@@ -4152,3 +4152,11 @@
@@ -4152,3 +4152,11 @@
color: #f59e0b; display: flex; gap: 7px; align-items: flex-start;
color: #f59e0b; display: flex; gap: 7px; align-items: flex-start;
}
}
.bc-cap-warn i { margin-top: 2px; flex-shrink: 0; }
.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);
+}
@@ -13564,3 +13564,87 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
@@ -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 h1.md-h { font-size: 1.5rem; }
.paste-md-body h2.md-h { font-size: 1.25rem; }
.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; }
+}
@@ -4972,6 +4972,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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="/live-notify.js?v=3" defer></script>
<script src="/js/app.js?v=125" 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-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/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
@@ -944,6 +944,23 @@ function _wsRenderPanel() {
@@ -944,6 +944,23 @@ function _wsRenderPanel() {
</label>
</label>
</div>
</div>
</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="bc-ws-row">
<div class="form-group" style="flex:1">
<div class="form-group" style="flex:1">
<label>Default VOD Visibility</label>
<label>Default VOD Visibility</label>
@@ -1869,6 +1886,8 @@ async function _wsSaveAll() {
@@ -1869,6 +1886,8 @@ async function _wsSaveAll() {
weather_zip: ms.weather_zip || null,
weather_zip: ms.weather_zip || null,
weather_detail: ms.weather_detail || 'basic',
weather_detail: ms.weather_detail || 'basic',
weather_show_location: ms.weather_show_location || 0,
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 = {
const profileBlob = {
new file mode 100644
new file mode 100644
@@ -0,0 +1,337 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
+})();
@@ -900,6 +900,10 @@ function _applyLoaderThumb(stream) {
@@ -900,6 +900,10 @@ function _applyLoaderThumb(stream) {
function initPlayer(stream) {
function initPlayer(stream) {
destroyPlayer();
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
setViewerPausedIntent(false); // fresh stream — always start playing
streamRef = stream; // Store for clip recording across all protocols
streamRef = stream; // Store for clip recording across all protocols
_resetPlayerRevealState();
_resetPlayerRevealState();
@@ -3687,6 +3691,9 @@ function getSavedPlayerAudioState() {
@@ -3687,6 +3691,9 @@ function getSavedPlayerAudioState() {
/* ── Cleanup ──────────────────────────────────────────────────── */
/* ── Cleanup ──────────────────────────────────────────────────── */
function destroyPlayer() {
function destroyPlayer() {
_playerGen++; // invalidate any in-flight WebRTC reconnect/rewatch closures from the old session
_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();
stopClipRecording();
destroyDVR();
destroyDVR();
_hideReconnectingIndicator();
_hideReconnectingIndicator();
@@ -2294,6 +2294,24 @@ function initDb() {
@@ -2294,6 +2294,24 @@ function initDb() {
database.exec("ALTER TABLE managed_streams ADD COLUMN browser_mode TEXT DEFAULT 'camera'");
database.exec("ALTER TABLE managed_streams ADD COLUMN browser_mode TEXT DEFAULT 'camera'");
console.log('[DB] Added browser_mode column to managed_streams');
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')) {
if (!msCols2.includes('default_vod_visibility')) {
database.exec("ALTER TABLE managed_streams ADD COLUMN default_vod_visibility TEXT DEFAULT 'public'");
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');
console.log('[DB] Added default_vod_visibility column to managed_streams');
@@ -3366,13 +3384,16 @@ function updateManagedStream(managedStreamId, userId, fields) {
@@ -3366,13 +3384,16 @@ function updateManagedStream(managedStreamId, userId, fields) {
'default_vod_visibility', 'default_clip_visibility', 'slot_vod_recording_enabled', 'slot_clip_recording_enabled',
'default_vod_visibility', 'default_clip_visibility', 'slot_vod_recording_enabled', 'slot_clip_recording_enabled',
'slot_clip_notify_enabled',
'slot_clip_notify_enabled',
'weather_zip', 'weather_detail', 'weather_show_location', 'mic_only_image',
'weather_zip', 'weather_detail', 'weather_show_location', 'mic_only_image',
+ 'pip_source_msid', 'pip_defaults',
]);
]);
const updates = [];
const updates = [];
const params = [];
const params = [];
for (const [key, val] of Object.entries(fields)) {
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined && allowed.has(key)) {
if (val !== undefined && allowed.has(key)) {
updates.push(`${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;
if (updates.length === 0) return;
@@ -3381,6 +3402,49 @@ function updateManagedStream(managedStreamId, userId, fields) {
@@ -3381,6 +3402,49 @@ function updateManagedStream(managedStreamId, userId, fields) {
return run(`UPDATE managed_streams SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`, params);
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) {
function deleteManagedStream(managedStreamId, userId) {
// Unlink sessions first (don't delete them — they're historical)
// Unlink sessions first (don't delete them — they're historical)
run('UPDATE streams SET managed_stream_id = NULL WHERE managed_stream_id = ?', [managedStreamId]);
run('UPDATE streams SET managed_stream_id = NULL WHERE managed_stream_id = ?', [managedStreamId]);
@@ -7445,6 +7509,7 @@ module.exports = {
@@ -7445,6 +7509,7 @@ module.exports = {
getManagedStreamBySlug, getManagedStreamByStreamKey, getManagedStreamByIdOrSlug,
getManagedStreamBySlug, getManagedStreamByStreamKey, getManagedStreamByIdOrSlug,
updateManagedStream, deleteManagedStream,
updateManagedStream, deleteManagedStream,
getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,
getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,
+ getPipOverlayForManagedStream, getPipCandidateSlots,
countManagedStreamsByUser, getManagedStreamLimit,
countManagedStreamsByUser, getManagedStreamLimit,
isValidManagedStreamSlug, isManagedStreamSlugTaken,
isValidManagedStreamSlug, isManagedStreamSlugTaken,
ensureStreamerRoleOnFeed,
ensureStreamerRoleOnFeed,
@@ -1571,6 +1571,35 @@ router.put('/managed/:id', requireAuth, (req, res) => {
@@ -1571,6 +1571,35 @@ router.put('/managed/:id', requireAuth, (req, res) => {
if (hasOwn(req.body, 'control_config_id')) {
if (hasOwn(req.body, 'control_config_id')) {
fields.control_config_id = req.body.control_config_id === null ? null : parseInt(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')) {
if (hasOwn(req.body, 'sort_order')) {
fields.sort_order = parseInt(req.body.sort_order) || 0;
fields.sort_order = parseInt(req.body.sort_order) || 0;
}
}
@@ -1728,6 +1757,16 @@ router.get('/:id', optionalAuth, (req, res) => {
@@ -1728,6 +1757,16 @@ router.get('/:id', optionalAuth, (req, res) => {
stream.controls = db.getStreamControls(stream.id);
stream.controls = db.getStreamControls(stream.id);
stream.channel = db.getChannelByUserId(stream.user_id) || null;
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);
if (req.user) stream.isFollowing = db.isFollowing(req.user.id, stream.user_id);
stream.follower_count = db.getFollowerCount(stream.user_id);
stream.follower_count = db.getFollowerCount(stream.user_id);
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -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');