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)

OP OpenVibers committed 17 days ago · 396d1a11ec · 9 files, +702 −260

Changes

ddiff --git a/docs/broadcasting.md b/docs/broadcasting.md
iindex 57a85160..9dfdec5e 100644
---- a/docs/broadcasting.md
++++ b/docs/broadcasting.md
@@@ -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
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
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
ddiff --git a/migrations/0021_stream_pips.sql b/migrations/0021_stream_pips.sql
nnew file mode 100644
iindex 00000000..d7b4f958
---- /dev/null
++++ b/migrations/0021_stream_pips.sql
@@@ -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;
ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 789b0a30..6cfa168b 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -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;
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 3654203a..b7b7a432 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -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; }
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 835a4080..eae8115c 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -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) {
ddiff --git a/public/js/stream-pip.js b/public/js/stream-pip.js
iindex 0acf130c..111dc5ab 100644
---- a/public/js/stream-pip.js
++++ b/public/js/stream-pip.js
@@@ -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 it
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;
/* ── 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 52562855..607f15e1 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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,
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 7a6ad6e1..04eba013 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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 stream
router.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);
ddiff --git a/test/pip-camera-slot.test.js b/test/pip-camera-slot.test.js
iindex 6bf9c7b7..16712008 100644
---- a/test/pip-camera-slot.test.js
++++ b/test/pip-camera-slot.test.js
@@@ -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-vie
console.log('OK H: overlay is a full independent viewer session, attached and detached with the player');
// ── I: viewer control — geometry persists, and hiding stays recoverable ──────────────
--assert.ok(/localStorage\.setItem\(LS_KEY\(state\.streamId\)/.test(pip), 'geometry must persist per stream');
++assert.ok(/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 mut
const 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 mut
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');
++process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });