openvibers/openvibe.live Commits
Picture-in-picture: up to three streams over a slot, from anyone's slots (stream_pips, migration 0021); the channel page draws them again (its API never sent the overlay); viewers move, resize, mute and hide each; a list editor in slot settings (#391)
Changes
@@@ -74,6 +74,30 @@ Clip (the last 30 seconds) and More, a sheet with share, watch as viewer, chooseone-minute clip, chat read aloud, the restream controls, stream health, all settings and **Full page** (the classicpage, with a button back). The studio drives the same functions as the page and reads its counters.++### Picture-in-picture++A slot can show up to three other streams over its own:++- your webcam on a slot of its own while the main slot is a screen share;++- a second angle;++- anyone's stream on OpenVibe.Live.++++Each stays a stream in its own right, with its own VOD, clips, transcript and restreams. A PIP shows while its stream is++live, and appears within 30 seconds when that stream goes live after the page opened.++++**Setting it up:** open the slot's **Settings → Picture-in-picture**.++- Add one of your slots, or type another streamer's @name and pick one of theirs.++- For each PIP, choose where it starts (a corner and a size) and whether it starts with sound.++- Changes save at once.++++**What viewers can do:** move, resize, mute or hide each PIP for themselves, and the page remembers it per stream.++- A PIP that can't play shows "… is offline" and can still be hidden.++- A PIP's name opens that streamer's channel.++++**Limits:** an 18+ stream shows only over another 18+ stream, and a banned streamer's slot shows nowhere.++++API: `GET`/`PUT /api/streams/managed/:id/pips` (`{ pips: [{ source_msid, x, y, w, muted }] }`, fractions of the++player), `GET /api/streams/pip-sources?username=` (a streamer's slots), and `GET /api/streams/:id/pips` (a live++stream's PIPs now). The stream objects carry `pip_overlays`.++### Viewer counts across platformsA live stream's viewers are counted wherever it goes out (`server/streaming/viewer-places.js`): OpenVibe itself (thelarger of Chat's count and the player sockets'), RobotStreamer, and each restream destination of the stream's slotnnew file mode 100644@@@ -0,0 +1,39 @@++-- phase: expand++-- Picture-in-picture: the streams drawn over a slot's stream, each where its streamer put it and movable by every viewer++-- (owner, 2026-10-26: "the streamer should be able to configure PIPs enabled for a stream, and it should be able to use++-- other streams (maybe not even just that streamer, like any streams on the site) and should be able to set their++-- default position/size while also allowing users to adjust them"). One row per PIP, up to three per slot:++-- managed_stream_id the slot whose viewers see the PIP++-- source_msid the slot drawn over it: one of the streamer's own, or anyone's; it shows while that slot is live++-- position the order the streamer listed them in (the first is also the N-1 pip_source_msid)++-- x, y, w where it starts, as fractions of the player (left, top, width), like pip_defaults++-- muted 1: the PIP starts without sound (the stream underneath already carries the voice)++-- managed_streams.pip_source_msid and pip_defaults stay for the N-1 release (the first PIP is mirrored there), and a++-- contract migration drops them later.++CREATE TABLE IF NOT EXISTS stream_pips (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ managed_stream_id bigint NOT NULL,++ source_msid bigint NOT NULL,++ position bigint NOT NULL DEFAULT 0,++ x double precision NOT NULL DEFAULT 0.72,++ y double precision NOT NULL DEFAULT 0.70,++ w double precision NOT NULL DEFAULT 0.25,++ muted bigint NOT NULL DEFAULT 1,++ created_at text COLLATE "C" DEFAULT ov_now(),++ UNIQUE (managed_stream_id, source_msid),++ CHECK (managed_stream_id <> source_msid),++ FOREIGN KEY (managed_stream_id) REFERENCES managed_streams(id) ON DELETE CASCADE,++ FOREIGN KEY (source_msid) REFERENCES managed_streams(id) ON DELETE CASCADE++);++CREATE INDEX IF NOT EXISTS idx_stream_pips_source ON stream_pips (source_msid);++++-- The one PIP a slot could have until now.++INSERT INTO stream_pips (managed_stream_id, source_msid, x, y, w)++SELECT m.id, m.pip_source_msid,++ LEAST(1, GREATEST(0, COALESCE((NULLIF(m.pip_defaults, '')::json ->> 'x')::double precision, 0.72))),++ LEAST(1, GREATEST(0, COALESCE((NULLIF(m.pip_defaults, '')::json ->> 'y')::double precision, 0.70))),++ LEAST(0.6, GREATEST(0.08, COALESCE((NULLIF(m.pip_defaults, '')::json ->> 'w')::double precision, 0.25)))++FROM managed_streams m++WHERE m.pip_source_msid IS NOT NULL AND m.pip_source_msid <> m.id++ AND EXISTS (SELECT 1 FROM managed_streams s WHERE s.id = m.pip_source_msid)++ON CONFLICT (managed_stream_id, source_msid) DO NOTHING;@@@ -1926,6 +1926,26 @@flex-direction: column;gap: 6px;}++/* Picture-in-picture streams (broadcast-workspace.js _wsPipRender): one row each, then the ways to add one. */++.bc-ws-pip-list { display: flex; flex-direction: column; gap: 6px; }++.bc-ws-pip-row {++ display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto auto; align-items: center; gap: 6px;++ padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px;++}++.bc-ws-pip-name { display: flex; align-items: center; gap: 7px; min-width: 0; font-size: 0.85rem; font-weight: 600; }++.bc-ws-pip-name span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.bc-ws-pip-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted, #8b93ad); }++.bc-ws-pip-dot.is-live { background: #ef4444; }++.bc-ws-pip-sound { display: inline-flex; align-items: center; gap: 4px; font-size: 0.8rem; white-space: nowrap; }++.bc-ws-pip-add { display: flex; flex-direction: column; gap: 6px; }++.bc-ws-pip-add-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }++.bc-ws-pip-add-row .form-input { flex: 1; min-width: 9rem; }++.bc-ws-pip-add-label { font-size: 0.8rem; color: var(--text-muted, #8b93ad); min-width: 1.6rem; }++.bc-ws-pip-found { display: flex; flex-wrap: wrap; gap: 6px; }++@media (max-width: 560px) {++ .bc-ws-pip-row { grid-template-columns: minmax(0, 1fr) auto auto; }++ .bc-ws-pip-name { grid-column: 1 / -1; }++}.bc-ws-restream-card {border: 1px solid var(--border);border-radius: 6px;@@@ -1266,7 +1266,17 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:.ov-pip-actions { display: flex; gap: 3px; flex-shrink: 0; }.ov-pip:hover .ov-pip-resize { opacity: 1; }.ov-pip.is-offline .ov-pip-offline { display: flex; }++/* The bar stays above the offline layer and shows while offline, so a PIP that cannot play can still be hidden (the++ layer, drawn after the bar over the whole PIP, used to cover it). */++.ov-pip-bar { z-index: 2; }++.ov-pip-offline { z-index: 1; }++.ov-pip.is-offline .ov-pip-bar { opacity: 1; pointer-events: auto; }.ov-pip-restore:hover { background: rgba(0,0,0,.8); }++/* Several PIPs (stream_pips): each hidden one's "Show …" pill, in one row in the corner; a PIP's name opens its channel. */++.ov-pip-restores { position: absolute; right: 12px; bottom: 12px; z-index: 25; display: flex; flex-wrap: wrap-reverse; justify-content: flex-end; gap: 6px; max-width: calc(100% - 24px); }++.ov-pip-restores .ov-pip-restore { position: static; }++.ov-pip-title a { color: inherit; text-decoration: none; }++.ov-pip-title a:hover, .ov-pip-title a:focus-visible { text-decoration: underline; }@media (max-width: 640px) {.ov-pip-resize { width: 26px; height: 26px; opacity: .8; }@@@ -636,45 +636,17 @@ function _wsRenderPanel() {<div class="bc-ws-profile-section"><!-- Picture-in-picture camera -->-- <details class="bc-ws-slot-settings">-- <summary><i class="fa-solid fa-video"></i> Picture-in-picture Camera</summary>++ <details class="bc-ws-slot-settings" id="bc-ws-pip-details">++ <summary><i class="fa-solid fa-clone"></i> Picture-in-picture</summary><div class="bc-ws-slot-settings-inner"><p class="muted" style="font-size:0.82rem;margin-bottom:10px">-- Overlay another of your slots on top of this stream. The camera stays a stream-- in its own right — its own VOD, clips and restreams — so it can be your webcam,-- a second angle, or a co-host's slot. Viewers can move, resize, mute or hide it-- themselves; the values below are only where it starts.++ Show up to three other streams over this one: your webcam on a slot of its own, a second++ angle, or anyone's stream on OpenVibe.Live. Each stays a stream in its own right, with its own++ VOD, clips and restreams, and shows while it is live. Viewers can move, resize, mute or hide++ each one; what you set here is where it starts. Changes here save at once.</p>-- <div class="form-group">-- <label>Camera slot</label>-- <select id="bc-ws-pip-source" class="form-input" onchange="_wsSlotSettingChanged();_wsPipDefaultsToggle()">-- <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>-- </div>-- <div id="bc-ws-pip-defaults" style="display:${ms.pip_source_msid ? '' : 'none'}">-- <div class="bc-ws-row">-- <div class="form-group" style="flex:1">-- <label>Starting corner</label>-- <select id="bc-ws-pip-corner" class="form-input form-input-sm" onchange="_wsSlotSettingChanged()">-- <option value="br">Bottom Right</option>-- <option value="bl">Bottom Left</option>-- <option value="tr">Top Right</option>-- <option value="tl">Top Left</option>-- </select>-- </div>-- <div class="form-group" style="flex:1">-- <label>Starting size</label>-- <select id="bc-ws-pip-size" class="form-input form-input-sm" onchange="_wsSlotSettingChanged()">-- <option value="0.18">Small</option>-- <option value="0.25">Medium</option>-- <option value="0.35">Large</option>-- </select>-- </div>-- </div>-- </div>++ <div id="bc-ws-pip-list" class="bc-ws-pip-list"></div>++ <div id="bc-ws-pip-add" class="bc-ws-pip-add"></div></div></details>@@@ -907,7 +879,7 @@ function _wsRenderPanel() {// Async post-render: check device permissions// Corner/size selects are derived from the stored {x,y,w} fractions.-- _wsPipDefaultsRestore(ms);++ _wsPipLoad(ms.id);if (method === 'browser') {_wsInitDevicePicker(method, browserMode);_wsSyncScreenShareHiddenDefaults(browserMode);@@@ -1721,13 +1693,6 @@ 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,-- pip_defaults: (() => {-- const w = Number(document.getElementById('bc-ws-pip-size')?.value) || 0.25;-- const corner = document.getElementById('bc-ws-pip-corner')?.value || 'br';-- return { ...(_wsPipCornerToXY(corner, w)), w };-- })(),};const profileBlob = {@@@ -2045,34 +2010,120 @@ function _wsPipXYToCorner(x, y) {return (y < 0.5 ? 't' : 'b') + (x < 0.5 ? 'l' : 'r');}--/** Show the geometry controls only when a camera slot is actually selected. */--function _wsPipDefaultsToggle() {-- const on = !!document.getElementById('bc-ws-pip-source')?.value;-- _wsShowEl('bc-ws-pip-defaults', on);--}----/** Restore corner/size from the stored fractions when the panel renders. */--function _wsPipDefaultsRestore(ms) {-- let d = {};-- try { d = typeof ms.pip_defaults === 'string' ? JSON.parse(ms.pip_defaults || '{}') : (ms.pip_defaults || {}); }-- catch { d = {}; }-- const sizeEl = document.getElementById('bc-ws-pip-size');-- const cornerEl = document.getElementById('bc-ws-pip-corner');-- if (sizeEl) {-- const w = Number(d.w);-- // Snap to the nearest offered size rather than showing an option that is not there.-- const opts = [0.18, 0.25, 0.35];-- const nearest = Number.isFinite(w)-- ? opts.reduce((a, b) => (Math.abs(b - w) < Math.abs(a - w) ? b : a))-- : 0.25;-- sizeEl.value = String(nearest);-- }-- if (cornerEl) {-- cornerEl.value = (Number.isFinite(Number(d.x)) && Number.isFinite(Number(d.y)))-- ? _wsPipXYToCorner(Number(d.x), Number(d.y))-- : 'br';-- }-- _wsPipDefaultsToggle();++/* ── Picture-in-picture: the streams shown over this slot (stream_pips; PUT /streams/managed/:id/pips) ──++ Up to three, each one of your slots or anyone's, with where it starts (a corner and a size) and whether it starts++ with sound. The list saves on every change, apart from the slot's other settings. */++const _PIP_SIZES = [['0.18', 'Small'], ['0.25', 'Medium'], ['0.35', 'Large']];++const _PIP_CORNERS = [['br', 'Bottom right'], ['bl', 'Bottom left'], ['tr', 'Top right'], ['tl', 'Top left']];++++function _wsEl(tag, attrs, ...kids) {++ const el = document.createElement(tag);++ for (const [k, v] of Object.entries(attrs || {})) {++ if (v == null || v === false) continue;++ if (k === 'text') el.textContent = v;++ else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);++ else el.setAttribute(k, v === true ? '' : v);++ }++ for (const kid of kids) if (kid != null) el.append(kid);++ return el;++}++function _wsSelect(options, value, onchange, label) {++ const sel = _wsEl('select', { class: 'form-input form-input-sm', 'aria-label': label, onchange });++ for (const [v, t] of options) sel.append(_wsEl('option', { value: v, selected: String(v) === String(value), text: t }));++ return sel;++}++++async function _wsPipLoad(msId) {++ _wsState.pip = { msId, list: [], max: 3, found: null };++ const box = document.getElementById('bc-ws-pip-list');++ if (box) box.replaceChildren(_wsEl('div', { class: 'bc-ws-loading', text: 'Loading…' }));++ try {++ const d = await api(`/streams/managed/${msId}/pips`);++ if (!_wsState.pip || _wsState.pip.msId !== msId) return;++ _wsState.pip.list = d.pips || [];++ _wsState.pip.max = d.max || 3;++ } catch (e) {++ if (box) box.replaceChildren(_wsEl('p', { class: 'muted', text: `Could not load the picture-in-picture streams: ${e.message}` }));++ return;++ }++ _wsPipRender();++}++++/** Save `list` (rows of { source_msid, x, y, w, muted }) and redraw from what the server kept. */++async function _wsPipSave(list) {++ const st = _wsState.pip;++ if (!st) return;++ try {++ const d = await api(`/streams/managed/${st.msId}/pips`, { method: 'PUT', body: { pips: list } });++ st.list = d.pips || [];++ toast('Picture-in-picture saved', 'success');++ } catch (e) {++ toast(e.message || 'Could not save the picture-in-picture streams', 'error');++ }++ _wsPipRender();++}++++const _wsPipRow = (p) => ({ source_msid: p.source_msid, x: p.defaults.x, y: p.defaults.y, w: p.defaults.w, muted: p.muted });++++function _wsPipRender() {++ const st = _wsState.pip;++ const box = document.getElementById('bc-ws-pip-list');++ const add = document.getElementById('bc-ws-pip-add');++ if (!st || !box || !add) return;++ const mine = (_wsState.managedStreams || []);++ const ownIds = new Set(mine.map((m) => Number(m.id)));++ box.replaceChildren(...st.list.map((p, i) => {++ const change = (patch) => _wsPipSave(st.list.map((q, j) => (j === i ? { ..._wsPipRow(q), ...patch(q) } : _wsPipRow(q))));++ const w = [0.18, 0.25, 0.35].reduce((a, b) => (Math.abs(b - p.defaults.w) < Math.abs(a - p.defaults.w) ? b : a));++ const name = ownIds.has(p.source_msid) ? p.title : `${p.title} · @${p.username}`;++ return _wsEl('div', { class: 'bc-ws-pip-row' },++ _wsEl('div', { class: 'bc-ws-pip-name' },++ _wsEl('span', { class: `bc-ws-pip-dot${p.live ? ' is-live' : ''}`, title: p.live ? 'Live now' : 'Offline: shows when it goes live' }),++ _wsEl('span', { text: name })),++ _wsSelect(_PIP_CORNERS, _wsPipXYToCorner(p.defaults.x, p.defaults.y), (e) => change((q) => ({ ..._wsPipCornerToXY(e.target.value, q.defaults.w) })), `Where ${p.title} starts`),++ _wsSelect(_PIP_SIZES, String(w), (e) => change((q) => {++ const nw = Number(e.target.value);++ return { w: nw, ..._wsPipCornerToXY(_wsPipXYToCorner(q.defaults.x, q.defaults.y), nw) };++ }), `How big ${p.title} starts`),++ _wsEl('label', { class: 'bc-ws-pip-sound' },++ _wsEl('input', { type: 'checkbox', checked: !p.muted, onchange: (e) => change(() => ({ muted: !e.target.checked })) }),++ ' Sound'),++ _wsEl('button', { type: 'button', class: 'btn btn-sm btn-ghost', 'aria-label': `Remove ${p.title}`, title: 'Remove',++ onclick: () => _wsPipSave(st.list.filter((_, j) => j !== i).map(_wsPipRow)) }, _wsEl('i', { class: 'fa-solid fa-xmark' })));++ }));++ if (!st.list.length) box.append(_wsEl('p', { class: 'muted', style: 'font-size:0.82rem', text: 'No picture-in-picture streams yet.' }));++++ const used = new Set(st.list.map((p) => p.source_msid));++ const full = st.list.length >= st.max;++ const append = (sourceMsid) => {++ const corner = ['br', 'bl', 'tr', 'tl'][st.list.length % 4];++ _wsPipSave([...st.list.map(_wsPipRow), { source_msid: Number(sourceMsid), w: 0.25, ..._wsPipCornerToXY(corner, 0.25), muted: true }]);++ };++ if (full) { add.replaceChildren(_wsEl('p', { class: 'muted', style: 'font-size:0.82rem', text: `That is the most (${st.max}). Remove one to add another.` })); return; }++ const own = mine.filter((m) => Number(m.id) !== st.msId && !used.has(Number(m.id)));++ const ownSel = _wsSelect([['', 'One of your slots…'], ...own.map((m) => [m.id, m.title || `Slot ${m.id}`])], '', (e) => { if (e.target.value) append(e.target.value); }, 'Add one of your slots');++ const who = _wsEl('input', { type: 'text', class: 'form-input form-input-sm', placeholder: '@streamer', 'aria-label': "Another streamer's name", maxlength: '64' });++ const found = _wsEl('div', { class: 'bc-ws-pip-found' });++ const find = async () => {++ const name = who.value.trim().replace(/^@/, '');++ if (!name) return;++ found.replaceChildren(_wsEl('span', { class: 'muted', text: 'Looking…' }));++ try {++ const d = await api(`/streams/pip-sources?username=${encodeURIComponent(name)}`);++ const slots = (d.slots || []).filter((sl) => sl.id !== st.msId && !used.has(sl.id));++ if (!slots.length) { found.replaceChildren(_wsEl('span', { class: 'muted', text: `@${d.username} has no other stream slot to show.` })); return; }++ found.replaceChildren(...slots.map((sl) => _wsEl('button', { type: 'button', class: 'btn btn-sm btn-secondary', onclick: () => append(sl.id) },++ `${sl.title || `@${d.username}'s stream`}${sl.live ? ' · live' : ''}${sl.nsfw ? ' · 18+' : ''}`)));++ } catch (e) {++ found.replaceChildren(_wsEl('span', { class: 'muted', text: e.message || 'No such streamer' }));++ }++ };++ who.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); find(); } });++ add.replaceChildren(++ _wsEl('div', { class: 'bc-ws-pip-add-row' }, _wsEl('span', { class: 'bc-ws-pip-add-label', text: 'Add' }), ownSel),++ _wsEl('div', { class: 'bc-ws-pip-add-row' }, _wsEl('span', { class: 'bc-ws-pip-add-label', text: 'or' }), who,++ _wsEl('button', { type: 'button', class: 'btn btn-sm btn-secondary', onclick: find }, 'Find their streams')),++ found);}function _wsPopulateInlineDevices(selectId, devices) {@@@ -1,144 +1,158 @@/* ───────────────────────────────────────────────────────────────-- 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.++ stream-pip.js — the streams drawn over a live stream (picture-in-picture).++++ A slot lists up to three other slots (stream_pips, migration 0021): the streamer's own (their camera while the main++ stream is a screen share) or anyone's on the site. Each is an ordinary stream in its own right, with its own VOD,++ clips, transcript and restreams, so each PIP here is a second, self-contained viewer session: its own websocket,++ mediasoup recv transport and consumers (an RTMP source plays its FLV through the same proxy as the main player). It++ deliberately does not reuse stream-player.js's machinery, which is written around module-level singletons.++++ The server sends every PIP with the stream (`pip_overlays`: source slot, its live stream or null, the streamer's++ default place and sound). Only live ones are drawn; every 30 s the list is asked for again++ (GET /api/streams/:id/pips), so one that goes live appears and one that ends goes. Geometry is kept as FRACTIONS++ of the player box, remembered per stream and per PIP, so a viewer who drags one out of the way finds it there next++ time. A PIP that cannot play reads as "offline" and never stops the stream underneath.─────────────────────────────────────────────────────────────── */(function () {'use strict';-- const LS_KEY = (streamId) => `ov-pip-geom-${streamId}`;++ const LS_KEY = (streamId, sourceMsid) => `ov-pip-geom-${streamId}-${sourceMsid}`;++ const LS_OLD = (streamId) => `ov-pip-geom-${streamId}`; // the single PIP before 0021: the first PIP still reads itconst MIN_W = 0.08, MAX_W = 0.60;++ const POLL_MS = 30000;-- let state = null; // { streamId, srcStreamId, ws, device, transport, consumers, el, geom, gen }++ let host = null; // { streamId, container, overlays: Map(source_msid → overlay), timer, gen }let gen = 0;/* ── geometry ─────────────────────────────────────────────── */-- function loadGeom(streamId, defaults) {++ 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 loadGeom(streamId, pip, first) {++ const d = pip.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,++ x: clamp(d.x, 0, 1, 0.72), y: clamp(d.y, 0, 1, 0.70), w: clamp(d.w, MIN_W, MAX_W, 0.25),++ // The streamer's choice; by default muted: the stream underneath already carries the voice, and two live++ // audio tracks would double every word.++ hidden: false, muted: pip.muted !== false,};try {-- const raw = JSON.parse(localStorage.getItem(LS_KEY(streamId)) || 'null');++ let raw = JSON.parse(localStorage.getItem(LS_KEY(streamId, pip.source_msid)) || 'null');++ if (!raw && first) raw = JSON.parse(localStorage.getItem(LS_OLD(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,++ 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, muted: typeof raw.muted === 'boolean' ? raw.muted : fallback.muted,};} catch { return fallback; }}-- function saveGeom() {-- if (!state) return;-- try { localStorage.setItem(LS_KEY(state.streamId), JSON.stringify(state.geom)); } catch { /* */ }++ function saveGeom(o) {++ try { localStorage.setItem(LS_KEY(o.hostId, o.pip.source_msid), JSON.stringify(o.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(o) {++ const { x, y, w, hidden } = o.geom;++ o.el.style.left = `${x * 100}%`;++ o.el.style.top = `${y * 100}%`;++ o.el.style.width = `${w * 100}%`;++ o.el.classList.toggle('is-hidden', !!hidden);}-- 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 escapeHtml(s) {++ return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));}-- /* ── DOM ──────────────────────────────────────────────────── */++ function muteButton(btn, muted, label) {++ btn.title = muted ? `Turn on ${label}'s sound` : `Mute ${label}`;++ btn.setAttribute('aria-label', btn.title);++ btn.innerHTML = `<i class="fa-solid ${muted ? 'fa-volume-xmark' : 'fa-volume-high'}"></i>`;++ }-- function build(container, label) {++ function build(o, container) {++ const label = o.pip.title || 'Picture-in-picture';++ const href = o.pip.username ? `/@${encodeURIComponent(o.pip.username)}${o.pip.slug ? `/${encodeURIComponent(o.pip.slug)}` : ''}` : null;const el = document.createElement('div');el.className = 'ov-pip';++ el.dataset.pipSource = String(o.pip.source_msid);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-title" title="${escapeHtml(label)}"><i class="fa-solid fa-video"></i> ${href++ ? `<a href="${escapeHtml(href)}" onclick="return handleLinkClick(event, '${escapeHtml(href)}')">${escapeHtml(label)}</a>` : 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>++ <button type="button" data-act="mute"></button>++ <button type="button" data-act="reset" title="Back to where the streamer put it" aria-label="Back to where the streamer put it"><i class="fa-solid fa-arrows-to-dot"></i></button>++ <button type="button" data-act="hide" title="Hide ${escapeHtml(label)}" aria-label="Hide ${escapeHtml(label)}"><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>`;++ <div class="ov-pip-offline"><i class="fa-solid fa-video-slash"></i><span>${escapeHtml(label)} is 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;-- };++ o.el = el;++ muteButton(el.querySelector('[data-act="mute"]'), o.geom.muted, label);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(); }++ if (act === 'hide') { o.geom.hidden = true; saveGeom(o); applyGeom(o); showRestore(o, container); }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();++ const d = o.pip.defaults || {};++ o.geom.x = clamp(d.x, 0, 1, 0.72); o.geom.y = clamp(d.y, 0, 1, 0.70); o.geom.w = clamp(d.w, MIN_W, MAX_W, 0.25);++ saveGeom(o); applyGeom(o);} else if (act === 'mute') {-- state.geom.muted = !state.geom.muted;++ o.geom.muted = !o.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();++ v.muted = o.geom.muted;++ if (!o.geom.muted) v.play().catch(() => {});++ muteButton(e.target.closest('[data-act="mute"]'), o.geom.muted, label);++ saveGeom(o);}});++ wireDrag(o, container);++ wireResize(o, container);++ applyGeom(o);++ if (o.geom.hidden) showRestore(o, container);++ }-- wireDrag(el, container);-- wireResize(el, container);-- if (state?.geom?.hidden) showRestore();-- return el;++ /** The "Show …" pill for a hidden PIP; pills of several hidden PIPs sit side by side. */++ function showRestore(o, container) {++ if (o.pill) return;++ let bar = container.querySelector('.ov-pip-restores');++ if (!bar) { bar = document.createElement('div'); bar.className = 'ov-pip-restores'; container.appendChild(bar); }++ const pill = document.createElement('button');++ pill.type = 'button';++ pill.className = 'ov-pip-restore';++ pill.innerHTML = `<i class="fa-solid fa-video"></i> Show ${escapeHtml(o.pip.title || 'picture-in-picture')}`;++ pill.addEventListener('click', () => { o.geom.hidden = false; saveGeom(o); applyGeom(o); removeRestore(o); });++ bar.appendChild(pill);++ o.pill = pill;++ }++++ function removeRestore(o) {++ if (!o.pill) return;++ const bar = o.pill.parentNode;++ o.pill.remove();++ o.pill = null;++ if (bar && !bar.children.length) bar.remove();}/** Pointer-driven move, constrained so the overlay can never be dragged off-screen. */-- function wireDrag(el, container) {++ function wireDrag(o, container) {++ const el = o.el;let start = null;const onDown = (e) => {-- if (e.target.closest('[data-act]') || e.target.closest('.ov-pip-resize')) return;++ if (e.target.closest('[data-act]') || e.target.closest('.ov-pip-resize') || e.target.closest('a')) 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 };++ start = { px: e.clientX, py: e.clientY, x: o.geom.x, y: o.geom.y, rw: r.width, rh: r.height };el.classList.add('is-dragging');el.setPointerCapture?.(e.pointerId);e.preventDefault();@@@ -147,18 +161,16 @@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();++ o.geom.x = Math.min(Math.max(0, start.x + (e.clientX - start.px) / start.rw), Math.max(0, 1 - (box.width / r.width)));++ o.geom.y = Math.min(Math.max(0, start.y + (e.clientY - start.py) / start.rh), Math.max(0, 1 - (box.height / r.height)));++ applyGeom(o);};const onUp = (e) => {if (!start) return;start = null;el.classList.remove('is-dragging');el.releasePointerCapture?.(e.pointerId);-- saveGeom();++ saveGeom(o);};el.addEventListener('pointerdown', onDown);el.addEventListener('pointermove', onMove);@@@ -166,41 +178,42 @@el.addEventListener('pointercancel', onUp);}-- function wireResize(el, container) {++ function wireResize(o, container) {++ const el = o.el;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 };++ start = { px: e.clientX, w: o.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);++ o.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();++ o.geom.x = Math.min(o.geom.x, Math.max(0, 1 - box.width / r.width));++ o.geom.y = Math.min(o.geom.y, Math.max(0, 1 - box.height / r.height));++ applyGeom(o);});const end = (e) => {if (!start) return;start = null;el.classList.remove('is-resizing');handle.releasePointerCapture?.(e.pointerId);-- saveGeom();++ saveGeom(o);};handle.addEventListener('pointerup', end);handle.addEventListener('pointercancel', end);}-- function escapeHtml(s) {-- return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++ function markOffline(o, off) {++ if (o.el) o.el.classList.toggle('is-offline', !!off);}-- /* ── viewer session ───────────────────────────────────────── */++ /* ── viewer sessions ──────────────────────────────────────── */function waitFor(ws, type, timeoutMs = 15000) {return new Promise((resolve, reject) => {@@@ -208,7 +221,7 @@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') {++ else if (m.type === 'sfu-error' || m.type === 'sfu-viewer-error' || m.type === 'sfu-source-unavailable') {clearTimeout(timer); ws.removeEventListener('message', on); reject(new Error(m.error || 'pip: source unavailable'));}}@@@ -216,121 +229,150 @@});}-- 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;++ const alive = (o) => host && host.overlays.get(o.pip.source_msid) === o && o.gen === host.gen && !o.closed;++ /** A WebRTC source: a viewer of its mediasoup room, like the main player. */++ async function connectWebrtc(o, videoEl) {++ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';++ const ws = new WebSocket(`${proto}://${window.location.host}/ws/broadcast?streamId=${o.pip.stream_id}&role=viewer`);++ o.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; }--++ if (!alive(o)) return;++ ws.addEventListener('close', () => { if (alive(o)) markOffline(o, true); });ws.send(JSON.stringify({ type: 'watch' }));const ready = await waitFor(ws, 'sfu-viewer-ready', 20000);-- if (myGen !== gen) return;++ if (!alive(o)) 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;++ if (!alive(o)) return;ws.send(JSON.stringify({ type: 'sfu-viewer-create-transport' }));const tp = await waitFor(ws, 'sfu-viewer-transport-created');-- if (myGen !== gen) return;--++ if (!alive(o)) 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,++ 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; }--++ o.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);-- });++ transport.on('connectionstatechange', (st) => { if (st === 'failed' || st === 'closed') markOffline(o, 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,-- }));++ 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);++ if (!alive(o)) return;++ const consumer = await transport.consume({ id: cp.id, producerId: cp.producerId, kind: cp.kind, rtpParameters: cp.rtpParameters });++ o.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); });++ /** An RTMP source: its FLV through Live's proxy (flv.js, loaded by the main player when it needed it). */++ async function connectFlv(o, videoEl) {++ if (typeof flvjs === 'undefined' || !flvjs.isSupported()) throw new Error('pip: flv.js unavailable');++ const player = flvjs.createPlayer({ type: 'flv', isLive: true, url: `/api/streams/rtmp-proxy/${o.pip.stream_id}.flv` },++ { enableStashBuffer: false, liveBufferLatencyChasing: true });++ o.flv = player;++ player.attachMediaElement(videoEl);++ player.on(flvjs.Events.ERROR, () => { if (alive(o)) markOffline(o, true); });++ player.load();}-- function markOffline(off) {-- if (!state?.el) return;-- state.el.classList.toggle('is-offline', !!off);++ async function connect(o) {++ const videoEl = o.el.querySelector('.ov-pip-video');++ try {++ if (o.pip.protocol === 'rtmp') await connectFlv(o, videoEl);++ else await connectWebrtc(o, videoEl);++ if (!alive(o)) return;++ videoEl.muted = o.geom.muted;++ videoEl.play().catch(() => {});++ markOffline(o, false);++ } catch (err) {++ console.warn(`[PiP] ${o.pip.title || o.pip.source_msid} unavailable:`, err.message);++ if (alive(o)) markOffline(o, true);++ }++ }++++ function close(o) {++ o.closed = true;++ try { o.consumers.forEach((c) => { try { c.close(); } catch { /* */ } }); } catch { /* */ }++ try { o.transport?.close(); } catch { /* */ }++ try { o.ws?.close(); } catch { /* */ }++ try { o.flv?.destroy(); } catch { /* */ }++ removeRestore(o);++ try { o.el?.remove(); } catch { /* */ }++ }++++ /* ── the list ─────────────────────────────────────────────── */++++ /** Draw the live PIPs of `pips` and drop the rest; a PIP whose source restarted (a new stream) reconnects. */++ function sync(pips) {++ if (!host) return;++ const live = (Array.isArray(pips) ? pips : []).filter((p) => p && p.live && p.stream_id && p.source_msid);++ const keep = new Set(live.map((p) => p.source_msid));++ for (const [key, o] of host.overlays) {++ if (!keep.has(key)) { close(o); host.overlays.delete(key); }++ }++ live.forEach((pip, i) => {++ const cur = host.overlays.get(pip.source_msid);++ if (cur && cur.pip.stream_id === pip.stream_id && !cur.el.classList.contains('is-offline')) return;++ if (cur) { close(cur); host.overlays.delete(pip.source_msid); }++ const o = { pip, hostId: host.streamId, gen: host.gen, geom: loadGeom(host.streamId, pip, i === 0), consumers: [], el: null, pill: null };++ host.overlays.set(pip.source_msid, o);++ build(o, host.container);++ connect(o);++ });++ }++++ async function poll() {++ if (!host) return;++ const myGen = host.gen;++ try {++ const r = await fetch(`/api/streams/${encodeURIComponent(host.streamId)}/pips`, { credentials: 'same-origin' });++ if (!r.ok) return;++ const d = await r.json();++ if (host && host.gen === myGen) sync(d.pips);++ } catch { /* the next poll tries again */ }}/* ── 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.++ * Draw the PIPs of a stream (`pip_overlays`, or the N-1 `pip_overlay`). Safe to call on every player init; it tears++ * down any previous ones first.*/-- async function attach(stream) {++ 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);-- }++ if (!container || !stream || !stream.id) return;++ const pips = Array.isArray(stream.pip_overlays) ? stream.pip_overlays++ : (stream.pip_overlay ? [Object.assign({ source_msid: stream.pip_overlay.source_msid || 0 }, stream.pip_overlay)] : []);++ if (!pips.length) return; // a slot with no PIPs never polls++ host = { streamId: stream.id, container, overlays: new Map(), gen: ++gen, timer: null };++ sync(pips);++ host.timer = setInterval(poll, POLL_MS);}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;++ if (!host) return;++ clearInterval(host.timer);++ for (const o of host.overlays.values()) close(o);++ try { host.container.querySelector('.ov-pip-restores')?.remove(); } catch { /* */ }++ host = null;}window.streamPip = { attach, detach };@@@ -1199,6 +1199,74 @@ async function getPipOverlayForManagedStream(managedStreamId) {} catch { return null; }}++// ── Picture-in-picture (stream_pips, migration 0021) ─────────++// A slot shows up to MAX_PIPS other slots over its stream: the streamer's own or anyone's, each starting where the++// streamer put it (fractions of the player) and moved by every viewer for themselves (public/js/stream-pip.js).++const MAX_PIPS = 3;++const pipNum = (v, lo, hi, dflt) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt; };++++/**++ * The PIPs of a slot, in its order, each with what a player needs: the source slot, its owner, and its live stream++ * (null while it is offline). A source marked NSFW never shows over a slot that is not.++ */++async function getPipsForManagedStream(managedStreamId) {++ try {++ const host = await get('SELECT id, is_nsfw FROM managed_streams WHERE id = ?', [managedStreamId]);++ if (!host) return [];++ const rows = await all(`SELECT p.id, p.source_msid, p.position, p.x, p.y, p.w, p.muted,++ m.title, m.slug, m.is_nsfw, m.protocol AS slot_protocol, u.username, u.display_name,++ (SELECT s.id FROM streams s WHERE s.managed_stream_id = m.id AND s.is_live = 1 ORDER BY s.started_at DESC LIMIT 1) AS stream_id,++ (SELECT s.protocol FROM streams s WHERE s.managed_stream_id = m.id AND s.is_live = 1 ORDER BY s.started_at DESC LIMIT 1) AS protocol++ FROM stream_pips p JOIN managed_streams m ON m.id = p.source_msid JOIN users u ON u.id = m.user_id++ WHERE p.managed_stream_id = ? AND (u.is_banned IS NULL OR u.is_banned = 0)++ ORDER BY p.position ASC, p.id ASC LIMIT ${MAX_PIPS}`, [managedStreamId]);++ return rows.filter((r) => !Number(r.is_nsfw) || Number(host.is_nsfw)).map((r) => ({++ id: Number(r.id),++ source_msid: Number(r.source_msid),++ title: r.title || `${r.display_name || r.username}'s stream`,++ username: r.username,++ slug: r.slug || null,++ stream_id: r.stream_id ? Number(r.stream_id) : null,++ live: !!r.stream_id,++ protocol: r.protocol || r.slot_protocol || 'webrtc',++ defaults: { x: Number(r.x), y: Number(r.y), w: Number(r.w) },++ muted: Number(r.muted) !== 0,++ }));++ } catch { return []; }++}++++/**++ * Replace a slot's PIPs with `pips` ([{ source_msid, x, y, w, muted }], at most MAX_PIPS, in order). The first is++ * mirrored into managed_streams.pip_source_msid / pip_defaults for the N-1 release. Returns the saved list.++ */++async function setPipsForManagedStream(managedStreamId, pips) {++ const list = (pips || []).slice(0, MAX_PIPS).map((p, i) => ({++ source_msid: Number(p.source_msid), position: i,++ x: pipNum(p.x, 0, 1, 0.72), y: pipNum(p.y, 0, 1, 0.70), w: pipNum(p.w, 0.08, 0.6, 0.25), muted: p.muted === false ? 0 : 1,++ }));++ await tx(async () => {++ await run('DELETE FROM stream_pips WHERE managed_stream_id = ?', [managedStreamId]);++ for (const p of list) {++ await run(`INSERT INTO stream_pips (managed_stream_id, source_msid, position, x, y, w, muted) VALUES (?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (managed_stream_id, source_msid) DO NOTHING`, [managedStreamId, p.source_msid, p.position, p.x, p.y, p.w, p.muted]);++ }++ const first = list[0];++ await run('UPDATE managed_streams SET pip_source_msid = ?, pip_defaults = ?, updated_at = ov_now() WHERE id = ?',++ [first ? first.source_msid : null, first ? JSON.stringify({ x: first.x, y: first.y, w: first.w }) : '{}', managedStreamId]);++ });++ return list;++}++++/** A person's slots, as PIP sources: id, title, slug, and whether each is live now. */++async function getPipSourceSlots(userId) {++ try {++ const rows = await all(`SELECT m.id, m.title, m.slug, m.is_nsfw,++ EXISTS (SELECT 1 FROM streams s WHERE s.managed_stream_id = m.id AND s.is_live = 1) AS live++ FROM managed_streams m WHERE m.user_id = ? ORDER BY m.sort_order ASC, m.id ASC LIMIT 50`, [userId]);++ return rows.map((r) => ({ id: Number(r.id), title: r.title || null, slug: r.slug || null, nsfw: !!Number(r.is_nsfw), live: !!r.live }));++ } catch { return []; }++}++/** Slots that could serve as a PiP source for this user (everything except `excludeId`). */async function getPipCandidateSlots(userId, excludeId = null) {try {@@@ -3589,7 +3657,7 @@ module.exports = {getManagedStreamBySlug, getManagedStreamByStreamKey, getManagedStreamByIdOrSlug,updateManagedStream, deleteManagedStream,getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,-- getPipOverlayForManagedStream, getPipCandidateSlots,++ getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, MAX_PIPS,countManagedStreamsByUser, getManagedStreamLimit,isValidManagedStreamSlug, isManagedStreamSlugTaken,ensureStreamerRoleOnFeed,@@@ -114,6 +114,17 @@ async function staffMayModerate(actor, ownerUserId) {*/const cache = require('openvibe-shared/cache-policy');++// The PIPs a player draws over a live stream (stream_pips): every one its slot lists, each live or not (public/js/++// stream-pip.js shows the live ones and picks up one that goes live), and the first live one as pip_overlay, the shape++// the N-1 player reads.++async function attachPips(stream) {++ const pips = stream && stream.managed_stream_id ? await db.getPipsForManagedStream(stream.managed_stream_id) : [];++ stream.pip_overlays = pips;++ const first = pips.find((p) => p.live);++ stream.pip_overlay = first ? { source_msid: first.source_msid, title: first.title, slug: first.slug, stream_id: first.stream_id, live: true, defaults: first.defaults } : null;++ return stream;++}++function hasOwn(obj, key) {return Object.prototype.hasOwnProperty.call(obj || {}, key);}@@@ -244,6 +255,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {// Whether this live slot is being recorded server-side — if so, the client can// cut clips on the SERVER (no CPU-heavy client-side MediaRecorder buffer).try { liveStream.server_clip = !!recorder.getActiveRecording(liveStream.id); } catch { liveStream.server_clip = false; }++ await attachPips(liveStream);delete liveStream.stream_key;delete liveStream.managed_stream_key;}@@@ -1726,6 +1738,12 @@ router.put('/managed/:id', requireAuth, async (req, res) => {}await db.updateManagedStream(msId, ms.user_id, fields);++ // The one PIP an older page sets (pip_source_msid, pip_defaults) becomes the slot's whole PIP list (stream_pips).++ if (hasOwn(req.body, 'pip_source_msid')) {++ let d = {};++ try { d = fields.pip_defaults ? JSON.parse(fields.pip_defaults) : {}; } catch { d = {}; }++ await db.setPipsForManagedStream(msId, fields.pip_source_msid ? [{ source_msid: fields.pip_source_msid, ...d }] : []);++ }const updated = await db.getManagedStreamById(msId);// If the title changed and this slot has a RobotStreamer integration, mirror the@@@ -1747,6 +1765,60 @@ router.put('/managed/:id', requireAuth, async (req, res) => {}});++// ── Picture-in-picture (stream_pips, migration 0021) ─────────++// Up to db.MAX_PIPS streams over a slot's stream: the streamer's own slots or anyone's (owner, 2026-10-26: "it should be++// able to use other streams (maybe not even just that streamer, like any streams on the site)"), each starting where++// the streamer put it; every viewer moves and resizes them for themselves (public/js/stream-pip.js).++// GET /managed/:id/pips → { pips, max }++// PUT /managed/:id/pips { pips: [{ source_msid, x, y, w, muted }] } → { pips, max }: replaces them, in that order++// GET /pip-sources?username= → { username, slots }: a person's slots to pick from (your own without one)++async function pipSlotFor(req, res) {++ const msId = parseInt(req.params.id, 10);++ const ms = Number.isFinite(msId) ? await db.getManagedStreamById(msId) : null;++ if (!ms) { res.status(404).json({ error: 'Managed stream not found' }); return null; }++ if (ms.user_id !== req.user.id && !await staffMayModerate(req.user, ms.user_id)) { res.status(403).json({ error: 'Not your managed stream' }); return null; }++ return ms;++}++router.get('/managed/:id/pips', requireAuth, async (req, res) => {++ const ms = await pipSlotFor(req, res);++ if (!ms) return;++ res.json({ pips: await db.getPipsForManagedStream(ms.id), max: db.MAX_PIPS });++});++router.put('/managed/:id/pips', requireAuth, async (req, res) => {++ try {++ const ms = await pipSlotFor(req, res);++ if (!ms) return;++ const list = req.body && req.body.pips;++ if (!Array.isArray(list)) return res.status(400).json({ error: 'pips must be a list' });++ if (list.length > db.MAX_PIPS) return res.status(400).json({ error: `At most ${db.MAX_PIPS} picture-in-picture streams` });++ const seen = new Set();++ for (const p of list) {++ const src = parseInt(p && p.source_msid, 10);++ if (!Number.isFinite(src)) return res.status(400).json({ error: 'Each picture-in-picture needs a stream slot' });++ if (src === ms.id) return res.status(400).json({ error: 'A slot cannot show itself as a picture-in-picture' });++ if (seen.has(src)) return res.status(400).json({ error: 'That stream is already a picture-in-picture here' });++ seen.add(src);++ const slot = await db.getManagedStreamById(src);++ if (!slot) return res.status(404).json({ error: 'That stream slot does not exist' });++ const owner = slot.user_id === ms.user_id ? null : await db.getUserById(slot.user_id);++ if (owner && owner.is_banned) return res.status(404).json({ error: 'That stream slot does not exist' });++ if (Number(slot.is_nsfw) && !Number(ms.is_nsfw)) return res.status(400).json({ error: 'An 18+ stream can only be shown over another 18+ stream' });++ p.source_msid = src;++ }++ await db.setPipsForManagedStream(ms.id, list);++ res.json({ pips: await db.getPipsForManagedStream(ms.id), max: db.MAX_PIPS });++ } catch (err) {++ console.error('[PiP] Save error:', err.message);++ res.status(500).json({ error: 'Failed to save the picture-in-picture streams' });++ }++});++router.get('/pip-sources', requireAuth, async (req, res) => {++ const name = String(req.query.username || '').trim().replace(/^@/, '').slice(0, 64);++ const user = name ? await db.getUserByUsername(name) : req.user;++ if (!user || user.is_banned) return res.status(404).json({ error: 'No such streamer' });++ res.json({ username: user.username, slots: await db.getPipSourceSlots(user.id) });++});++// Delete a managed streamrouter.delete('/managed/:id', requireAuth, async (req, res) => {try {@@@ -1807,6 +1879,16 @@ router.post('/managed/:id/regenerate-key', requireAuth, async (req, res) => {// Its recording parts (each its own VOD, with where it starts in stream time), the broadcaster's picture sizes and// reconnects, in seconds since the stream started. The player rewinds across parts with it; a part whose VOD the// viewer cannot see is listed without its VOD.++// A live stream's PIPs now (attachPips), for a player already showing it: it asks again every 30 s, so a PIP whose++// stream goes live later appears, and one that ends goes.++router.get('/:id/pips', optionalAuth, async (req, res) => {++ const id = parseInt(req.params.id, 10);++ const stream = Number.isFinite(id) ? await db.get('SELECT id, managed_stream_id, is_live FROM streams WHERE id = ?', [id]) : null;++ if (!stream) return res.status(404).json({ error: 'Stream not found' });++ res.set('Cache-Control', 'public, max-age=15');++ res.json({ pips: (await attachPips({ managed_stream_id: stream.is_live ? stream.managed_stream_id : null })).pip_overlays });++});++router.get('/:id/history', optionalAuth, async (req, res) => {try {const stream = await db.getStreamById(req.params.id);@@@ -1879,9 +1961,7 @@ router.get('/:id', optionalAuth, async (req, res) => {// 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-- ? await db.getPipOverlayForManagedStream(stream.managed_stream_id)-- : null;++ await attachPips(stream);const networkFollows = require('../social/network-follows');if (req.user) stream.isFollowing = await networkFollows.isFollowing(req.user.id, stream.user_id);@@@ -1,5 +1,8 @@/**-- * Tranche 3 contract: the picture-in-picture camera is a SLOT, not a second track.++ * Tranche 3 contract: the picture-in-picture camera is a SLOT, not a second track. Since migration 0021 a slot shows up to++ * three of them (stream_pips), from any streamer's slots (owner, 2026-10-26: "it should be able to use other streams …++ * like any streams on the site … set their default position/size while also allowing users to adjust them"), and the++ * channel page draws them (it never did: /api/streams/channel/:username sent no overlay, so PIPs had stopped showing).** 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,@@@ -15,6 +18,13 @@ const root = path.join(__dirname, '..');const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');const db = require('../server/db/database');++const http = require('http');++const express = require('express');++// The routes take requireAuth/optionalAuth from the auth module when they load: act as `actAs` instead.++const auth = require('../server/auth/auth');++let actAs = null;++auth.requireAuth = (req, res, next) => { if (!actAs) return res.status(401).json({ error: 'Authentication required' }); req.user = actAs; next(); };++auth.optionalAuth = (req, res, next) => { if (actAs) req.user = actAs; next(); };(async () => {await db.initDb();@@@ -66,8 +76,10 @@ 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');++assert.ok(/stream\.pip_overlay = first \?/.test(routes), 'the viewer endpoints still expose the first live PIP for the N-1 player');++assert.ok(/await attachPips\(liveStream\);/.test(routes), 'the channel page\'s live streams carry their PIPs (they never did)');++assert.ok(/router\.get\('\/:id', optionalAuth[\s\S]{0,4000}await attachPips\(stream\);/.test(routes), 'and GET /:id');++console.log('OK G: the old single-PIP field keeps its checks; both stream routes carry PIPs');// ── H: the viewer overlay is an independent session, torn down with the player ───────const pip = read('public/js/stream-pip.js');@@@ -87,11 +99,12 @@ for (const msg of ['sfu-viewer-create-transport', 'sfu-viewer-consume', 'sfu-vieconsole.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(/localStorage\.setItem\(LS_KEY\(o\.hostId, o\.pip\.source_msid\)/.test(pip), 'geometry must persist per stream and per PIP');++assert.ok(/if \(!raw && first\) raw = JSON\.parse\(localStorage\.getItem\(LS_OLD\(streamId\)\)/.test(pip), 'the first PIP keeps where a viewer put the old single one');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(/muted: pip\.muted !== false/.test(pip),++ 'the overlay starts as the streamer chose, muted unless they said otherwise');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');@@@ -106,17 +119,17 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut// Live's robot controls are gone (robots are OpenVibe.Bot's): no control profile select anywhere.assert.ok(!ws.includes('id="bc-control-config"'), 'no control profile select in the workspace');-- assert.ok(settingsPanel.includes('id="bc-ws-pip-source"'),-- 'the PiP camera picker must render in the Settings tab');++ assert.ok(settingsPanel.includes('id="bc-ws-pip-list"') && settingsPanel.includes('id="bc-ws-pip-add"'),++ 'the PIP list must render in the Settings tab');// It is its own collapsible section, matching the other Settings groups.-- for (const label of ['Picture-in-picture Camera']) {++ for (const label of ['Picture-in-picture']) {// Anchor on the SUMMARY text: an HTML comment carries the same label just above// the <details>, and matching that would look in the wrong place entirely.const idx = settingsPanel.indexOf(`${label}</summary>`);assert.ok(idx > 0, `Settings must have a "${label}" collapsible section`);const before = settingsPanel.slice(Math.max(0, idx - 400), idx);-- assert.ok(before.includes('<details class="bc-ws-slot-settings">'),++ assert.ok(before.includes('<details class="bc-ws-slot-settings" id="bc-ws-pip-details">'),`"${label}" must be a collapsible <details> section like the rest of Settings`);}console.log('OK J: the PiP camera is a collapsible section under Settings, and there is no control profile');@@@ -127,8 +140,9 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mutconst ws = read('public/js/broadcast-workspace.js');assert.ok(/function _wsPipCornerToXY/.test(ws) && /function _wsPipXYToCorner/.test(ws),'corner <-> fraction conversion must exist in both directions');-- assert.ok(/pip_defaults: \(\(\) => \{/.test(ws), 'the save payload must include pip_defaults');-- assert.ok(/_wsPipDefaultsRestore\(ms\)/.test(ws), 'stored fractions must be restored into the selects');++ assert.ok(!/pip_source_msid|pip_defaults/.test(ws), 'the slot settings save no longer sends the single PIP (it would collapse the list)');++ assert.ok(/_wsPipLoad\(ms\.id\)/.test(ws) && /api\(`\/streams\/managed\/\$\{st\.msId\}\/pips`, \{ method: 'PUT'/.test(ws), 'the list loads with the slot and saves on its own');++ assert.ok(/_wsPipCornerToXY\(e\.target\.value, q\.defaults\.w\)/.test(ws), 'a row\'s corner is stored as fractions');// Exercise the conversion the way the UI does: every corner must land in its own// quadrant and stay fully inside the player box.@@@ -152,5 +166,99 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mutconsole.log('OK K: every corner/size default round-trips and stays inside the player box');}++// ── L: the migration carries the one PIP a slot had into stream_pips ─────────────────++{++ await db.run('DELETE FROM stream_pips');++ await db.run(`UPDATE managed_streams SET pip_source_msid = 11, pip_defaults = '{"x":0.1,"y":0.2,"w":0.9}' WHERE id = 10`);++ const sql = read('migrations/0021_stream_pips.sql');++ await db.run(sql.slice(sql.indexOf('INSERT INTO stream_pips')).trim().replace(/;$/, ''));++ const rows = await db.all('SELECT managed_stream_id, source_msid, x, y, w, muted FROM stream_pips');++ assert.deepStrictEqual(rows.map((r) => [Number(r.managed_stream_id), Number(r.source_msid), Number(r.x), Number(r.y), Number(r.w), Number(r.muted)]),++ [[10, 11, 0.1, 0.2, 0.6, 1]], 'the old PIP, its width kept to the most a PIP may be');++ console.log('OK L: migration 0021 carries the old single PIP over');++}++++// ── M: a slot's PIPs: in order, anyone's slot, live state, the 18+ rule, a banned owner's dropped ──++{++ await db.run("INSERT INTO users (id, username, password_hash) OVERRIDING SYSTEM VALUE VALUES (3, 'gone', 'x')");++ await db.run('UPDATE users SET is_banned = 1 WHERE id = 3');++ await db.run(`INSERT INTO managed_streams (id, user_id, title, slug, stream_key, is_nsfw) OVERRIDING SYSTEM VALUE VALUES++ (13, 2, 'Late night', 'late', 'key-nsfw', 1), (14, 3, 'Banned slot', NULL, 'key-banned', 0)`);++ await db.run('INSERT INTO streams (id, user_id, managed_stream_id, is_live, protocol) OVERRIDING SYSTEM VALUE VALUES (501, 2, 12, 1, ?)', ['rtmp']);++ const saved = await db.setPipsForManagedStream(10, [++ { source_msid: 12, x: 0.02, y: 0.02, w: 0.18, muted: false }, { source_msid: 11, x: 2, y: -1, w: 9 }, { source_msid: 13 }, { source_msid: 14 }]);++ assert.strictEqual(saved.length, 3, `at most ${db.MAX_PIPS}`);++ const pips = await db.getPipsForManagedStream(10);++ assert.deepStrictEqual(pips.map((p) => [p.source_msid, p.username, p.live, p.stream_id, p.protocol, p.muted]),++ [[12, 'someone-else', true, 501, 'rtmp', false], [11, 'owner', true, 500, 'webrtc', true]],++ "in order: another streamer's live RTMP slot, the owner's live webcam; an 18+ slot never shows over one that is not");++ assert.deepStrictEqual(pips[1].defaults, { x: 1, y: 0, w: 0.6 }, 'places are kept inside the player');++ const mirror = await db.get('SELECT pip_source_msid, pip_defaults FROM managed_streams WHERE id = 10');++ assert.deepStrictEqual([Number(mirror.pip_source_msid), JSON.parse(mirror.pip_defaults)], [12, { x: 0.02, y: 0.02, w: 0.18 }], 'the first is mirrored for the N-1 release');++ await db.run('UPDATE managed_streams SET is_nsfw = 1 WHERE id = 10');++ assert.ok((await db.getPipsForManagedStream(10)).some((p) => p.source_msid === 13), 'over an 18+ slot, an 18+ PIP shows');++ await db.run('UPDATE managed_streams SET is_nsfw = 0 WHERE id = 10');++ await db.setPipsForManagedStream(10, [{ source_msid: 14 }]);++ assert.deepStrictEqual(await db.getPipsForManagedStream(10), [], "a banned streamer's slot shows nowhere");++ console.log("OK M: up to three PIPs in order, anyone's slots, live state and protocol, 18+ and bans respected, the first mirrored");++}++++// ── N: the routes: only the slot's owner edits; the checks; anyone's slots to pick from; the channel page's PIPs ──++{++ const app = express();++ app.use(express.json());++ app.use('/api/streams', require('../server/streaming/routes'));++ const server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });++ const base = `http://127.0.0.1:${server.address().port}/api/streams`;++ const call = async (method, p, body) => {++ const r = await fetch(base + p, { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });++ return { status: r.status, body: await r.json().catch(() => null) };++ };++ try {++ const owner = await db.getUserById(1), other = await db.getUserById(2);++ actAs = null;++ assert.strictEqual((await call('GET', '/managed/10/pips')).status, 401);++ actAs = other;++ assert.strictEqual((await call('PUT', '/managed/10/pips', { pips: [] })).status, 403, "another streamer cannot change this slot's PIPs");++ actAs = owner;++ assert.strictEqual((await call('PUT', '/managed/10/pips', { pips: [{ source_msid: 10 }] })).body.error, 'A slot cannot show itself as a picture-in-picture');++ assert.match((await call('PUT', '/managed/10/pips', { pips: [{ source_msid: 13 }] })).body.error, /18\+ stream can only be shown over another 18\+ stream/);++ assert.strictEqual((await call('PUT', '/managed/10/pips', { pips: [{ source_msid: 14 }] })).status, 404, "a banned streamer's slot is not offered");++ assert.match((await call('PUT', '/managed/10/pips', { pips: [{ source_msid: 11 }, { source_msid: 11 }] })).body.error, /already/);++ assert.match((await call('PUT', '/managed/10/pips', { pips: [1, 2, 3, 4].map(() => ({ source_msid: 11 })) })).body.error, /At most 3/);++ const ok = await call('PUT', '/managed/10/pips', { pips: [{ source_msid: 11, x: 0.7, y: 0.7, w: 0.25 }, { source_msid: 12, muted: false }] });++ assert.deepStrictEqual(ok.body.pips.map((p) => p.source_msid), [11, 12], "your own slot and someone else's, in order");++ assert.deepStrictEqual((await call('GET', '/managed/10/pips')).body.pips.map((p) => p.source_msid), [11, 12]);++ const theirs = await call('GET', '/pip-sources?username=@Someone-Else');++ assert.deepStrictEqual([theirs.body.username, theirs.body.slots.map((sl) => [sl.id, sl.live, sl.nsfw])], ['someone-else', [[12, true, false], [13, false, true]]]);++ assert.deepStrictEqual((await call('GET', '/pip-sources')).body.slots.map((sl) => sl.id), [10, 11], 'your own slots without a name');++ assert.strictEqual((await call('GET', '/pip-sources?username=gone')).status, 404, 'a banned streamer is not found');++ // The old single-PIP field (an N-1 tab) replaces the list with that one.++ await call('PUT', '/managed/10', { pip_source_msid: 11, pip_defaults: { x: 0.5, y: 0.5, w: 0.3 } });++ assert.deepStrictEqual((await db.getPipsForManagedStream(10)).map((p) => [p.source_msid, p.defaults.w]), [[11, 0.3]]);++ await call('PUT', '/managed/10/pips', { pips: [{ source_msid: 11 }, { source_msid: 12 }] });++ // A viewer: the live stream of slot 10 carries its PIPs, on the poll route too.++ await db.run('INSERT INTO streams (id, user_id, managed_stream_id, is_live, protocol) OVERRIDING SYSTEM VALUE VALUES (502, 1, 10, 1, ?)', ['webrtc']);++ actAs = null;++ const polled = await call('GET', '/502/pips');++ assert.deepStrictEqual(polled.body.pips.map((p) => [p.source_msid, p.live, p.stream_id]), [[11, true, 500], [12, true, 501]]);++ assert.deepStrictEqual((await call('GET', '/500/pips')).body.pips, [], 'a slot without PIPs has none');++ console.log("OK N: only the owner edits; self, 18+, banned, duplicates and a fourth refused; anyone's slots listed; the old field and the poll route work");++ } finally { server.close(); }++}++++// ── O: the viewer draws every live PIP, picks up new ones and drops ended ones ──────────────++{++ const pip = read('public/js/stream-pip.js');++ assert.ok(/const pips = Array\.isArray\(stream\.pip_overlays\) \? stream\.pip_overlays/.test(pip), 'every PIP, the N-1 single one as a fallback');++ assert.ok(/fetch\(`\/api\/streams\/\$\{encodeURIComponent\(host\.streamId\)\}\/pips`/.test(pip) && /setInterval\(poll, POLL_MS\)/.test(pip), 'asks again every 30 s');++ assert.ok(/if \(!keep\.has\(key\)\) \{ close\(o\); host\.overlays\.delete\(key\); \}/.test(pip), 'an ended or removed PIP goes');++ assert.ok(/if \(o\.pip\.protocol === 'rtmp'\) await connectFlv\(o, videoEl\);/.test(pip), 'an RTMP source plays its FLV');++ assert.ok(!/token=/.test(pip), 'no token in a socket address');++ assert.ok(read('public/css/features/channel.css').includes('.ov-pip-restores .ov-pip-restore { position: static; }'), "hidden PIPs' pills sit in one row");++ console.log('OK O: the viewer draws every live PIP, polls, and plays WebRTC and RTMP sources');++}++console.log('✅ PiP camera slot test passed');++process.exit(0);})().catch((e) => { console.error(e); process.exit(1); });
@@ -74,6 +74,30 @@ Clip (the last 30 seconds) and More, a sheet with share, watch as viewer, choose
@@ -74,6 +74,30 @@ Clip (the last 30 seconds) and More, a sheet with share, watch as viewer, choose
one-minute clip, chat read aloud, the restream controls, stream health, all settings and **Full page** (the classic
one-minute clip, chat read aloud, the restream controls, stream health, all settings and **Full page** (the classic
page, with a button back). The studio drives the same functions as the page and reads its counters.
page, with a button back). The studio drives the same functions as the page and reads its counters.
+### Picture-in-picture
+A slot can show up to three other streams over its own:
+- your webcam on a slot of its own while the main slot is a screen share;
+- a second angle;
+- anyone's stream on OpenVibe.Live.
+
+Each stays a stream in its own right, with its own VOD, clips, transcript and restreams. A PIP shows while its stream is
+live, and appears within 30 seconds when that stream goes live after the page opened.
+
+**Setting it up:** open the slot's **Settings → Picture-in-picture**.
+- Add one of your slots, or type another streamer's @name and pick one of theirs.
+- For each PIP, choose where it starts (a corner and a size) and whether it starts with sound.
+- Changes save at once.
+
+**What viewers can do:** move, resize, mute or hide each PIP for themselves, and the page remembers it per stream.
+- A PIP that can't play shows "… is offline" and can still be hidden.
+- A PIP's name opens that streamer's channel.
+
+**Limits:** an 18+ stream shows only over another 18+ stream, and a banned streamer's slot shows nowhere.
+
+API: `GET`/`PUT /api/streams/managed/:id/pips` (`{ pips: [{ source_msid, x, y, w, muted }] }`, fractions of the
+player), `GET /api/streams/pip-sources?username=` (a streamer's slots), and `GET /api/streams/:id/pips` (a live
+stream's PIPs now). The stream objects carry `pip_overlays`.
+
### Viewer counts across platforms
### Viewer counts across platforms
A live stream's viewers are counted wherever it goes out (`server/streaming/viewer-places.js`): OpenVibe itself (the
A live stream's viewers are counted wherever it goes out (`server/streaming/viewer-places.js`): OpenVibe itself (the
larger of Chat's count and the player sockets'), RobotStreamer, and each restream destination of the stream's slot
larger of Chat's count and the player sockets'), RobotStreamer, and each restream destination of the stream's slot
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+-- phase: expand
+-- Picture-in-picture: the streams drawn over a slot's stream, each where its streamer put it and movable by every viewer
+-- (owner, 2026-10-26: "the streamer should be able to configure PIPs enabled for a stream, and it should be able to use
+-- other streams (maybe not even just that streamer, like any streams on the site) and should be able to set their
+-- default position/size while also allowing users to adjust them"). One row per PIP, up to three per slot:
+-- managed_stream_id the slot whose viewers see the PIP
+-- source_msid the slot drawn over it: one of the streamer's own, or anyone's; it shows while that slot is live
+-- position the order the streamer listed them in (the first is also the N-1 pip_source_msid)
+-- x, y, w where it starts, as fractions of the player (left, top, width), like pip_defaults
+-- muted 1: the PIP starts without sound (the stream underneath already carries the voice)
+-- managed_streams.pip_source_msid and pip_defaults stay for the N-1 release (the first PIP is mirrored there), and a
+-- contract migration drops them later.
+CREATE TABLE IF NOT EXISTS stream_pips (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ managed_stream_id bigint NOT NULL,
+ source_msid bigint NOT NULL,
+ position bigint NOT NULL DEFAULT 0,
+ x double precision NOT NULL DEFAULT 0.72,
+ y double precision NOT NULL DEFAULT 0.70,
+ w double precision NOT NULL DEFAULT 0.25,
+ muted bigint NOT NULL DEFAULT 1,
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ UNIQUE (managed_stream_id, source_msid),
+ CHECK (managed_stream_id <> source_msid),
+ FOREIGN KEY (managed_stream_id) REFERENCES managed_streams(id) ON DELETE CASCADE,
+ FOREIGN KEY (source_msid) REFERENCES managed_streams(id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_stream_pips_source ON stream_pips (source_msid);
+
+-- The one PIP a slot could have until now.
+INSERT INTO stream_pips (managed_stream_id, source_msid, x, y, w)
+SELECT m.id, m.pip_source_msid,
+ LEAST(1, GREATEST(0, COALESCE((NULLIF(m.pip_defaults, '')::json ->> 'x')::double precision, 0.72))),
+ LEAST(1, GREATEST(0, COALESCE((NULLIF(m.pip_defaults, '')::json ->> 'y')::double precision, 0.70))),
+ LEAST(0.6, GREATEST(0.08, COALESCE((NULLIF(m.pip_defaults, '')::json ->> 'w')::double precision, 0.25)))
+FROM managed_streams m
+WHERE m.pip_source_msid IS NOT NULL AND m.pip_source_msid <> m.id
+ AND EXISTS (SELECT 1 FROM managed_streams s WHERE s.id = m.pip_source_msid)
+ON CONFLICT (managed_stream_id, source_msid) DO NOTHING;
@@ -1926,6 +1926,26 @@
@@ -1926,6 +1926,26 @@
flex-direction: column;
flex-direction: column;
gap: 6px;
gap: 6px;
}
}
+/* Picture-in-picture streams (broadcast-workspace.js _wsPipRender): one row each, then the ways to add one. */
+.bc-ws-pip-list { display: flex; flex-direction: column; gap: 6px; }
+.bc-ws-pip-row {
+ display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto auto; align-items: center; gap: 6px;
+ padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px;
+}
+.bc-ws-pip-name { display: flex; align-items: center; gap: 7px; min-width: 0; font-size: 0.85rem; font-weight: 600; }
+.bc-ws-pip-name span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.bc-ws-pip-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted, #8b93ad); }
+.bc-ws-pip-dot.is-live { background: #ef4444; }
+.bc-ws-pip-sound { display: inline-flex; align-items: center; gap: 4px; font-size: 0.8rem; white-space: nowrap; }
+.bc-ws-pip-add { display: flex; flex-direction: column; gap: 6px; }
+.bc-ws-pip-add-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
+.bc-ws-pip-add-row .form-input { flex: 1; min-width: 9rem; }
+.bc-ws-pip-add-label { font-size: 0.8rem; color: var(--text-muted, #8b93ad); min-width: 1.6rem; }
+.bc-ws-pip-found { display: flex; flex-wrap: wrap; gap: 6px; }
+@media (max-width: 560px) {
+ .bc-ws-pip-row { grid-template-columns: minmax(0, 1fr) auto auto; }
+ .bc-ws-pip-name { grid-column: 1 / -1; }
+}
.bc-ws-restream-card {
.bc-ws-restream-card {
border: 1px solid var(--border);
border: 1px solid var(--border);
border-radius: 6px;
border-radius: 6px;
@@ -1266,7 +1266,17 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
@@ -1266,7 +1266,17 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
.ov-pip-actions { display: flex; gap: 3px; flex-shrink: 0; }
.ov-pip-actions { display: flex; gap: 3px; flex-shrink: 0; }
.ov-pip:hover .ov-pip-resize { opacity: 1; }
.ov-pip:hover .ov-pip-resize { opacity: 1; }
.ov-pip.is-offline .ov-pip-offline { display: flex; }
.ov-pip.is-offline .ov-pip-offline { display: flex; }
+/* The bar stays above the offline layer and shows while offline, so a PIP that cannot play can still be hidden (the
+ layer, drawn after the bar over the whole PIP, used to cover it). */
+.ov-pip-bar { z-index: 2; }
+.ov-pip-offline { z-index: 1; }
+.ov-pip.is-offline .ov-pip-bar { opacity: 1; pointer-events: auto; }
.ov-pip-restore:hover { background: rgba(0,0,0,.8); }
.ov-pip-restore:hover { background: rgba(0,0,0,.8); }
+/* Several PIPs (stream_pips): each hidden one's "Show …" pill, in one row in the corner; a PIP's name opens its channel. */
+.ov-pip-restores { position: absolute; right: 12px; bottom: 12px; z-index: 25; display: flex; flex-wrap: wrap-reverse; justify-content: flex-end; gap: 6px; max-width: calc(100% - 24px); }
+.ov-pip-restores .ov-pip-restore { position: static; }
+.ov-pip-title a { color: inherit; text-decoration: none; }
+.ov-pip-title a:hover, .ov-pip-title a:focus-visible { text-decoration: underline; }
@media (max-width: 640px) {
@media (max-width: 640px) {
.ov-pip-resize { width: 26px; height: 26px; opacity: .8; }
.ov-pip-resize { width: 26px; height: 26px; opacity: .8; }
@@ -636,45 +636,17 @@ function _wsRenderPanel() {
@@ -636,45 +636,17 @@ function _wsRenderPanel() {
<div class="bc-ws-profile-section">
<div class="bc-ws-profile-section">
<!-- Picture-in-picture camera -->
<!-- Picture-in-picture camera -->
- <details class="bc-ws-slot-settings">
- <summary><i class="fa-solid fa-video"></i> Picture-in-picture Camera</summary>
+ <details class="bc-ws-slot-settings" id="bc-ws-pip-details">
+ <summary><i class="fa-solid fa-clone"></i> Picture-in-picture</summary>
<div class="bc-ws-slot-settings-inner">
<div class="bc-ws-slot-settings-inner">
<p class="muted" style="font-size:0.82rem;margin-bottom:10px">
<p class="muted" style="font-size:0.82rem;margin-bottom:10px">
- Overlay another of your slots on top of this stream. The camera stays a stream
- in its own right — its own VOD, clips and restreams — so it can be your webcam,
- a second angle, or a co-host's slot. Viewers can move, resize, mute or hide it
- themselves; the values below are only where it starts.
+ Show up to three other streams over this one: your webcam on a slot of its own, a second
+ angle, or anyone's stream on OpenVibe.Live. Each stays a stream in its own right, with its own
+ VOD, clips and restreams, and shows while it is live. Viewers can move, resize, mute or hide
+ each one; what you set here is where it starts. Changes here save at once.
</p>
</p>
- <div class="form-group">
- <label>Camera slot</label>
- <select id="bc-ws-pip-source" class="form-input" onchange="_wsSlotSettingChanged();_wsPipDefaultsToggle()">
- <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>
- </div>
- <div id="bc-ws-pip-defaults" style="display:${ms.pip_source_msid ? '' : 'none'}">
- <div class="bc-ws-row">
- <div class="form-group" style="flex:1">
- <label>Starting corner</label>
- <select id="bc-ws-pip-corner" class="form-input form-input-sm" onchange="_wsSlotSettingChanged()">
- <option value="br">Bottom Right</option>
- <option value="bl">Bottom Left</option>
- <option value="tr">Top Right</option>
- <option value="tl">Top Left</option>
- </select>
- </div>
- <div class="form-group" style="flex:1">
- <label>Starting size</label>
- <select id="bc-ws-pip-size" class="form-input form-input-sm" onchange="_wsSlotSettingChanged()">
- <option value="0.18">Small</option>
- <option value="0.25">Medium</option>
- <option value="0.35">Large</option>
- </select>
- </div>
- </div>
- </div>
+ <div id="bc-ws-pip-list" class="bc-ws-pip-list"></div>
+ <div id="bc-ws-pip-add" class="bc-ws-pip-add"></div>
</div>
</div>
</details>
</details>
@@ -907,7 +879,7 @@ function _wsRenderPanel() {
@@ -907,7 +879,7 @@ function _wsRenderPanel() {
// Async post-render: check device permissions
// Async post-render: check device permissions
// Corner/size selects are derived from the stored {x,y,w} fractions.
// Corner/size selects are derived from the stored {x,y,w} fractions.
- _wsPipDefaultsRestore(ms);
+ _wsPipLoad(ms.id);
if (method === 'browser') {
if (method === 'browser') {
_wsInitDevicePicker(method, browserMode);
_wsInitDevicePicker(method, browserMode);
_wsSyncScreenShareHiddenDefaults(browserMode);
_wsSyncScreenShareHiddenDefaults(browserMode);
@@ -1721,13 +1693,6 @@ async function _wsSaveAll() {
@@ -1721,13 +1693,6 @@ 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,
- pip_defaults: (() => {
- const w = Number(document.getElementById('bc-ws-pip-size')?.value) || 0.25;
- const corner = document.getElementById('bc-ws-pip-corner')?.value || 'br';
- return { ...(_wsPipCornerToXY(corner, w)), w };
- })(),
};
};
const profileBlob = {
const profileBlob = {
@@ -2045,34 +2010,120 @@ function _wsPipXYToCorner(x, y) {
@@ -2045,34 +2010,120 @@ function _wsPipXYToCorner(x, y) {
return (y < 0.5 ? 't' : 'b') + (x < 0.5 ? 'l' : 'r');
return (y < 0.5 ? 't' : 'b') + (x < 0.5 ? 'l' : 'r');
}
}
-/** Show the geometry controls only when a camera slot is actually selected. */
-function _wsPipDefaultsToggle() {
- const on = !!document.getElementById('bc-ws-pip-source')?.value;
- _wsShowEl('bc-ws-pip-defaults', on);
-}
-
-/** Restore corner/size from the stored fractions when the panel renders. */
-function _wsPipDefaultsRestore(ms) {
- let d = {};
- try { d = typeof ms.pip_defaults === 'string' ? JSON.parse(ms.pip_defaults || '{}') : (ms.pip_defaults || {}); }
- catch { d = {}; }
- const sizeEl = document.getElementById('bc-ws-pip-size');
- const cornerEl = document.getElementById('bc-ws-pip-corner');
- if (sizeEl) {
- const w = Number(d.w);
- // Snap to the nearest offered size rather than showing an option that is not there.
- const opts = [0.18, 0.25, 0.35];
- const nearest = Number.isFinite(w)
- ? opts.reduce((a, b) => (Math.abs(b - w) < Math.abs(a - w) ? b : a))
- : 0.25;
- sizeEl.value = String(nearest);
- }
- if (cornerEl) {
- cornerEl.value = (Number.isFinite(Number(d.x)) && Number.isFinite(Number(d.y)))
- ? _wsPipXYToCorner(Number(d.x), Number(d.y))
- : 'br';
- }
- _wsPipDefaultsToggle();
+/* ── Picture-in-picture: the streams shown over this slot (stream_pips; PUT /streams/managed/:id/pips) ──
+ Up to three, each one of your slots or anyone's, with where it starts (a corner and a size) and whether it starts
+ with sound. The list saves on every change, apart from the slot's other settings. */
+const _PIP_SIZES = [['0.18', 'Small'], ['0.25', 'Medium'], ['0.35', 'Large']];
+const _PIP_CORNERS = [['br', 'Bottom right'], ['bl', 'Bottom left'], ['tr', 'Top right'], ['tl', 'Top left']];
+
+function _wsEl(tag, attrs, ...kids) {
+ const el = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs || {})) {
+ if (v == null || v === false) continue;
+ if (k === 'text') el.textContent = v;
+ else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
+ else el.setAttribute(k, v === true ? '' : v);
+ }
+ for (const kid of kids) if (kid != null) el.append(kid);
+ return el;
+}
+function _wsSelect(options, value, onchange, label) {
+ const sel = _wsEl('select', { class: 'form-input form-input-sm', 'aria-label': label, onchange });
+ for (const [v, t] of options) sel.append(_wsEl('option', { value: v, selected: String(v) === String(value), text: t }));
+ return sel;
+}
+
+async function _wsPipLoad(msId) {
+ _wsState.pip = { msId, list: [], max: 3, found: null };
+ const box = document.getElementById('bc-ws-pip-list');
+ if (box) box.replaceChildren(_wsEl('div', { class: 'bc-ws-loading', text: 'Loading…' }));
+ try {
+ const d = await api(`/streams/managed/${msId}/pips`);
+ if (!_wsState.pip || _wsState.pip.msId !== msId) return;
+ _wsState.pip.list = d.pips || [];
+ _wsState.pip.max = d.max || 3;
+ } catch (e) {
+ if (box) box.replaceChildren(_wsEl('p', { class: 'muted', text: `Could not load the picture-in-picture streams: ${e.message}` }));
+ return;
+ }
+ _wsPipRender();
+}
+
+/** Save `list` (rows of { source_msid, x, y, w, muted }) and redraw from what the server kept. */
+async function _wsPipSave(list) {
+ const st = _wsState.pip;
+ if (!st) return;
+ try {
+ const d = await api(`/streams/managed/${st.msId}/pips`, { method: 'PUT', body: { pips: list } });
+ st.list = d.pips || [];
+ toast('Picture-in-picture saved', 'success');
+ } catch (e) {
+ toast(e.message || 'Could not save the picture-in-picture streams', 'error');
+ }
+ _wsPipRender();
+}
+
+const _wsPipRow = (p) => ({ source_msid: p.source_msid, x: p.defaults.x, y: p.defaults.y, w: p.defaults.w, muted: p.muted });
+
+function _wsPipRender() {
+ const st = _wsState.pip;
+ const box = document.getElementById('bc-ws-pip-list');
+ const add = document.getElementById('bc-ws-pip-add');
+ if (!st || !box || !add) return;
+ const mine = (_wsState.managedStreams || []);
+ const ownIds = new Set(mine.map((m) => Number(m.id)));
+ box.replaceChildren(...st.list.map((p, i) => {
+ const change = (patch) => _wsPipSave(st.list.map((q, j) => (j === i ? { ..._wsPipRow(q), ...patch(q) } : _wsPipRow(q))));
+ const w = [0.18, 0.25, 0.35].reduce((a, b) => (Math.abs(b - p.defaults.w) < Math.abs(a - p.defaults.w) ? b : a));
+ const name = ownIds.has(p.source_msid) ? p.title : `${p.title} · @${p.username}`;
+ return _wsEl('div', { class: 'bc-ws-pip-row' },
+ _wsEl('div', { class: 'bc-ws-pip-name' },
+ _wsEl('span', { class: `bc-ws-pip-dot${p.live ? ' is-live' : ''}`, title: p.live ? 'Live now' : 'Offline: shows when it goes live' }),
+ _wsEl('span', { text: name })),
+ _wsSelect(_PIP_CORNERS, _wsPipXYToCorner(p.defaults.x, p.defaults.y), (e) => change((q) => ({ ..._wsPipCornerToXY(e.target.value, q.defaults.w) })), `Where ${p.title} starts`),
+ _wsSelect(_PIP_SIZES, String(w), (e) => change((q) => {
+ const nw = Number(e.target.value);
+ return { w: nw, ..._wsPipCornerToXY(_wsPipXYToCorner(q.defaults.x, q.defaults.y), nw) };
+ }), `How big ${p.title} starts`),
+ _wsEl('label', { class: 'bc-ws-pip-sound' },
+ _wsEl('input', { type: 'checkbox', checked: !p.muted, onchange: (e) => change(() => ({ muted: !e.target.checked })) }),
+ ' Sound'),
+ _wsEl('button', { type: 'button', class: 'btn btn-sm btn-ghost', 'aria-label': `Remove ${p.title}`, title: 'Remove',
+ onclick: () => _wsPipSave(st.list.filter((_, j) => j !== i).map(_wsPipRow)) }, _wsEl('i', { class: 'fa-solid fa-xmark' })));
+ }));
+ if (!st.list.length) box.append(_wsEl('p', { class: 'muted', style: 'font-size:0.82rem', text: 'No picture-in-picture streams yet.' }));
+
+ const used = new Set(st.list.map((p) => p.source_msid));
+ const full = st.list.length >= st.max;
+ const append = (sourceMsid) => {
+ const corner = ['br', 'bl', 'tr', 'tl'][st.list.length % 4];
+ _wsPipSave([...st.list.map(_wsPipRow), { source_msid: Number(sourceMsid), w: 0.25, ..._wsPipCornerToXY(corner, 0.25), muted: true }]);
+ };
+ if (full) { add.replaceChildren(_wsEl('p', { class: 'muted', style: 'font-size:0.82rem', text: `That is the most (${st.max}). Remove one to add another.` })); return; }
+ const own = mine.filter((m) => Number(m.id) !== st.msId && !used.has(Number(m.id)));
+ const ownSel = _wsSelect([['', 'One of your slots…'], ...own.map((m) => [m.id, m.title || `Slot ${m.id}`])], '', (e) => { if (e.target.value) append(e.target.value); }, 'Add one of your slots');
+ const who = _wsEl('input', { type: 'text', class: 'form-input form-input-sm', placeholder: '@streamer', 'aria-label': "Another streamer's name", maxlength: '64' });
+ const found = _wsEl('div', { class: 'bc-ws-pip-found' });
+ const find = async () => {
+ const name = who.value.trim().replace(/^@/, '');
+ if (!name) return;
+ found.replaceChildren(_wsEl('span', { class: 'muted', text: 'Looking…' }));
+ try {
+ const d = await api(`/streams/pip-sources?username=${encodeURIComponent(name)}`);
+ const slots = (d.slots || []).filter((sl) => sl.id !== st.msId && !used.has(sl.id));
+ if (!slots.length) { found.replaceChildren(_wsEl('span', { class: 'muted', text: `@${d.username} has no other stream slot to show.` })); return; }
+ found.replaceChildren(...slots.map((sl) => _wsEl('button', { type: 'button', class: 'btn btn-sm btn-secondary', onclick: () => append(sl.id) },
+ `${sl.title || `@${d.username}'s stream`}${sl.live ? ' · live' : ''}${sl.nsfw ? ' · 18+' : ''}`)));
+ } catch (e) {
+ found.replaceChildren(_wsEl('span', { class: 'muted', text: e.message || 'No such streamer' }));
+ }
+ };
+ who.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); find(); } });
+ add.replaceChildren(
+ _wsEl('div', { class: 'bc-ws-pip-add-row' }, _wsEl('span', { class: 'bc-ws-pip-add-label', text: 'Add' }), ownSel),
+ _wsEl('div', { class: 'bc-ws-pip-add-row' }, _wsEl('span', { class: 'bc-ws-pip-add-label', text: 'or' }), who,
+ _wsEl('button', { type: 'button', class: 'btn btn-sm btn-secondary', onclick: find }, 'Find their streams')),
+ found);
}
}
function _wsPopulateInlineDevices(selectId, devices) {
function _wsPopulateInlineDevices(selectId, devices) {
@@ -1,144 +1,158 @@
@@ -1,144 +1,158 @@
/* ───────────────────────────────────────────────────────────────
/* ───────────────────────────────────────────────────────────────
- 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.
+ stream-pip.js — the streams drawn over a live stream (picture-in-picture).
+
+ A slot lists up to three other slots (stream_pips, migration 0021): the streamer's own (their camera while the main
+ stream is a screen share) or anyone's on the site. Each is an ordinary stream in its own right, with its own VOD,
+ clips, transcript and restreams, so each PIP here is a second, self-contained viewer session: its own websocket,
+ mediasoup recv transport and consumers (an RTMP source plays its FLV through the same proxy as the main player). It
+ deliberately does not reuse stream-player.js's machinery, which is written around module-level singletons.
+
+ The server sends every PIP with the stream (`pip_overlays`: source slot, its live stream or null, the streamer's
+ default place and sound). Only live ones are drawn; every 30 s the list is asked for again
+ (GET /api/streams/:id/pips), so one that goes live appears and one that ends goes. Geometry is kept as FRACTIONS
+ of the player box, remembered per stream and per PIP, so a viewer who drags one out of the way finds it there next
+ time. A PIP that cannot play reads as "offline" and never stops the stream underneath.
─────────────────────────────────────────────────────────────── */
─────────────────────────────────────────────────────────────── */
(function () {
(function () {
'use strict';
'use strict';
- const LS_KEY = (streamId) => `ov-pip-geom-${streamId}`;
+ const LS_KEY = (streamId, sourceMsid) => `ov-pip-geom-${streamId}-${sourceMsid}`;
+ const LS_OLD = (streamId) => `ov-pip-geom-${streamId}`; // the single PIP before 0021: the first PIP still reads it
const MIN_W = 0.08, MAX_W = 0.60;
const MIN_W = 0.08, MAX_W = 0.60;
+ const POLL_MS = 30000;
- let state = null; // { streamId, srcStreamId, ws, device, transport, consumers, el, geom, gen }
+ let host = null; // { streamId, container, overlays: Map(source_msid → overlay), timer, gen }
let gen = 0;
let gen = 0;
/* ── geometry ─────────────────────────────────────────────── */
/* ── geometry ─────────────────────────────────────────────── */
- function loadGeom(streamId, defaults) {
+ 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 loadGeom(streamId, pip, first) {
+ const d = pip.defaults || {};
const fallback = {
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,
+ x: clamp(d.x, 0, 1, 0.72), y: clamp(d.y, 0, 1, 0.70), w: clamp(d.w, MIN_W, MAX_W, 0.25),
+ // The streamer's choice; by default muted: the stream underneath already carries the voice, and two live
+ // audio tracks would double every word.
+ hidden: false, muted: pip.muted !== false,
};
};
try {
try {
- const raw = JSON.parse(localStorage.getItem(LS_KEY(streamId)) || 'null');
+ let raw = JSON.parse(localStorage.getItem(LS_KEY(streamId, pip.source_msid)) || 'null');
+ if (!raw && first) raw = JSON.parse(localStorage.getItem(LS_OLD(streamId)) || 'null');
if (!raw) return fallback;
if (!raw) return fallback;
return {
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,
+ 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, muted: typeof raw.muted === 'boolean' ? raw.muted : fallback.muted,
};
};
} catch { return fallback; }
} catch { return fallback; }
}
}
- function saveGeom() {
- if (!state) return;
- try { localStorage.setItem(LS_KEY(state.streamId), JSON.stringify(state.geom)); } catch { /* */ }
+ function saveGeom(o) {
+ try { localStorage.setItem(LS_KEY(o.hostId, o.pip.source_msid), JSON.stringify(o.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(o) {
+ const { x, y, w, hidden } = o.geom;
+ o.el.style.left = `${x * 100}%`;
+ o.el.style.top = `${y * 100}%`;
+ o.el.style.width = `${w * 100}%`;
+ o.el.classList.toggle('is-hidden', !!hidden);
}
}
- 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 escapeHtml(s) {
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
}
- /* ── DOM ──────────────────────────────────────────────────── */
+ function muteButton(btn, muted, label) {
+ btn.title = muted ? `Turn on ${label}'s sound` : `Mute ${label}`;
+ btn.setAttribute('aria-label', btn.title);
+ btn.innerHTML = `<i class="fa-solid ${muted ? 'fa-volume-xmark' : 'fa-volume-high'}"></i>`;
+ }
- function build(container, label) {
+ function build(o, container) {
+ const label = o.pip.title || 'Picture-in-picture';
+ const href = o.pip.username ? `/@${encodeURIComponent(o.pip.username)}${o.pip.slug ? `/${encodeURIComponent(o.pip.slug)}` : ''}` : null;
const el = document.createElement('div');
const el = document.createElement('div');
el.className = 'ov-pip';
el.className = 'ov-pip';
+ el.dataset.pipSource = String(o.pip.source_msid);
el.innerHTML = `
el.innerHTML = `
<video class="ov-pip-video" autoplay playsinline muted></video>
<video class="ov-pip-video" autoplay playsinline muted></video>
<div class="ov-pip-bar">
<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-title" title="${escapeHtml(label)}"><i class="fa-solid fa-video"></i> ${href
+ ? `<a href="${escapeHtml(href)}" onclick="return handleLinkClick(event, '${escapeHtml(href)}')">${escapeHtml(label)}</a>` : escapeHtml(label)}</span>
<span class="ov-pip-actions">
<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>
+ <button type="button" data-act="mute"></button>
+ <button type="button" data-act="reset" title="Back to where the streamer put it" aria-label="Back to where the streamer put it"><i class="fa-solid fa-arrows-to-dot"></i></button>
+ <button type="button" data-act="hide" title="Hide ${escapeHtml(label)}" aria-label="Hide ${escapeHtml(label)}"><i class="fa-solid fa-eye-slash"></i></button>
</span>
</span>
</div>
</div>
<div class="ov-pip-resize" title="Drag to resize"></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>`;
+ <div class="ov-pip-offline"><i class="fa-solid fa-video-slash"></i><span>${escapeHtml(label)} is offline</span></div>`;
container.appendChild(el);
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;
- };
+ o.el = el;
+ muteButton(el.querySelector('[data-act="mute"]'), o.geom.muted, label);
el.querySelector('.ov-pip-actions').addEventListener('click', (e) => {
el.querySelector('.ov-pip-actions').addEventListener('click', (e) => {
const act = e.target.closest('[data-act]')?.dataset.act;
const act = e.target.closest('[data-act]')?.dataset.act;
if (!act) return;
if (!act) return;
e.stopPropagation();
e.stopPropagation();
- if (act === 'hide') { state.geom.hidden = true; saveGeom(); applyGeom(); showRestore(); }
+ if (act === 'hide') { o.geom.hidden = true; saveGeom(o); applyGeom(o); showRestore(o, container); }
else if (act === 'reset') {
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();
+ const d = o.pip.defaults || {};
+ o.geom.x = clamp(d.x, 0, 1, 0.72); o.geom.y = clamp(d.y, 0, 1, 0.70); o.geom.w = clamp(d.w, MIN_W, MAX_W, 0.25);
+ saveGeom(o); applyGeom(o);
} else if (act === 'mute') {
} else if (act === 'mute') {
- state.geom.muted = !state.geom.muted;
+ o.geom.muted = !o.geom.muted;
const v = el.querySelector('.ov-pip-video');
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();
+ v.muted = o.geom.muted;
+ if (!o.geom.muted) v.play().catch(() => {});
+ muteButton(e.target.closest('[data-act="mute"]'), o.geom.muted, label);
+ saveGeom(o);
}
}
});
});
+ wireDrag(o, container);
+ wireResize(o, container);
+ applyGeom(o);
+ if (o.geom.hidden) showRestore(o, container);
+ }
- wireDrag(el, container);
- wireResize(el, container);
- if (state?.geom?.hidden) showRestore();
- return el;
+ /** The "Show …" pill for a hidden PIP; pills of several hidden PIPs sit side by side. */
+ function showRestore(o, container) {
+ if (o.pill) return;
+ let bar = container.querySelector('.ov-pip-restores');
+ if (!bar) { bar = document.createElement('div'); bar.className = 'ov-pip-restores'; container.appendChild(bar); }
+ const pill = document.createElement('button');
+ pill.type = 'button';
+ pill.className = 'ov-pip-restore';
+ pill.innerHTML = `<i class="fa-solid fa-video"></i> Show ${escapeHtml(o.pip.title || 'picture-in-picture')}`;
+ pill.addEventListener('click', () => { o.geom.hidden = false; saveGeom(o); applyGeom(o); removeRestore(o); });
+ bar.appendChild(pill);
+ o.pill = pill;
+ }
+
+ function removeRestore(o) {
+ if (!o.pill) return;
+ const bar = o.pill.parentNode;
+ o.pill.remove();
+ o.pill = null;
+ if (bar && !bar.children.length) bar.remove();
}
}
/** Pointer-driven move, constrained so the overlay can never be dragged off-screen. */
/** Pointer-driven move, constrained so the overlay can never be dragged off-screen. */
- function wireDrag(el, container) {
+ function wireDrag(o, container) {
+ const el = o.el;
let start = null;
let start = null;
const onDown = (e) => {
const onDown = (e) => {
- if (e.target.closest('[data-act]') || e.target.closest('.ov-pip-resize')) return;
+ if (e.target.closest('[data-act]') || e.target.closest('.ov-pip-resize') || e.target.closest('a')) return;
const r = container.getBoundingClientRect();
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 };
+ start = { px: e.clientX, py: e.clientY, x: o.geom.x, y: o.geom.y, rw: r.width, rh: r.height };
el.classList.add('is-dragging');
el.classList.add('is-dragging');
el.setPointerCapture?.(e.pointerId);
el.setPointerCapture?.(e.pointerId);
e.preventDefault();
e.preventDefault();
@@ -147,18 +161,16 @@
@@ -147,18 +161,16 @@
if (!start) return;
if (!start) return;
const box = el.getBoundingClientRect();
const box = el.getBoundingClientRect();
const r = container.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();
+ o.geom.x = Math.min(Math.max(0, start.x + (e.clientX - start.px) / start.rw), Math.max(0, 1 - (box.width / r.width)));
+ o.geom.y = Math.min(Math.max(0, start.y + (e.clientY - start.py) / start.rh), Math.max(0, 1 - (box.height / r.height)));
+ applyGeom(o);
};
};
const onUp = (e) => {
const onUp = (e) => {
if (!start) return;
if (!start) return;
start = null;
start = null;
el.classList.remove('is-dragging');
el.classList.remove('is-dragging');
el.releasePointerCapture?.(e.pointerId);
el.releasePointerCapture?.(e.pointerId);
- saveGeom();
+ saveGeom(o);
};
};
el.addEventListener('pointerdown', onDown);
el.addEventListener('pointerdown', onDown);
el.addEventListener('pointermove', onMove);
el.addEventListener('pointermove', onMove);
@@ -166,41 +178,42 @@
@@ -166,41 +178,42 @@
el.addEventListener('pointercancel', onUp);
el.addEventListener('pointercancel', onUp);
}
}
- function wireResize(el, container) {
+ function wireResize(o, container) {
+ const el = o.el;
const handle = el.querySelector('.ov-pip-resize');
const handle = el.querySelector('.ov-pip-resize');
let start = null;
let start = null;
handle.addEventListener('pointerdown', (e) => {
handle.addEventListener('pointerdown', (e) => {
const r = container.getBoundingClientRect();
const r = container.getBoundingClientRect();
- start = { px: e.clientX, w: state.geom.w, rw: r.width };
+ start = { px: e.clientX, w: o.geom.w, rw: r.width };
el.classList.add('is-resizing');
el.classList.add('is-resizing');
handle.setPointerCapture?.(e.pointerId);
handle.setPointerCapture?.(e.pointerId);
e.preventDefault(); e.stopPropagation();
e.preventDefault(); e.stopPropagation();
});
});
handle.addEventListener('pointermove', (e) => {
handle.addEventListener('pointermove', (e) => {
if (!start) return;
if (!start) return;
- state.geom.w = clamp(start.w + (e.clientX - start.px) / start.rw, MIN_W, MAX_W, start.w);
+ o.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.
// Keep it inside the player as it grows.
const box = el.getBoundingClientRect(), r = container.getBoundingClientRect();
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();
+ o.geom.x = Math.min(o.geom.x, Math.max(0, 1 - box.width / r.width));
+ o.geom.y = Math.min(o.geom.y, Math.max(0, 1 - box.height / r.height));
+ applyGeom(o);
});
});
const end = (e) => {
const end = (e) => {
if (!start) return;
if (!start) return;
start = null;
start = null;
el.classList.remove('is-resizing');
el.classList.remove('is-resizing');
handle.releasePointerCapture?.(e.pointerId);
handle.releasePointerCapture?.(e.pointerId);
- saveGeom();
+ saveGeom(o);
};
};
handle.addEventListener('pointerup', end);
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
handle.addEventListener('pointercancel', end);
}
}
- function escapeHtml(s) {
- return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ function markOffline(o, off) {
+ if (o.el) o.el.classList.toggle('is-offline', !!off);
}
}
- /* ── viewer session ───────────────────────────────────────── */
+ /* ── viewer sessions ──────────────────────────────────────── */
function waitFor(ws, type, timeoutMs = 15000) {
function waitFor(ws, type, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
@@ -208,7 +221,7 @@
@@ -208,7 +221,7 @@
function on(ev) {
function on(ev) {
let m; try { m = JSON.parse(ev.data); } catch { return; }
let m; try { m = JSON.parse(ev.data); } catch { return; }
if (m.type === type) { clearTimeout(timer); ws.removeEventListener('message', on); resolve(m); }
if (m.type === type) { clearTimeout(timer); ws.removeEventListener('message', on); resolve(m); }
- else if (m.type === 'sfu-error' || m.type === 'sfu-source-unavailable') {
+ else if (m.type === 'sfu-error' || m.type === 'sfu-viewer-error' || m.type === 'sfu-source-unavailable') {
clearTimeout(timer); ws.removeEventListener('message', on); reject(new Error(m.error || 'pip: source unavailable'));
clearTimeout(timer); ws.removeEventListener('message', on); reject(new Error(m.error || 'pip: source unavailable'));
}
}
}
}
@@ -216,121 +229,150 @@
@@ -216,121 +229,150 @@
});
});
}
}
- 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;
+ const alive = (o) => host && host.overlays.get(o.pip.source_msid) === o && o.gen === host.gen && !o.closed;
+ /** A WebRTC source: a viewer of its mediasoup room, like the main player. */
+ async function connectWebrtc(o, videoEl) {
+ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
+ const ws = new WebSocket(`${proto}://${window.location.host}/ws/broadcast?streamId=${o.pip.stream_id}&role=viewer`);
+ o.ws = ws;
await new Promise((res, rej) => {
await new Promise((res, rej) => {
ws.addEventListener('open', res, { once: true });
ws.addEventListener('open', res, { once: true });
ws.addEventListener('error', () => rej(new Error('pip: websocket failed')), { once: true });
ws.addEventListener('error', () => rej(new Error('pip: websocket failed')), { once: true });
});
});
- if (myGen !== gen) { try { ws.close(); } catch { /* */ } return; }
-
+ if (!alive(o)) return;
+ ws.addEventListener('close', () => { if (alive(o)) markOffline(o, true); });
ws.send(JSON.stringify({ type: 'watch' }));
ws.send(JSON.stringify({ type: 'watch' }));
const ready = await waitFor(ws, 'sfu-viewer-ready', 20000);
const ready = await waitFor(ws, 'sfu-viewer-ready', 20000);
- if (myGen !== gen) return;
+ if (!alive(o)) return;
const mod = await (window.loadMediasoupClient ? window.loadMediasoupClient() : Promise.reject(new Error('pip: mediasoup-client unavailable')));
const mod = await (window.loadMediasoupClient ? window.loadMediasoupClient() : Promise.reject(new Error('pip: mediasoup-client unavailable')));
const Device = mod.Device || mod.default?.Device;
const Device = mod.Device || mod.default?.Device;
const device = new Device();
const device = new Device();
await device.load({ routerRtpCapabilities: ready.rtpCapabilities });
await device.load({ routerRtpCapabilities: ready.rtpCapabilities });
- if (myGen !== gen) return;
+ if (!alive(o)) return;
ws.send(JSON.stringify({ type: 'sfu-viewer-create-transport' }));
ws.send(JSON.stringify({ type: 'sfu-viewer-create-transport' }));
const tp = await waitFor(ws, 'sfu-viewer-transport-created');
const tp = await waitFor(ws, 'sfu-viewer-transport-created');
- if (myGen !== gen) return;
-
+ if (!alive(o)) return;
const iceServers = (typeof sanitizeIceServers === 'function' ? sanitizeIceServers(tp.iceServers) : tp.iceServers) || [];
const iceServers = (typeof sanitizeIceServers === 'function' ? sanitizeIceServers(tp.iceServers) : tp.iceServers) || [];
const transport = device.createRecvTransport({
const transport = device.createRecvTransport({
- id: tp.id, iceParameters: tp.iceParameters, iceCandidates: tp.iceCandidates,
- dtlsParameters: tp.dtlsParameters,
+ id: tp.id, iceParameters: tp.iceParameters, iceCandidates: tp.iceCandidates, dtlsParameters: tp.dtlsParameters,
iceServers: iceServers.length ? iceServers : [{ urls: 'stun:stun.l.google.com:19302' }],
iceServers: iceServers.length ? iceServers : [{ urls: 'stun:stun.l.google.com:19302' }],
});
});
- if (state) { state.device = device; state.transport = transport; }
-
+ o.transport = transport;
transport.on('connect', ({ dtlsParameters }, cb, errb) => {
transport.on('connect', ({ dtlsParameters }, cb, errb) => {
ws.send(JSON.stringify({ type: 'sfu-viewer-connect-transport', transportId: transport.id, dtlsParameters }));
ws.send(JSON.stringify({ type: 'sfu-viewer-connect-transport', transportId: transport.id, dtlsParameters }));
waitFor(ws, 'sfu-viewer-transport-connected').then(() => cb()).catch(errb);
waitFor(ws, 'sfu-viewer-transport-connected').then(() => cb()).catch(errb);
});
});
- transport.on('connectionstatechange', (st) => {
- if (st === 'failed' || st === 'closed') markOffline(true);
- });
+ transport.on('connectionstatechange', (st) => { if (st === 'failed' || st === 'closed') markOffline(o, true); });
const media = new MediaStream();
const media = new MediaStream();
for (const prod of (ready.producers || [])) {
for (const prod of (ready.producers || [])) {
- ws.send(JSON.stringify({
- type: 'sfu-viewer-consume', transportId: transport.id,
- producerId: prod.id, rtpCapabilities: device.rtpCapabilities,
- }));
+ 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');
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);
+ if (!alive(o)) return;
+ const consumer = await transport.consume({ id: cp.id, producerId: cp.producerId, kind: cp.kind, rtpParameters: cp.rtpParameters });
+ o.consumers.push(consumer);
media.addTrack(consumer.track);
media.addTrack(consumer.track);
ws.send(JSON.stringify({ type: 'sfu-viewer-resume', consumerId: cp.id }));
ws.send(JSON.stringify({ type: 'sfu-viewer-resume', consumerId: cp.id }));
}
}
-
videoEl.srcObject = media;
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); });
+ /** An RTMP source: its FLV through Live's proxy (flv.js, loaded by the main player when it needed it). */
+ async function connectFlv(o, videoEl) {
+ if (typeof flvjs === 'undefined' || !flvjs.isSupported()) throw new Error('pip: flv.js unavailable');
+ const player = flvjs.createPlayer({ type: 'flv', isLive: true, url: `/api/streams/rtmp-proxy/${o.pip.stream_id}.flv` },
+ { enableStashBuffer: false, liveBufferLatencyChasing: true });
+ o.flv = player;
+ player.attachMediaElement(videoEl);
+ player.on(flvjs.Events.ERROR, () => { if (alive(o)) markOffline(o, true); });
+ player.load();
}
}
- function markOffline(off) {
- if (!state?.el) return;
- state.el.classList.toggle('is-offline', !!off);
+ async function connect(o) {
+ const videoEl = o.el.querySelector('.ov-pip-video');
+ try {
+ if (o.pip.protocol === 'rtmp') await connectFlv(o, videoEl);
+ else await connectWebrtc(o, videoEl);
+ if (!alive(o)) return;
+ videoEl.muted = o.geom.muted;
+ videoEl.play().catch(() => {});
+ markOffline(o, false);
+ } catch (err) {
+ console.warn(`[PiP] ${o.pip.title || o.pip.source_msid} unavailable:`, err.message);
+ if (alive(o)) markOffline(o, true);
+ }
+ }
+
+ function close(o) {
+ o.closed = true;
+ try { o.consumers.forEach((c) => { try { c.close(); } catch { /* */ } }); } catch { /* */ }
+ try { o.transport?.close(); } catch { /* */ }
+ try { o.ws?.close(); } catch { /* */ }
+ try { o.flv?.destroy(); } catch { /* */ }
+ removeRestore(o);
+ try { o.el?.remove(); } catch { /* */ }
+ }
+
+ /* ── the list ─────────────────────────────────────────────── */
+
+ /** Draw the live PIPs of `pips` and drop the rest; a PIP whose source restarted (a new stream) reconnects. */
+ function sync(pips) {
+ if (!host) return;
+ const live = (Array.isArray(pips) ? pips : []).filter((p) => p && p.live && p.stream_id && p.source_msid);
+ const keep = new Set(live.map((p) => p.source_msid));
+ for (const [key, o] of host.overlays) {
+ if (!keep.has(key)) { close(o); host.overlays.delete(key); }
+ }
+ live.forEach((pip, i) => {
+ const cur = host.overlays.get(pip.source_msid);
+ if (cur && cur.pip.stream_id === pip.stream_id && !cur.el.classList.contains('is-offline')) return;
+ if (cur) { close(cur); host.overlays.delete(pip.source_msid); }
+ const o = { pip, hostId: host.streamId, gen: host.gen, geom: loadGeom(host.streamId, pip, i === 0), consumers: [], el: null, pill: null };
+ host.overlays.set(pip.source_msid, o);
+ build(o, host.container);
+ connect(o);
+ });
+ }
+
+ async function poll() {
+ if (!host) return;
+ const myGen = host.gen;
+ try {
+ const r = await fetch(`/api/streams/${encodeURIComponent(host.streamId)}/pips`, { credentials: 'same-origin' });
+ if (!r.ok) return;
+ const d = await r.json();
+ if (host && host.gen === myGen) sync(d.pips);
+ } catch { /* the next poll tries again */ }
}
}
/* ── public API ───────────────────────────────────────────── */
/* ── 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.
+ * Draw the PIPs of a stream (`pip_overlays`, or the N-1 `pip_overlay`). Safe to call on every player init; it tears
+ * down any previous ones first.
*/
*/
- async function attach(stream) {
+ function attach(stream) {
detach();
detach();
- const pip = stream?.pip_overlay;
- if (!pip || !pip.live || !pip.stream_id) return;
-
const container = document.getElementById('video-container');
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);
- }
+ if (!container || !stream || !stream.id) return;
+ const pips = Array.isArray(stream.pip_overlays) ? stream.pip_overlays
+ : (stream.pip_overlay ? [Object.assign({ source_msid: stream.pip_overlay.source_msid || 0 }, stream.pip_overlay)] : []);
+ if (!pips.length) return; // a slot with no PIPs never polls
+ host = { streamId: stream.id, container, overlays: new Map(), gen: ++gen, timer: null };
+ sync(pips);
+ host.timer = setInterval(poll, POLL_MS);
}
}
function detach() {
function detach() {
gen++;
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;
+ if (!host) return;
+ clearInterval(host.timer);
+ for (const o of host.overlays.values()) close(o);
+ try { host.container.querySelector('.ov-pip-restores')?.remove(); } catch { /* */ }
+ host = null;
}
}
window.streamPip = { attach, detach };
window.streamPip = { attach, detach };
@@ -1199,6 +1199,74 @@ async function getPipOverlayForManagedStream(managedStreamId) {
@@ -1199,6 +1199,74 @@ async function getPipOverlayForManagedStream(managedStreamId) {
} catch { return null; }
} catch { return null; }
}
}
+// ── Picture-in-picture (stream_pips, migration 0021) ─────────
+// A slot shows up to MAX_PIPS other slots over its stream: the streamer's own or anyone's, each starting where the
+// streamer put it (fractions of the player) and moved by every viewer for themselves (public/js/stream-pip.js).
+const MAX_PIPS = 3;
+const pipNum = (v, lo, hi, dflt) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt; };
+
+/**
+ * The PIPs of a slot, in its order, each with what a player needs: the source slot, its owner, and its live stream
+ * (null while it is offline). A source marked NSFW never shows over a slot that is not.
+ */
+async function getPipsForManagedStream(managedStreamId) {
+ try {
+ const host = await get('SELECT id, is_nsfw FROM managed_streams WHERE id = ?', [managedStreamId]);
+ if (!host) return [];
+ const rows = await all(`SELECT p.id, p.source_msid, p.position, p.x, p.y, p.w, p.muted,
+ m.title, m.slug, m.is_nsfw, m.protocol AS slot_protocol, u.username, u.display_name,
+ (SELECT s.id FROM streams s WHERE s.managed_stream_id = m.id AND s.is_live = 1 ORDER BY s.started_at DESC LIMIT 1) AS stream_id,
+ (SELECT s.protocol FROM streams s WHERE s.managed_stream_id = m.id AND s.is_live = 1 ORDER BY s.started_at DESC LIMIT 1) AS protocol
+ FROM stream_pips p JOIN managed_streams m ON m.id = p.source_msid JOIN users u ON u.id = m.user_id
+ WHERE p.managed_stream_id = ? AND (u.is_banned IS NULL OR u.is_banned = 0)
+ ORDER BY p.position ASC, p.id ASC LIMIT ${MAX_PIPS}`, [managedStreamId]);
+ return rows.filter((r) => !Number(r.is_nsfw) || Number(host.is_nsfw)).map((r) => ({
+ id: Number(r.id),
+ source_msid: Number(r.source_msid),
+ title: r.title || `${r.display_name || r.username}'s stream`,
+ username: r.username,
+ slug: r.slug || null,
+ stream_id: r.stream_id ? Number(r.stream_id) : null,
+ live: !!r.stream_id,
+ protocol: r.protocol || r.slot_protocol || 'webrtc',
+ defaults: { x: Number(r.x), y: Number(r.y), w: Number(r.w) },
+ muted: Number(r.muted) !== 0,
+ }));
+ } catch { return []; }
+}
+
+/**
+ * Replace a slot's PIPs with `pips` ([{ source_msid, x, y, w, muted }], at most MAX_PIPS, in order). The first is
+ * mirrored into managed_streams.pip_source_msid / pip_defaults for the N-1 release. Returns the saved list.
+ */
+async function setPipsForManagedStream(managedStreamId, pips) {
+ const list = (pips || []).slice(0, MAX_PIPS).map((p, i) => ({
+ source_msid: Number(p.source_msid), position: i,
+ x: pipNum(p.x, 0, 1, 0.72), y: pipNum(p.y, 0, 1, 0.70), w: pipNum(p.w, 0.08, 0.6, 0.25), muted: p.muted === false ? 0 : 1,
+ }));
+ await tx(async () => {
+ await run('DELETE FROM stream_pips WHERE managed_stream_id = ?', [managedStreamId]);
+ for (const p of list) {
+ await run(`INSERT INTO stream_pips (managed_stream_id, source_msid, position, x, y, w, muted) VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (managed_stream_id, source_msid) DO NOTHING`, [managedStreamId, p.source_msid, p.position, p.x, p.y, p.w, p.muted]);
+ }
+ const first = list[0];
+ await run('UPDATE managed_streams SET pip_source_msid = ?, pip_defaults = ?, updated_at = ov_now() WHERE id = ?',
+ [first ? first.source_msid : null, first ? JSON.stringify({ x: first.x, y: first.y, w: first.w }) : '{}', managedStreamId]);
+ });
+ return list;
+}
+
+/** A person's slots, as PIP sources: id, title, slug, and whether each is live now. */
+async function getPipSourceSlots(userId) {
+ try {
+ const rows = await all(`SELECT m.id, m.title, m.slug, m.is_nsfw,
+ EXISTS (SELECT 1 FROM streams s WHERE s.managed_stream_id = m.id AND s.is_live = 1) AS live
+ FROM managed_streams m WHERE m.user_id = ? ORDER BY m.sort_order ASC, m.id ASC LIMIT 50`, [userId]);
+ return rows.map((r) => ({ id: Number(r.id), title: r.title || null, slug: r.slug || null, nsfw: !!Number(r.is_nsfw), live: !!r.live }));
+ } catch { return []; }
+}
+
/** Slots that could serve as a PiP source for this user (everything except `excludeId`). */
/** Slots that could serve as a PiP source for this user (everything except `excludeId`). */
async function getPipCandidateSlots(userId, excludeId = null) {
async function getPipCandidateSlots(userId, excludeId = null) {
try {
try {
@@ -3589,7 +3657,7 @@ module.exports = {
@@ -3589,7 +3657,7 @@ module.exports = {
getManagedStreamBySlug, getManagedStreamByStreamKey, getManagedStreamByIdOrSlug,
getManagedStreamBySlug, getManagedStreamByStreamKey, getManagedStreamByIdOrSlug,
updateManagedStream, deleteManagedStream,
updateManagedStream, deleteManagedStream,
getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,
getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,
- getPipOverlayForManagedStream, getPipCandidateSlots,
+ getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, MAX_PIPS,
countManagedStreamsByUser, getManagedStreamLimit,
countManagedStreamsByUser, getManagedStreamLimit,
isValidManagedStreamSlug, isManagedStreamSlugTaken,
isValidManagedStreamSlug, isManagedStreamSlugTaken,
ensureStreamerRoleOnFeed,
ensureStreamerRoleOnFeed,
@@ -114,6 +114,17 @@ async function staffMayModerate(actor, ownerUserId) {
@@ -114,6 +114,17 @@ async function staffMayModerate(actor, ownerUserId) {
*/
*/
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
+// The PIPs a player draws over a live stream (stream_pips): every one its slot lists, each live or not (public/js/
+// stream-pip.js shows the live ones and picks up one that goes live), and the first live one as pip_overlay, the shape
+// the N-1 player reads.
+async function attachPips(stream) {
+ const pips = stream && stream.managed_stream_id ? await db.getPipsForManagedStream(stream.managed_stream_id) : [];
+ stream.pip_overlays = pips;
+ const first = pips.find((p) => p.live);
+ stream.pip_overlay = first ? { source_msid: first.source_msid, title: first.title, slug: first.slug, stream_id: first.stream_id, live: true, defaults: first.defaults } : null;
+ return stream;
+}
+
function hasOwn(obj, key) {
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj || {}, key);
return Object.prototype.hasOwnProperty.call(obj || {}, key);
}
}
@@ -244,6 +255,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -244,6 +255,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
// Whether this live slot is being recorded server-side — if so, the client can
// Whether this live slot is being recorded server-side — if so, the client can
// cut clips on the SERVER (no CPU-heavy client-side MediaRecorder buffer).
// cut clips on the SERVER (no CPU-heavy client-side MediaRecorder buffer).
try { liveStream.server_clip = !!recorder.getActiveRecording(liveStream.id); } catch { liveStream.server_clip = false; }
try { liveStream.server_clip = !!recorder.getActiveRecording(liveStream.id); } catch { liveStream.server_clip = false; }
+ await attachPips(liveStream);
delete liveStream.stream_key;
delete liveStream.stream_key;
delete liveStream.managed_stream_key;
delete liveStream.managed_stream_key;
}
}
@@ -1726,6 +1738,12 @@ router.put('/managed/:id', requireAuth, async (req, res) => {
@@ -1726,6 +1738,12 @@ router.put('/managed/:id', requireAuth, async (req, res) => {
}
}
await db.updateManagedStream(msId, ms.user_id, fields);
await db.updateManagedStream(msId, ms.user_id, fields);
+ // The one PIP an older page sets (pip_source_msid, pip_defaults) becomes the slot's whole PIP list (stream_pips).
+ if (hasOwn(req.body, 'pip_source_msid')) {
+ let d = {};
+ try { d = fields.pip_defaults ? JSON.parse(fields.pip_defaults) : {}; } catch { d = {}; }
+ await db.setPipsForManagedStream(msId, fields.pip_source_msid ? [{ source_msid: fields.pip_source_msid, ...d }] : []);
+ }
const updated = await db.getManagedStreamById(msId);
const updated = await db.getManagedStreamById(msId);
// If the title changed and this slot has a RobotStreamer integration, mirror the
// If the title changed and this slot has a RobotStreamer integration, mirror the
@@ -1747,6 +1765,60 @@ router.put('/managed/:id', requireAuth, async (req, res) => {
@@ -1747,6 +1765,60 @@ router.put('/managed/:id', requireAuth, async (req, res) => {
}
}
});
});
+// ── Picture-in-picture (stream_pips, migration 0021) ─────────
+// Up to db.MAX_PIPS streams over a slot's stream: the streamer's own slots or anyone's (owner, 2026-10-26: "it should be
+// able to use other streams (maybe not even just that streamer, like any streams on the site)"), each starting where
+// the streamer put it; every viewer moves and resizes them for themselves (public/js/stream-pip.js).
+// GET /managed/:id/pips → { pips, max }
+// PUT /managed/:id/pips { pips: [{ source_msid, x, y, w, muted }] } → { pips, max }: replaces them, in that order
+// GET /pip-sources?username= → { username, slots }: a person's slots to pick from (your own without one)
+async function pipSlotFor(req, res) {
+ const msId = parseInt(req.params.id, 10);
+ const ms = Number.isFinite(msId) ? await db.getManagedStreamById(msId) : null;
+ if (!ms) { res.status(404).json({ error: 'Managed stream not found' }); return null; }
+ if (ms.user_id !== req.user.id && !await staffMayModerate(req.user, ms.user_id)) { res.status(403).json({ error: 'Not your managed stream' }); return null; }
+ return ms;
+}
+router.get('/managed/:id/pips', requireAuth, async (req, res) => {
+ const ms = await pipSlotFor(req, res);
+ if (!ms) return;
+ res.json({ pips: await db.getPipsForManagedStream(ms.id), max: db.MAX_PIPS });
+});
+router.put('/managed/:id/pips', requireAuth, async (req, res) => {
+ try {
+ const ms = await pipSlotFor(req, res);
+ if (!ms) return;
+ const list = req.body && req.body.pips;
+ if (!Array.isArray(list)) return res.status(400).json({ error: 'pips must be a list' });
+ if (list.length > db.MAX_PIPS) return res.status(400).json({ error: `At most ${db.MAX_PIPS} picture-in-picture streams` });
+ const seen = new Set();
+ for (const p of list) {
+ const src = parseInt(p && p.source_msid, 10);
+ if (!Number.isFinite(src)) return res.status(400).json({ error: 'Each picture-in-picture needs a stream slot' });
+ if (src === ms.id) return res.status(400).json({ error: 'A slot cannot show itself as a picture-in-picture' });
+ if (seen.has(src)) return res.status(400).json({ error: 'That stream is already a picture-in-picture here' });
+ seen.add(src);
+ const slot = await db.getManagedStreamById(src);
+ if (!slot) return res.status(404).json({ error: 'That stream slot does not exist' });
+ const owner = slot.user_id === ms.user_id ? null : await db.getUserById(slot.user_id);
+ if (owner && owner.is_banned) return res.status(404).json({ error: 'That stream slot does not exist' });
+ if (Number(slot.is_nsfw) && !Number(ms.is_nsfw)) return res.status(400).json({ error: 'An 18+ stream can only be shown over another 18+ stream' });
+ p.source_msid = src;
+ }
+ await db.setPipsForManagedStream(ms.id, list);
+ res.json({ pips: await db.getPipsForManagedStream(ms.id), max: db.MAX_PIPS });
+ } catch (err) {
+ console.error('[PiP] Save error:', err.message);
+ res.status(500).json({ error: 'Failed to save the picture-in-picture streams' });
+ }
+});
+router.get('/pip-sources', requireAuth, async (req, res) => {
+ const name = String(req.query.username || '').trim().replace(/^@/, '').slice(0, 64);
+ const user = name ? await db.getUserByUsername(name) : req.user;
+ if (!user || user.is_banned) return res.status(404).json({ error: 'No such streamer' });
+ res.json({ username: user.username, slots: await db.getPipSourceSlots(user.id) });
+});
+
// Delete a managed stream
// Delete a managed stream
router.delete('/managed/:id', requireAuth, async (req, res) => {
router.delete('/managed/:id', requireAuth, async (req, res) => {
try {
try {
@@ -1807,6 +1879,16 @@ router.post('/managed/:id/regenerate-key', requireAuth, async (req, res) => {
@@ -1807,6 +1879,16 @@ router.post('/managed/:id/regenerate-key', requireAuth, async (req, res) => {
// Its recording parts (each its own VOD, with where it starts in stream time), the broadcaster's picture sizes and
// Its recording parts (each its own VOD, with where it starts in stream time), the broadcaster's picture sizes and
// reconnects, in seconds since the stream started. The player rewinds across parts with it; a part whose VOD the
// reconnects, in seconds since the stream started. The player rewinds across parts with it; a part whose VOD the
// viewer cannot see is listed without its VOD.
// viewer cannot see is listed without its VOD.
+// A live stream's PIPs now (attachPips), for a player already showing it: it asks again every 30 s, so a PIP whose
+// stream goes live later appears, and one that ends goes.
+router.get('/:id/pips', optionalAuth, async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const stream = Number.isFinite(id) ? await db.get('SELECT id, managed_stream_id, is_live FROM streams WHERE id = ?', [id]) : null;
+ if (!stream) return res.status(404).json({ error: 'Stream not found' });
+ res.set('Cache-Control', 'public, max-age=15');
+ res.json({ pips: (await attachPips({ managed_stream_id: stream.is_live ? stream.managed_stream_id : null })).pip_overlays });
+});
+
router.get('/:id/history', optionalAuth, async (req, res) => {
router.get('/:id/history', optionalAuth, async (req, res) => {
try {
try {
const stream = await db.getStreamById(req.params.id);
const stream = await db.getStreamById(req.params.id);
@@ -1879,9 +1961,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -1879,9 +1961,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
// which live stream to put in the overlay and where to start it. Null whenever
// 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
// nothing is configured or the camera slot is not currently live, so the player
// simply renders no overlay.
// simply renders no overlay.
- stream.pip_overlay = stream.managed_stream_id
- ? await db.getPipOverlayForManagedStream(stream.managed_stream_id)
- : null;
+ await attachPips(stream);
const networkFollows = require('../social/network-follows');
const networkFollows = require('../social/network-follows');
if (req.user) stream.isFollowing = await networkFollows.isFollowing(req.user.id, stream.user_id);
if (req.user) stream.isFollowing = await networkFollows.isFollowing(req.user.id, stream.user_id);
@@ -1,5 +1,8 @@
@@ -1,5 +1,8 @@
/**
/**
- * Tranche 3 contract: the picture-in-picture camera is a SLOT, not a second track.
+ * Tranche 3 contract: the picture-in-picture camera is a SLOT, not a second track. Since migration 0021 a slot shows up to
+ * three of them (stream_pips), from any streamer's slots (owner, 2026-10-26: "it should be able to use other streams …
+ * like any streams on the site … set their default position/size while also allowing users to adjust them"), and the
+ * channel page draws them (it never did: /api/streams/channel/:username sent no overlay, so PIPs had stopped showing).
*
*
* Modelling the camera as an ordinary slot publishing an ordinary stream is what lets it
* 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,
* inherit everything the platform already does per stream — its own VOD, clips,
@@ -15,6 +18,13 @@ const root = path.join(__dirname, '..');
@@ -15,6 +18,13 @@ const root = path.join(__dirname, '..');
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
const db = require('../server/db/database');
const db = require('../server/db/database');
+const http = require('http');
+const express = require('express');
+// The routes take requireAuth/optionalAuth from the auth module when they load: act as `actAs` instead.
+const auth = require('../server/auth/auth');
+let actAs = null;
+auth.requireAuth = (req, res, next) => { if (!actAs) return res.status(401).json({ error: 'Authentication required' }); req.user = actAs; next(); };
+auth.optionalAuth = (req, res, next) => { if (actAs) req.user = actAs; next(); };
(async () => {
(async () => {
await db.initDb();
await db.initDb();
@@ -66,8 +76,10 @@ const routes = read('server/streaming/routes.js');
@@ -66,8 +76,10 @@ const routes = read('server/streaming/routes.js');
const put = routes.slice(routes.indexOf("router.put('/managed/:id'"), routes.indexOf("router.put('/managed/:id'") + 6000);
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(/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(/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');
+assert.ok(/stream\.pip_overlay = first \?/.test(routes), 'the viewer endpoints still expose the first live PIP for the N-1 player');
+assert.ok(/await attachPips\(liveStream\);/.test(routes), 'the channel page\'s live streams carry their PIPs (they never did)');
+assert.ok(/router\.get\('\/:id', optionalAuth[\s\S]{0,4000}await attachPips\(stream\);/.test(routes), 'and GET /:id');
+console.log('OK G: the old single-PIP field keeps its checks; both stream routes carry PIPs');
// ── H: the viewer overlay is an independent session, torn down with the player ───────
// ── H: the viewer overlay is an independent session, torn down with the player ───────
const pip = read('public/js/stream-pip.js');
const pip = read('public/js/stream-pip.js');
@@ -87,11 +99,12 @@ for (const msg of ['sfu-viewer-create-transport', 'sfu-viewer-consume', 'sfu-vie
@@ -87,11 +99,12 @@ for (const msg of ['sfu-viewer-create-transport', 'sfu-viewer-consume', 'sfu-vie
console.log('OK H: overlay is a full independent viewer session, attached and detached with the player');
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 ──────────────
// ── 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(/localStorage\.setItem\(LS_KEY\(o\.hostId, o\.pip\.source_msid\)/.test(pip), 'geometry must persist per stream and per PIP');
+assert.ok(/if \(!raw && first\) raw = JSON\.parse\(localStorage\.getItem\(LS_OLD\(streamId\)\)/.test(pip), 'the first PIP keeps where a viewer put the old single one');
assert.ok(/ov-pip-restore/.test(pip) && read('public/css/style.css').includes('.ov-pip-restore'),
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');
'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(/muted: pip\.muted !== false/.test(pip),
+ 'the overlay starts as the streamer chose, muted unless they said otherwise');
assert.ok(/is-offline/.test(pip), 'a camera going offline must degrade to a badge, not a broken overlay');
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');
console.log('OK I: geometry persists, hide is recoverable, audio defaults to muted, offline degrades gracefully');
@@ -106,17 +119,17 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
@@ -106,17 +119,17 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
// Live's robot controls are gone (robots are OpenVibe.Bot's): no control profile select anywhere.
// Live's robot controls are gone (robots are OpenVibe.Bot's): no control profile select anywhere.
assert.ok(!ws.includes('id="bc-control-config"'), 'no control profile select in the workspace');
assert.ok(!ws.includes('id="bc-control-config"'), 'no control profile select in the workspace');
- assert.ok(settingsPanel.includes('id="bc-ws-pip-source"'),
- 'the PiP camera picker must render in the Settings tab');
+ assert.ok(settingsPanel.includes('id="bc-ws-pip-list"') && settingsPanel.includes('id="bc-ws-pip-add"'),
+ 'the PIP list must render in the Settings tab');
// It is its own collapsible section, matching the other Settings groups.
// It is its own collapsible section, matching the other Settings groups.
- for (const label of ['Picture-in-picture Camera']) {
+ for (const label of ['Picture-in-picture']) {
// Anchor on the SUMMARY text: an HTML comment carries the same label just above
// Anchor on the SUMMARY text: an HTML comment carries the same label just above
// the <details>, and matching that would look in the wrong place entirely.
// the <details>, and matching that would look in the wrong place entirely.
const idx = settingsPanel.indexOf(`${label}</summary>`);
const idx = settingsPanel.indexOf(`${label}</summary>`);
assert.ok(idx > 0, `Settings must have a "${label}" collapsible section`);
assert.ok(idx > 0, `Settings must have a "${label}" collapsible section`);
const before = settingsPanel.slice(Math.max(0, idx - 400), idx);
const before = settingsPanel.slice(Math.max(0, idx - 400), idx);
- assert.ok(before.includes('<details class="bc-ws-slot-settings">'),
+ assert.ok(before.includes('<details class="bc-ws-slot-settings" id="bc-ws-pip-details">'),
`"${label}" must be a collapsible <details> section like the rest of Settings`);
`"${label}" must be a collapsible <details> section like the rest of Settings`);
}
}
console.log('OK J: the PiP camera is a collapsible section under Settings, and there is no control profile');
console.log('OK J: the PiP camera is a collapsible section under Settings, and there is no control profile');
@@ -127,8 +140,9 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
@@ -127,8 +140,9 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
const ws = read('public/js/broadcast-workspace.js');
const ws = read('public/js/broadcast-workspace.js');
assert.ok(/function _wsPipCornerToXY/.test(ws) && /function _wsPipXYToCorner/.test(ws),
assert.ok(/function _wsPipCornerToXY/.test(ws) && /function _wsPipXYToCorner/.test(ws),
'corner <-> fraction conversion must exist in both directions');
'corner <-> fraction conversion must exist in both directions');
- assert.ok(/pip_defaults: \(\(\) => \{/.test(ws), 'the save payload must include pip_defaults');
- assert.ok(/_wsPipDefaultsRestore\(ms\)/.test(ws), 'stored fractions must be restored into the selects');
+ assert.ok(!/pip_source_msid|pip_defaults/.test(ws), 'the slot settings save no longer sends the single PIP (it would collapse the list)');
+ assert.ok(/_wsPipLoad\(ms\.id\)/.test(ws) && /api\(`\/streams\/managed\/\$\{st\.msId\}\/pips`, \{ method: 'PUT'/.test(ws), 'the list loads with the slot and saves on its own');
+ assert.ok(/_wsPipCornerToXY\(e\.target\.value, q\.defaults\.w\)/.test(ws), 'a row\'s corner is stored as fractions');
// Exercise the conversion the way the UI does: every corner must land in its own
// Exercise the conversion the way the UI does: every corner must land in its own
// quadrant and stay fully inside the player box.
// quadrant and stay fully inside the player box.
@@ -152,5 +166,99 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
@@ -152,5 +166,99 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
console.log('OK K: every corner/size default round-trips and stays inside the player box');
console.log('OK K: every corner/size default round-trips and stays inside the player box');
}
}
+// ── L: the migration carries the one PIP a slot had into stream_pips ─────────────────
+{
+ await db.run('DELETE FROM stream_pips');
+ await db.run(`UPDATE managed_streams SET pip_source_msid = 11, pip_defaults = '{"x":0.1,"y":0.2,"w":0.9}' WHERE id = 10`);
+ const sql = read('migrations/0021_stream_pips.sql');
+ await db.run(sql.slice(sql.indexOf('INSERT INTO stream_pips')).trim().replace(/;$/, ''));
+ const rows = await db.all('SELECT managed_stream_id, source_msid, x, y, w, muted FROM stream_pips');
+ assert.deepStrictEqual(rows.map((r) => [Number(r.managed_stream_id), Number(r.source_msid), Number(r.x), Number(r.y), Number(r.w), Number(r.muted)]),
+ [[10, 11, 0.1, 0.2, 0.6, 1]], 'the old PIP, its width kept to the most a PIP may be');
+ console.log('OK L: migration 0021 carries the old single PIP over');
+}
+
+// ── M: a slot's PIPs: in order, anyone's slot, live state, the 18+ rule, a banned owner's dropped ──
+{
+ await db.run("INSERT INTO users (id, username, password_hash) OVERRIDING SYSTEM VALUE VALUES (3, 'gone', 'x')");
+ await db.run('UPDATE users SET is_banned = 1 WHERE id = 3');
+ await db.run(`INSERT INTO managed_streams (id, user_id, title, slug, stream_key, is_nsfw) OVERRIDING SYSTEM VALUE VALUES
+ (13, 2, 'Late night', 'late', 'key-nsfw', 1), (14, 3, 'Banned slot', NULL, 'key-banned', 0)`);
+ await db.run('INSERT INTO streams (id, user_id, managed_stream_id, is_live, protocol) OVERRIDING SYSTEM VALUE VALUES (501, 2, 12, 1, ?)', ['rtmp']);
+ const saved = await db.setPipsForManagedStream(10, [
+ { source_msid: 12, x: 0.02, y: 0.02, w: 0.18, muted: false }, { source_msid: 11, x: 2, y: -1, w: 9 }, { source_msid: 13 }, { source_msid: 14 }]);
+ assert.strictEqual(saved.length, 3, `at most ${db.MAX_PIPS}`);
+ const pips = await db.getPipsForManagedStream(10);
+ assert.deepStrictEqual(pips.map((p) => [p.source_msid, p.username, p.live, p.stream_id, p.protocol, p.muted]),
+ [[12, 'someone-else', true, 501, 'rtmp', false], [11, 'owner', true, 500, 'webrtc', true]],
+ "in order: another streamer's live RTMP slot, the owner's live webcam; an 18+ slot never shows over one that is not");
+ assert.deepStrictEqual(pips[1].defaults, { x: 1, y: 0, w: 0.6 }, 'places are kept inside the player');
+ const mirror = await db.get('SELECT pip_source_msid, pip_defaults FROM managed_streams WHERE id = 10');
+ assert.deepStrictEqual([Number(mirror.pip_source_msid), JSON.parse(mirror.pip_defaults)], [12, { x: 0.02, y: 0.02, w: 0.18 }], 'the first is mirrored for the N-1 release');
+ await db.run('UPDATE managed_streams SET is_nsfw = 1 WHERE id = 10');
+ assert.ok((await db.getPipsForManagedStream(10)).some((p) => p.source_msid === 13), 'over an 18+ slot, an 18+ PIP shows');
+ await db.run('UPDATE managed_streams SET is_nsfw = 0 WHERE id = 10');
+ await db.setPipsForManagedStream(10, [{ source_msid: 14 }]);
+ assert.deepStrictEqual(await db.getPipsForManagedStream(10), [], "a banned streamer's slot shows nowhere");
+ console.log("OK M: up to three PIPs in order, anyone's slots, live state and protocol, 18+ and bans respected, the first mirrored");
+}
+
+// ── N: the routes: only the slot's owner edits; the checks; anyone's slots to pick from; the channel page's PIPs ──
+{
+ const app = express();
+ app.use(express.json());
+ app.use('/api/streams', require('../server/streaming/routes'));
+ const server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
+ const base = `http://127.0.0.1:${server.address().port}/api/streams`;
+ const call = async (method, p, body) => {
+ const r = await fetch(base + p, { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
+ return { status: r.status, body: await r.json().catch(() => null) };
+ };
+ try {
+ const owner = await db.getUserById(1), other = await db.getUserById(2);
+ actAs = null;
+ assert.strictEqual((await call('GET', '/managed/10/pips')).status, 401);
+ actAs = other;
+ assert.strictEqual((await call('PUT', '/managed/10/pips', { pips: [] })).status, 403, "another streamer cannot change this slot's PIPs");
+ actAs = owner;
+ assert.strictEqual((await call('PUT', '/managed/10/pips', { pips: [{ source_msid: 10 }] })).body.error, 'A slot cannot show itself as a picture-in-picture');
+ assert.match((await call('PUT', '/managed/10/pips', { pips: [{ source_msid: 13 }] })).body.error, /18\+ stream can only be shown over another 18\+ stream/);
+ assert.strictEqual((await call('PUT', '/managed/10/pips', { pips: [{ source_msid: 14 }] })).status, 404, "a banned streamer's slot is not offered");
+ assert.match((await call('PUT', '/managed/10/pips', { pips: [{ source_msid: 11 }, { source_msid: 11 }] })).body.error, /already/);
+ assert.match((await call('PUT', '/managed/10/pips', { pips: [1, 2, 3, 4].map(() => ({ source_msid: 11 })) })).body.error, /At most 3/);
+ const ok = await call('PUT', '/managed/10/pips', { pips: [{ source_msid: 11, x: 0.7, y: 0.7, w: 0.25 }, { source_msid: 12, muted: false }] });
+ assert.deepStrictEqual(ok.body.pips.map((p) => p.source_msid), [11, 12], "your own slot and someone else's, in order");
+ assert.deepStrictEqual((await call('GET', '/managed/10/pips')).body.pips.map((p) => p.source_msid), [11, 12]);
+ const theirs = await call('GET', '/pip-sources?username=@Someone-Else');
+ assert.deepStrictEqual([theirs.body.username, theirs.body.slots.map((sl) => [sl.id, sl.live, sl.nsfw])], ['someone-else', [[12, true, false], [13, false, true]]]);
+ assert.deepStrictEqual((await call('GET', '/pip-sources')).body.slots.map((sl) => sl.id), [10, 11], 'your own slots without a name');
+ assert.strictEqual((await call('GET', '/pip-sources?username=gone')).status, 404, 'a banned streamer is not found');
+ // The old single-PIP field (an N-1 tab) replaces the list with that one.
+ await call('PUT', '/managed/10', { pip_source_msid: 11, pip_defaults: { x: 0.5, y: 0.5, w: 0.3 } });
+ assert.deepStrictEqual((await db.getPipsForManagedStream(10)).map((p) => [p.source_msid, p.defaults.w]), [[11, 0.3]]);
+ await call('PUT', '/managed/10/pips', { pips: [{ source_msid: 11 }, { source_msid: 12 }] });
+ // A viewer: the live stream of slot 10 carries its PIPs, on the poll route too.
+ await db.run('INSERT INTO streams (id, user_id, managed_stream_id, is_live, protocol) OVERRIDING SYSTEM VALUE VALUES (502, 1, 10, 1, ?)', ['webrtc']);
+ actAs = null;
+ const polled = await call('GET', '/502/pips');
+ assert.deepStrictEqual(polled.body.pips.map((p) => [p.source_msid, p.live, p.stream_id]), [[11, true, 500], [12, true, 501]]);
+ assert.deepStrictEqual((await call('GET', '/500/pips')).body.pips, [], 'a slot without PIPs has none');
+ console.log("OK N: only the owner edits; self, 18+, banned, duplicates and a fourth refused; anyone's slots listed; the old field and the poll route work");
+ } finally { server.close(); }
+}
+
+// ── O: the viewer draws every live PIP, picks up new ones and drops ended ones ──────────────
+{
+ const pip = read('public/js/stream-pip.js');
+ assert.ok(/const pips = Array\.isArray\(stream\.pip_overlays\) \? stream\.pip_overlays/.test(pip), 'every PIP, the N-1 single one as a fallback');
+ assert.ok(/fetch\(`\/api\/streams\/\$\{encodeURIComponent\(host\.streamId\)\}\/pips`/.test(pip) && /setInterval\(poll, POLL_MS\)/.test(pip), 'asks again every 30 s');
+ assert.ok(/if \(!keep\.has\(key\)\) \{ close\(o\); host\.overlays\.delete\(key\); \}/.test(pip), 'an ended or removed PIP goes');
+ assert.ok(/if \(o\.pip\.protocol === 'rtmp'\) await connectFlv\(o, videoEl\);/.test(pip), 'an RTMP source plays its FLV');
+ assert.ok(!/token=/.test(pip), 'no token in a socket address');
+ assert.ok(read('public/css/features/channel.css').includes('.ov-pip-restores .ov-pip-restore { position: static; }'), "hidden PIPs' pills sit in one row");
+ console.log('OK O: the viewer draws every live PIP, polls, and plays WebRTC and RTMP sources');
+}
+
console.log('✅ PiP camera slot test passed');
console.log('✅ PiP camera slot test passed');
+process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });