openvibers/openvibe.live Commits

Phones go live on H.264 with real encoder limits, live Video quality and a battery saver; no second encode, no frame copy every second (#365)

OP OpenVibers committed 19 days ago · ae064ab02a · 10 files, +568 −10

Phones encoded VP8 in software (the router's first codec; produce() got none) and the settings' bitrate, FPS and
codec never reached the encoder. A phone or tablet now sends H.264 (hardware), every browser broadcast gets
maxBitrate/maxFramerate, a start bitrate and maintain-framerate. Resolution, frame rate and bitrate change live
(applyBroadcastQuality); the phone studio's More sheet has Video (480/720/1080p, 30/60 fps, battery saver: this
device only). The browser's fallback VOD recorder asks the server first (GET /api/vods/stream/:id/recording) and
stops on a 403; on a phone the frame watchdog counts frames instead of grabbing one every second (48.7 ms each).


Claude-Session: https://claude.ai/code/session_01K8AYn3FF3HE7CPfM262qdh

Co-authored-by: Claude Opus 5.5 <[email protected]>

Changes

ddiff --git a/docs/broadcasting.md b/docs/broadcasting.md
iindex 763c391e..8819aee7 100644
---- a/docs/broadcasting.md
++++ b/docs/broadcasting.md
@@@ -103,3 +103,23 @@ Configurable per-stream settings include:
- TTS mode and voice settings
- Auto-reconnect behavior
- Stream title and category
++
++### What the browser encoder is asked for
++A browser broadcast reaches the SFU through mediasoup-client (`public/js/broadcast.js`, `_handleSfuProduceRequest`;
++the options come from `sfuVideoProduceOptions` in `broadcast-state.js`):
++- **Codec:** the settings' Codec Preference (VP8 or H.264). Under Auto, a phone or tablet sends **H.264**, which its
++ hardware encodes, and a computer sends the router's first codec (VP8). Screen shares stay on the router's first codec.
++ The recorder, restreams, live thumbnails and AI vision build their pipelines from the producer's codec, as they do
++ for H.264 from OBS over WHIP.
++- **Limits:** Max Bitrate and FPS are the encoding's `maxBitrate` and `maxFramerate`, and the start bitrate is
++ `min(2500, target)` kbps. A camera keeps its frame rate under pressure (`degradationPreference: maintain-framerate`:
++ resolution drops first); a screen share keeps its detail.
++- **Changed while live:** resolution, frame rate and bitrate apply without a restart (`applyBroadcastQuality`: the
++ camera's `applyConstraints` and the producer's encoding parameters), from the settings or the phone studio's Video
++ quality.
++- **Battery saver** (`openvibe_battery_saver` in localStorage, this device only): 480p, 30 fps, at most 1 Mbps.
++- The browser's own VOD recorder is only a fallback: it starts when `GET /api/vods/stream/:id/recording` says the
++ server is not recording the stream after about 24 seconds, never when the channel records nothing, and it stops when
++ the server refuses a slice (403).
++- On a phone, the frame watchdog reads the camera track's frame count (MediaStreamTrack stats) instead of copying a
++ frame every second (a computer still copies one each second: that keeps PipeWire capture awake).
ddiff --git a/docs/go-live-in-your-browser.md b/docs/go-live-in-your-browser.md
iindex 65bdfcbf..32be65c6 100644
---- a/docs/go-live-in-your-browser.md
++++ b/docs/go-live-in-your-browser.md
@@@ -35,8 +35,14 @@ You don't need any of the following:
- The top shows LIVE, how long you have been live, how many people are watching, your restreams and your connection.
- The buttons on the right are Flip (front and back camera), Mute, Camera, Light (a back camera's flash), Chat (hide or
show it), Clip (the last 30 seconds) and More.
--- **More** opens copy link, watch as a viewer, choose any camera, media requests, a one-minute clip, chat read aloud,
-- your restreams, stream health, all settings and the full page.
++- **More** opens copy link, watch as a viewer, choose any camera, media requests, a one-minute clip,
++ **video quality**, chat read aloud, your restreams, stream health, all settings and the full page.
++- **Video quality** changes while you are live, without restarting the stream: 480p, 720p or 1080p, at 30 or 60 frames
++ a second. It shows what is being sent now, and your choice becomes the stream slot's default for next time.
++ **Battery saver** (480p at 30 fps) keeps the phone cooler and the stream going longer; it is a setting of that phone
++ only.
++- A phone sends H.264, which its hardware encodes, so the stream uses less battery and keeps its frame rate. When the
++ network or the phone gets busy, the picture gets softer before it gets choppy.
- **End** asks before it ends the stream.
- Keep the OpenVibe tab open and in front while you are live. A phone pauses the camera in other tabs and apps, and
the stream ends after a minute away, so "Watch as a viewer" opens over the studio instead of in a new tab.
ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 13769964..b8ec3708 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -2646,6 +2646,32 @@ body.bc-studio-on .toast-container { z-index: 1500; }
.bcs-sec { margin-top: 14px; }
.bcs-sec h3 { margin: 0 0 8px; font-size: 0.9rem; }
.bcs-sec .bc-rs-control, .bcs-sec .bc-restream-control { display: flex !important; flex-direction: column; gap: 8px; margin: 0 0 8px; }
++/* Battery saver in the slot's Video quality section (public/js/broadcast-workspace.js): this device only. */
++.bc-ws-saver { display: flex; align-items: flex-start; gap: 10px; margin-top: 10px; cursor: pointer; }
++.bc-ws-saver input { flex: none; width: 18px; height: 18px; margin-top: 2px; }
++.bc-ws-saver span { display: grid; gap: 2px; min-width: 0; font-size: 0.86rem; }
++.bc-ws-saver small { color: var(--text-secondary, #a1a1aa); font-size: 0.78rem; }
++
++/* Video quality in the More sheet (public/js/broadcast-studio.js videoSection): resolution, frame rate, battery saver. */
++.bcs-video-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
++.bcs-video-head h3 { margin: 0; }
++.bcs-video-now { color: var(--text-secondary, #a1a1aa); font-size: 0.76rem; font-variant-numeric: tabular-nums; text-align: right; }
++.bcs-seg { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 4px; padding: 4px; margin-bottom: 8px; border-radius: 12px; background: var(--surface-raised, rgba(255,255,255,0.05)); }
++.bcs-seg-b {
++ min-height: 40px; border: 0; border-radius: 9px; background: none; color: inherit; cursor: pointer;
++ font: inherit; font-size: 0.86rem; font-weight: 700;
++}
++.bcs-seg-b[aria-pressed="true"] { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
++.bcs-seg-b:disabled { opacity: 0.45; cursor: default; }
++.bcs-video .bcs-row { margin-top: 4px; }
++.bcs-switch {
++ position: relative; flex: none; width: 52px; height: 32px; padding: 0; border-radius: 999px; cursor: pointer;
++ border: 1px solid var(--border, rgba(255,255,255,0.18)); background: var(--surface-raised, rgba(255,255,255,0.1));
++}
++.bcs-switch-k { position: absolute; top: 3px; left: 3px; width: 24px; height: 24px; border-radius: 50%; background: #fff; transition: transform 0.15s; }
++.bcs-switch[aria-checked="true"] { background: #16a34a; border-color: transparent; }
++.bcs-switch[aria-checked="true"] .bcs-switch-k { transform: translateX(20px); }
++@media (prefers-reduced-motion: reduce) { .bcs-switch-k { transition: none; } }
.bcs-confirm {
width: 100%; max-width: 360px; padding: 20px; border-radius: 18px; text-align: center;
background: var(--bg-secondary, #15151c); color: var(--text-primary, #f4f4f5); box-shadow: 0 20px 60px rgba(0,0,0,0.5);
ddiff --git a/public/js/broadcast-state.js b/public/js/broadcast-state.js
iindex f3b2f735..07af1763 100644
---- a/public/js/broadcast-state.js
++++ b/public/js/broadcast-state.js
@@@ -82,6 +82,27 @@ const BROADCAST_THUMBNAIL_INTERVAL_MS = 115000;
const KEEPALIVE_INTERVAL_MS = 1000;
const WATCHDOG_DEAD_THRESHOLD_MS = 15000; // 15s of grabFrame failures with 'live' readyState
const WATCHDOG_ENDED_THRESHOLD_MS = 8000; // 8s for track.readyState !== 'live' (definitive death)
++// A phone has no PipeWire to keep awake, and copying a whole camera frame every second costs it battery: it counts the
++// track's delivered frames instead (MediaStreamTrack stats, no copy), or grabs a frame every 5 s where those are missing.
++const MOBILE_GRAB_EVERY_TICKS = 5;
++
++/** A phone or tablet: a mobile browser (iPadOS says Macintosh, with a touch screen). */
++function isMobileBroadcaster() {
++ if (typeof navigator === 'undefined') return false;
++ if (navigator.userAgentData && navigator.userAgentData.mobile) return true;
++ const ua = navigator.userAgent || '';
++ return /Android|iPhone|iPad|iPod|Mobile/i.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
++}
++
++/** Frames the camera has handed out so far (MediaStreamTrack video stats), or null where the browser has none. */
++function _trackFrameCount(track) {
++ try {
++ const st = track && track.stats;
++ if (st && typeof st.totalFrames === 'number') return st.totalFrames;
++ if (st && typeof st.deliveredFrames === 'number') return st.deliveredFrames + (st.discardedFrames || 0);
++ } catch { /* */ }
++ return null;
++}
function startTrackKeepalive(streamId) {
const ss = getStreamState(streamId);
@@@ -96,6 +117,8 @@ function startTrackKeepalive(streamId) {
let lastSuccessfulFrame = Date.now();
let watchdogTriggered = false;
const streamRef = ss.localStream; // capture reference for staleness check
++ const mobile = isMobileBroadcaster();
++ let ticks = 0, lastFrameCount = null;
ss._trackKeepaliveInterval = setInterval(() => {
const current = getStreamState(streamId);
@@@ -144,6 +167,24 @@ function startTrackKeepalive(streamId) {
return;
}
++ if (mobile) {
++ // A camera the streamer turned off sends no frames on purpose.
++ if (!videoTrack.enabled) { lastSuccessfulFrame = Date.now(); watchdogTriggered = false; lastFrameCount = null; return; }
++ const count = _trackFrameCount(videoTrack);
++ if (count != null) {
++ if (lastFrameCount == null || count !== lastFrameCount) { lastSuccessfulFrame = Date.now(); watchdogTriggered = false; }
++ lastFrameCount = count;
++ const deadDuration = Date.now() - lastSuccessfulFrame;
++ if (deadDuration >= WATCHDOG_DEAD_THRESHOLD_MS && !watchdogTriggered) {
++ watchdogTriggered = true;
++ console.warn(`[Broadcast] Watchdog: no camera frames for ${(deadDuration / 1000).toFixed(1)}s — triggering recovery`);
++ scheduleMediaRecovery(streamId, 'no camera frames (watchdog)');
++ }
++ return;
++ }
++ if (ticks++ % MOBILE_GRAB_EVERY_TICKS) return;
++ }
++
if (!capture) return;
capture.grabFrame()
.then(bmp => {
@@@ -217,16 +258,75 @@ document.addEventListener('visibilitychange', () => {
}
});
++// Battery saver: 480p at 30 fps and at most 1 Mbps, whatever the slot asks. A setting of this device (its own
++// localStorage key): broadcastState.settings also goes to the slot on the server, and a phone's saver must not follow
++// the streamer to their computer.
++const BATTERY_SAVER = { res: '480', fps: 30, kbps: 1000 };
++const BATTERY_SAVER_KEY = 'openvibe_battery_saver';
++let _batterySaver = (() => { try { return localStorage.getItem(BATTERY_SAVER_KEY) === '1'; } catch { return false; } })();
++function batterySaverOn() { return _batterySaver; }
++function setBatterySaver(on) {
++ _batterySaver = !!on;
++ try { localStorage.setItem(BATTERY_SAVER_KEY, _batterySaver ? '1' : '0'); } catch { /* this visit only */ }
++}
++
function getBroadcastFrameRate() {
++ if (_batterySaver) return BATTERY_SAVER.fps;
const fps = parseInt(broadcastState.settings.broadcastFps, 10);
return Number.isFinite(fps) && fps > 0 ? fps : 30;
}
++/** The capture height asked for ('360' … '1440'). */
++function getBroadcastResolution() {
++ if (_batterySaver) return BATTERY_SAVER.res;
++ return String(broadcastState.settings.broadcastRes || '720');
++}
++
function getTargetVideoBitrate() {
++ if (_batterySaver) return BATTERY_SAVER.kbps * 1000;
const kbps = parseInt(broadcastState.settings.broadcastBps, 10);
return (Number.isFinite(kbps) && kbps > 0 ? kbps : 2500) * 1000;
}
++/**
++ * What the SFU producer of the broadcast's video sends. The codec: the one chosen in settings, else H.264 on a phone or
++ * tablet (its hardware encoder: VP8, the router's first, is a software encode on most phones and costs battery and
++ * frames), else the router's first (VP8). Screen shares stay on the router's first. At most the target bitrate and the
++ * chosen frame rate (the settings' Max Bitrate, FPS and Codec Preference never reached the encoder before).
++ * @param {object} device - a loaded mediasoup-client Device
++ */
++function sfuVideoProduceOptions(device, track) {
++ const s = broadcastState.settings;
++ const codecs = (device && device.rtpCapabilities && device.rtpCapabilities.codecs) || [];
++ const find = (name) => codecs.find((c) => c.kind === 'video' && String(c.mimeType).toLowerCase() === `video/${name}`) || null;
++ const chosen = s.broadcastCodec === 'h264' || s.broadcastCodec === 'vp8' ? s.broadcastCodec : null;
++ const want = chosen || (!s.screenShare && isMobileBroadcaster() ? 'h264' : null);
++ const codec = want ? find(want) : null;
++ const kbps = Math.round(getTargetVideoBitrate() / 1000);
++ const opts = {
++ track, stopTracks: false,
++ encodings: [{ maxBitrate: kbps * 1000, maxFramerate: getBroadcastFrameRate() }],
++ // Start near the target, so viewers get a sharp picture at once (the router gives VP8 this; H.264 had none).
++ codecOptions: { videoGoogleStartBitrate: Math.min(2500, kbps) },
++ };
++ if (codec) opts.codec = codec;
++ return opts;
++}
++
++/**
++ * Under a slow network or a busy phone, keep the frame rate and drop resolution first (a camera), or keep the detail
++ * (a screen share): the browser's default for a camera trades frames away, which reads as lag.
++ */
++async function applyVideoSenderPrefs(producer) {
++ const sender = producer && producer.rtpSender;
++ if (!sender || typeof sender.getParameters !== 'function') return;
++ try {
++ const params = sender.getParameters();
++ params.degradationPreference = broadcastState.settings.screenShare ? 'maintain-resolution' : 'maintain-framerate';
++ await sender.setParameters(params);
++ } catch { /* an older browser: its default stands */ }
++}
++
function getSuggestedScaleDown(settings) {
const res = String(settings.broadcastRes || '720');
const kbps = parseInt(settings.broadcastBps, 10) || 2500;
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex 56ce1f82..c7d3722e 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -419,6 +419,50 @@
}
// ── More ──
++ // ── Video quality, changed while live (owner, 2026-10-25: "adjustable settings for mobile") ──
++ // Resolution and frame rate apply at once (the camera and the encoder, no restart: broadcast.js
++ // applyBroadcastQuality) and become the slot's defaults. Battery saver is this phone's own setting.
++ const RES_KBPS = { '480': 1200, '720': 2500, '1080': 4500 };
++ const suggestedKbps = (res, fps) => Math.round(((RES_KBPS[res] || 2500) * (Number(fps) >= 50 ? 1.5 : 1)) / 100) * 100;
++ let qualityQueue = Promise.resolve();
++ function videoSection() {
++ const s = broadcastState.settings;
++ const saver = typeof batterySaverOn === 'function' && batterySaverOn();
++ const seg = (label, options, current, key) => el('div', { class: 'bcs-seg', role: 'group', 'aria-label': label },
++ ...options.map(([v, text]) => el('button', {
++ type: 'button', class: 'bcs-seg-b', 'aria-pressed': String(!saver && String(current) === v), disabled: saver,
++ onclick: () => setQuality({ [key]: v }),
++ }, text)));
++ return el('div', { class: 'bcs-sec bcs-video', id: 'bcs-video' },
++ el('div', { class: 'bcs-video-head' }, el('h3', { text: 'Video' }), el('small', { class: 'bcs-video-now', id: 'bcs-video-now', 'aria-live': 'polite' })),
++ seg('Resolution', [['480', '480p'], ['720', '720p'], ['1080', '1080p']], s.broadcastRes, 'broadcastRes'),
++ seg('Frame rate', [['30', '30 fps'], ['60', '60 fps']], s.broadcastFps, 'broadcastFps'),
++ el('div', { class: 'bcs-row' },
++ el('div', null, el('b', { id: 'bcs-saver-t', text: 'Battery saver' }), el('small', { text: '480p at 30 fps: a cooler phone and a longer stream' })),
++ el('button', { type: 'button', class: 'bcs-switch', role: 'switch', 'aria-checked': String(saver), 'aria-labelledby': 'bcs-saver-t',
++ onclick: () => setQuality({ batterySaver: !saver }) }, el('span', { class: 'bcs-switch-k', 'aria-hidden': 'true' }))));
++ }
++ function showVideoNow(info) {
++ const n = $('bcs-video-now');
++ if (!n || !info || !info.width) return;
++ n.textContent = `Sending ${info.width}×${info.height} · ${info.fps} fps${info.codec ? ` · ${info.codec}` : ''}`;
++ }
++ function setQuality(patch) {
++ const s = broadcastState.settings;
++ if ('batterySaver' in patch) setBatterySaver(patch.batterySaver);
++ else {
++ Object.assign(s, patch);
++ s.broadcastBps = String(suggestedKbps(String(s.broadcastRes), s.broadcastFps));
++ if (typeof saveBroadcastSettings === 'function') saveBroadcastSettings();
++ const ss = streamState();
++ const msId = ss && ss.streamData ? ss.streamData.managed_stream_id : null;
++ if (typeof _wsRememberQuality === 'function') _wsRememberQuality(msId, { resolution: s.broadcastRes, fps: s.broadcastFps, bitrate: s.broadcastBps }).catch(() => {});
++ }
++ const old = $('bcs-video');
++ if (old) old.replaceWith(videoSection());
++ qualityQueue = qualityQueue.then(() => applyBroadcastQuality()).then(showVideoNow, () => {});
++ }
++
function tile(ico, label, onClick, hint) {
return el('button', { type: 'button', class: 'bcs-tile', onclick: onClick },
icon(ico), el('span', { class: 'bcs-tile-t', text: label }), hint ? el('small', { text: hint }) : null);
@@@ -481,12 +525,13 @@
el('div', { class: 'bcs-sheet-who' }, el('b', { id: 'bcs-sheet-title', text: title }), user ? el('small', { text: `openvibe.live/@${user}` }) : null),
el('button', { type: 'button', class: 'bcs-chip bcs-chip-strong', onclick: share }, icon('fa-link'), ' Copy link'),
el('button', { type: 'button', class: 'bcs-x', 'aria-label': 'Close', onclick: closeSheet }, icon('fa-xmark'))),
-- grid, voice, st.parked.length ? restreams : null);
++ grid, typeof applyBroadcastQuality === 'function' ? videoSection() : null, voice, st.parked.length ? restreams : null);
st.sheet = el('div', { class: 'bcs-sheet-wrap', onclick: (e) => { if (e.target === st.sheet) closeSheet(); } }, sheet);
document.body.append(st.sheet);
const focus = section === 'restreams' && st.parked.length ? restreams : sheet.querySelector('.bcs-tile');
if (section === 'restreams' && st.parked.length) restreams.scrollIntoView({ block: 'start' });
if (focus && focus.focus) focus.focus({ preventScroll: section === 'restreams' });
++ if (typeof broadcastVideoInfo === 'function') showVideoNow(broadcastVideoInfo());
}
function closeSheet() {
for (const { node, mark } of st.parked.splice(0)) { mark.replaceWith(node); }
@@@ -550,6 +595,8 @@
// ── Once a second: mount, update or leave ──
function tick() {
++ // A hidden tab draws nothing: no work until it shows again (visibilitychange below).
++ if (document.hidden) return;
const has = !!localStream();
if (has !== st.hadLocal) {
st.hadLocal = has;
@@@ -563,6 +610,7 @@
syncGoLiveDock();
}
setInterval(tick, 1000);
++ document.addEventListener('visibilitychange', tick);
if (NARROW.addEventListener) NARROW.addEventListener('change', tick);
ensureReturn();
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex f7addb4a..835a4080 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -528,6 +528,11 @@ function _wsRenderPanel() {
oninput="_wsMarkDirty()">
</div>
</div>
++ <label class="bc-ws-saver">
++ <input type="checkbox" id="bc-ws-battery-saver"${typeof batterySaverOn === 'function' && batterySaverOn() ? ' checked' : ''}
++ onchange="setBatterySaver(this.checked); if (broadcastState.activeStreamId) applyBroadcastQuality().catch(() => {})">
++ <span><b>Battery saver on this device</b><small>480p at 30 fps, whatever is set above: a cooler phone and a longer stream. Change it while live under More → Video.</small></span>
++ </label>
</details>
</div>
@@@ -1024,6 +1029,23 @@ function _wsConfirmAction(message, { title = 'Are you sure?', okLabel = 'Confirm
});
}
++/**
++ * Keep a quality change made while live (the phone studio's Video quality) as the slot's default for next time: the
++ * slot's settings blob with the new resolution, frame rate and bitrate, and the Video quality form, when it shows that
++ * slot.
++ */
++async function _wsRememberQuality(managedStreamId, { resolution, fps, bitrate }) {
++ if (!managedStreamId || Number(managedStreamId) !== Number(_wsState.selectedId) || !_wsState.profile) return false;
++ const profile = { ..._wsState.profile, resolution: String(resolution), fps: String(fps), bitrate: Number(bitrate) };
++ await api('/streams/broadcast-settings', { method: 'PUT', body: { managed_stream_id: Number(managedStreamId), settings: profile } });
++ _wsState.profile = profile;
++ const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = String(v); };
++ set('bc-ws-resolution', profile.resolution); set('bc-ws-fps', profile.fps); set('bc-ws-bitrate', profile.bitrate);
++ const sum = document.querySelector('.bc-ws-quality .bc-sum-val');
++ if (sum) sum.textContent = `${profile.resolution}p · ${profile.fps} fps · ${profile.bitrate} kbps`;
++ return true;
++}
++
/* ── Live stats timer ────────────────────────────────────────── */
function _wsStartLiveStatsTimer(liveSessionId, liveSessionData) {
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 86041f82..6b8723c6 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -1995,6 +1995,8 @@ function syncSettingsUI() {
function updateBroadcastSetting(key, value) {
broadcastState.settings[key] = value; saveBroadcastSettings();
++ // Size, frame rate and bitrate apply to a live broadcast at once (no restart).
++ if (['broadcastRes', 'broadcastFps', 'broadcastBps'].includes(key) && broadcastState.activeStreamId) applyBroadcastQuality().catch(() => {});
}
/**
@@@ -2431,6 +2433,11 @@ function startVodRecording(streamId) {
if (!ss || !ss.localStream) return;
if (ss.vodRecorder && ss.vodRecorder.state !== 'inactive') return;
if (ss.vodServerRecording) return; // server ingest is recording this stream — no browser chunks
++ if (ss.vodOff) return; // the channel records nothing
++ // This recorder is only the fallback for a stream the server does not record itself (Media ingest from the SFU):
++ // a second encode of the camera on the streamer's device, and a 30 s slice uploaded only to be dropped. Ask first;
++ // start it only when the server still is not recording after a short wait, or could not be asked.
++ if (ss.vodAsk !== 'done') { if (!ss.vodAsk) { ss.vodAsk = 'asking'; _vodAskServer(streamId, 0); } return; }
try {
const mimeType = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'].find(m => MediaRecorder.isTypeSupported(m));
if (!mimeType) { console.warn('[VOD] No supported codec'); return; }
@@@ -2460,6 +2467,22 @@ function startVodRecording(streamId) {
} catch (err) { console.warn('[VOD] Failed to start:', err); }
}
++const VOD_ASK_TRIES = 4; // 0, 8, 16, 24 s after going live: the server opens its recording within seconds
++const VOD_ASK_EVERY_MS = 8000;
++async function _vodAskServer(streamId, attempt) {
++ const ss = getStreamState(streamId);
++ if (!ss || !ss.localStream || !ss.streamData) return;
++ let st = null;
++ try { st = await api(`/vods/stream/${ss.streamData.id}/recording`); } catch { st = null; }
++ if (getStreamState(streamId) !== ss || !ss.localStream) return;
++ if (st && st.recording === false) { ss.vodOff = true; ss.vodAsk = 'done'; console.log('[VOD] This channel records nothing — no browser recording'); return; }
++ if (st && st.server) { ss.vodServerRecording = true; ss.vodAsk = 'done'; console.log('[VOD] Server-side recording active — no browser recording'); return; }
++ if (st && attempt + 1 < VOD_ASK_TRIES) { setTimeout(() => _vodAskServer(streamId, attempt + 1), VOD_ASK_EVERY_MS); return; }
++ ss.vodAsk = 'done';
++ console.log('[VOD] The server is not recording this stream — recording in the browser');
++ startVodRecording(streamId);
++}
++
/**
* Start a fresh MediaRecorder for a stream whose recording the server has to open again (needs-header): only a
* recorder's first slice carries the WebM header, so the pending slices are dropped and the next upload starts a
@@@ -2512,6 +2535,14 @@ async function uploadVodChunk(streamId) {
ss.vodRecorder = null;
console.log('[VOD] Server-side recording active — browser chunk upload stopped');
}
++ } else if (resp.status === 403) {
++ // Recording is off for this channel (or the stream is not this account's): every later slice would be
++ // refused too, so the recorder stops instead of encoding and uploading for the rest of the stream.
++ ss.vodOff = true;
++ ss.vodChunks = [];
++ const old = ss.vodRecorder; ss.vodRecorder = null;
++ if (old && old.state !== 'inactive') { try { old.stop(); } catch {} }
++ console.log('[VOD] The server refuses this recording — browser recording stopped');
} else if (resp.status === 409) {
const data = await resp.json().catch(() => ({}));
if (data.status === 'stream-ended') {
@@@ -2599,6 +2630,45 @@ if (typeof window !== 'undefined') {
/* ── Per-Stream Media Capture ────────────────────────────────── */
++const BROADCAST_RES_SIZES = { '360': { w: 640, h: 360 }, '480': { w: 854, h: 480 }, '720': { w: 1280, h: 720 }, '1080': { w: 1920, h: 1080 }, '1440': { w: 2560, h: 1440 } };
++
++/**
++ * Apply the quality settings to a live broadcast without restarting it (the phone studio's Video quality): the camera
++ * is asked for the new size and frame rate, and the encoder for the new limits. Returns what is sent now.
++ */
++async function applyBroadcastQuality(streamId = broadcastState.activeStreamId) {
++ const ss = getStreamState(streamId);
++ if (!ss || !ss.localStream) return null;
++ const s = broadcastState.settings;
++ const res = BROADCAST_RES_SIZES[getBroadcastResolution()] || BROADCAST_RES_SIZES['720'];
++ const fps = getBroadcastFrameRate();
++ const track = ss.localStream.getVideoTracks()[0];
++ // A screen share and the audio-only card keep their own capture.
++ if (track && !s.screenShare && !s.micOnly && typeof track.applyConstraints === 'function') {
++ try { await track.applyConstraints({ width: { ideal: res.w }, height: { ideal: res.h }, frameRate: { ideal: fps } }); }
++ catch (err) { console.warn('[Broadcast] The camera kept its size:', err.message || err); }
++ }
++ const state = _sfuProduceStates.get(streamId);
++ if (state && state.videoProducer && typeof state.videoProducer.setRtpEncodingParameters === 'function') {
++ try { await state.videoProducer.setRtpEncodingParameters({ maxBitrate: getTargetVideoBitrate(), maxFramerate: fps }); }
++ catch (err) { console.warn('[Broadcast] The encoder kept its limits:', err.message || err); }
++ }
++ return broadcastVideoInfo(streamId);
++}
++
++/** What the broadcast's video is now: the camera's size and frame rate, and the codec viewers get. */
++function broadcastVideoInfo(streamId = broadcastState.activeStreamId) {
++ const ss = getStreamState(streamId);
++ const track = ss && ss.localStream ? ss.localStream.getVideoTracks()[0] : null;
++ const set = track && track.getSettings ? track.getSettings() : {};
++ const state = _sfuProduceStates.get(streamId);
++ const mime = state && state.videoProducer && state.videoProducer.rtpParameters && state.videoProducer.rtpParameters.codecs && state.videoProducer.rtpParameters.codecs[0] ? state.videoProducer.rtpParameters.codecs[0].mimeType : '';
++ return {
++ width: set.width || 0, height: set.height || 0, fps: Math.round(set.frameRate || 0),
++ codec: mime ? (/h264/i.test(mime) ? 'H.264' : mime.split('/')[1]) : '',
++ };
++}
++
/**
* Build getDisplayMedia() constraints from the user's screen share settings.
* Modular helper — each constraint is independent so partial browser support is fine.
@@@ -2675,8 +2745,7 @@ async function startMediaCapture(streamId, opts = {}) {
const s = broadcastState.settings;
const forceCamera = opts.cameraId || s.forceCamera;
const forceAudio = (opts.skipMic) ? null : (opts.audioId || s.forceAudio);
-- const resMap = { '360': { w: 640, h: 360 }, '480': { w: 854, h: 480 }, '720': { w: 1280, h: 720 }, '1080': { w: 1920, h: 1080 }, '1440': { w: 2560, h: 1440 } };
-- const res = resMap[s.broadcastRes] || resMap['720'];
++ const res = BROADCAST_RES_SIZES[getBroadcastResolution()] || BROADCAST_RES_SIZES['720'];
let videoConstraints, audioConstraints;
// Phones and tablets cannot capture their screen (no getDisplayMedia): a screen-mode slot opened
@@@ -4049,8 +4118,19 @@ async function _handleSfuProduceRequest(streamId) {
const audioTrack = ss.localStream.getAudioTracks()[0];
if (videoTrack) {
-- state.videoProducer = await transport.produce({ track: videoTrack, stopTracks: false });
-- console.log('[SFU Produce] Video producer:', state.videoProducer.id);
++ const opts = sfuVideoProduceOptions(device, videoTrack);
++ try {
++ state.videoProducer = await transport.produce(opts);
++ } catch (err) {
++ // A browser that refuses the chosen codec sends the router's first, as before.
++ if (!opts.codec) throw err;
++ console.warn('[SFU Produce] ' + opts.codec.mimeType + ' refused (' + err.message + ') — sending the default codec');
++ delete opts.codec;
++ state.videoProducer = await transport.produce(opts);
++ }
++ await applyVideoSenderPrefs(state.videoProducer);
++ const sent = state.videoProducer.rtpParameters && state.videoProducer.rtpParameters.codecs ? state.videoProducer.rtpParameters.codecs[0] : null;
++ console.log('[SFU Produce] Video producer:', state.videoProducer.id, sent ? sent.mimeType : '', `≤${Math.round(getTargetVideoBitrate() / 1000)} kbps ≤${getBroadcastFrameRate()} fps`);
}
if (audioTrack) {
state.audioProducer = await transport.produce({ track: audioTrack, stopTracks: false });
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 11e5e711..cbd4e6aa 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -272,6 +272,25 @@ router.post('/stream/:streamId/finalize', requireAuth, async (req, res) => {
}
});
++// Whether the server records this stream itself (Media ingest from the SFU or RTMP), and whether the channel records
++// at all. The broadcast page asks before it starts its own fallback recorder: a second encode of the camera on the
++// streamer's device (a phone's battery), and slices the chunk route above would only drop.
++router.get('/stream/:streamId/recording', requireAuth, async (req, res) => {
++ try {
++ const streamId = parseInt(req.params.streamId, 10);
++ const stream = Number.isFinite(streamId) ? await db.getStreamById(streamId) : null;
++ if (!stream) return res.status(404).json({ error: 'Stream not found' });
++ if (stream.user_id !== req.user.id && !permissions.can(req.user, 'staff.streams.manage')) {
++ return res.status(403).json({ error: 'Not your stream' });
++ }
++ const policy = await db.getChannelVodRecordingPolicyByUserId(stream.user_id, stream.managed_stream_id);
++ const rec = recorder.getActiveRecording(streamId);
++ res.json({ recording: !!policy.recordingEnabled, server: !!(rec && rec.type !== 'chunks') });
++ } catch {
++ res.status(500).json({ error: 'Could not read the recording state' });
++ }
++});
++
router.get('/stream/:streamId/live', optionalAuth, async (req, res) => {
try {
const streamId = parseInt(req.params.streamId);
ddiff --git a/test/broadcast-camera-flip.test.js b/test/broadcast-camera-flip.test.js
iindex 1190e808..15453b22 100644
---- a/test/broadcast-camera-flip.test.js
++++ b/test/broadcast-camera-flip.test.js
@@@ -37,6 +37,15 @@ function extract(src, name) {
throw new Error(`unbalanced ${name}`);
}
++/** startTrackKeepalive and what it uses, run in `ctx` (a context object). */
++function liftWatchdog(ctx) {
++ vm.createContext(ctx);
++ const consts = ['KEEPALIVE_INTERVAL_MS', 'WATCHDOG_DEAD_THRESHOLD_MS', 'WATCHDOG_ENDED_THRESHOLD_MS', 'MOBILE_GRAB_EVERY_TICKS']
++ .map((n) => new RegExp(`const ${n} = [^;]+;`).exec(state)[0]).join('\n');
++ const fns = ['isMobileBroadcaster', '_trackFrameCount', 'startTrackKeepalive', 'stopTrackKeepalive'].map((n) => extract(state, n)).join('\n');
++ vm.runInContext(`${consts}\n${fns}`, ctx);
++}
++
function fakeTrack(kind, settings = {}) {
return { kind, readyState: 'live', enabled: true, label: '', getSettings: () => settings, stop() { this.readyState = 'ended'; }, addEventListener() {} };
}
@@@ -65,9 +74,7 @@ function fakeStream(tracks) {
// A frame comes from a live track; a stopped one has none.
ImageCapture: function (t) { this.grabFrame = () => (t.readyState === 'live' ? Promise.resolve({ close() {} }) : Promise.reject(new Error('ended'))); },
};
-- vm.createContext(ctx);
-- const consts = ['KEEPALIVE_INTERVAL_MS', 'WATCHDOG_DEAD_THRESHOLD_MS', 'WATCHDOG_ENDED_THRESHOLD_MS'].map((n) => new RegExp(`const ${n} = [^;]+;`).exec(state)[0]).join('\n');
-- vm.runInContext(`${consts}\n${extract(state, 'startTrackKeepalive')}\n${extract(state, 'stopTrackKeepalive')}`, ctx);
++ liftWatchdog(ctx);
const front = fakeTrack('video');
ss.localStream = fakeStream([front, fakeTrack('audio')]);
@@@ -91,6 +98,56 @@ function fakeStream(tracks) {
console.log(' ✓ the frame watchdog follows the track a camera switch puts in the stream');
}
++ // ── On a phone: frame counts instead of a frame copy every second ──
++ // Copying a whole camera frame (ImageCapture.grabFrame) every second costs a phone battery: it reads the track's
++ // delivered frames (MediaStreamTrack stats) instead, or grabs a frame every 5 s where those are missing.
++ {
++ const phone = { userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36', maxTouchPoints: 5 };
++ const setup = (track) => {
++ let now = 1_000_000, tick = null, grabs = 0;
++ const recoveries = [];
++ const ss = { localStream: fakeStream([track, fakeTrack('audio')]) };
++ const ctx = {
++ console: { log() {}, warn() {} }, navigator: phone,
++ Date: { now: () => now },
++ setInterval: (fn) => { tick = fn; return 1; },
++ clearInterval: () => { tick = null; },
++ getStreamState: () => ss,
++ scheduleMediaRecovery: (id, reason) => recoveries.push(reason),
++ ImageCapture: function () { this.grabFrame = () => { grabs++; return Promise.resolve({ close() {} }); }; },
++ };
++ liftWatchdog(ctx);
++ ctx.startTrackKeepalive(7);
++ const run = async (seconds, each) => { for (let i = 0; i < seconds; i++) { now += 1000; if (each) each(); tick(); await new Promise((r) => setImmediate(r)); } };
++ return { run, recoveries, grabs: () => grabs };
++ };
++
++ // Frames flowing: no copies, no recovery.
++ const cam = fakeTrack('video');
++ cam.stats = { totalFrames: 0 };
++ let w = setup(cam);
++ await w.run(30, () => { cam.stats.totalFrames += 30; });
++ assert.deepStrictEqual([w.grabs(), w.recoveries], [0, []], 'a phone reads frame counts, never copies a frame');
++ // The camera stops delivering (the count stands still): recovery after 15 s.
++ await w.run(16);
++ assert.deepStrictEqual(w.recoveries, ['no camera frames (watchdog)']);
++
++ // A camera the streamer turned off sends nothing on purpose.
++ const off = fakeTrack('video');
++ off.stats = { totalFrames: 100 };
++ off.enabled = false;
++ w = setup(off);
++ await w.run(40);
++ assert.deepStrictEqual(w.recoveries, [], 'a camera turned off is not a dead camera');
++
++ // A browser without track stats: one frame copy every 5 s.
++ w = setup(fakeTrack('video'));
++ await w.run(30);
++ assert.strictEqual(w.grabs(), 6, 'a frame every 5 s, not every second');
++ assert.deepStrictEqual(w.recoveries, []);
++ console.log(' ✓ on a phone the watchdog counts frames (or copies one every 5 s), and a camera turned off is left alone');
++ }
++
// ── The switch asks in landscape terms and keeps the browser recording going ──
{
const order = [];
ddiff --git a/test/broadcast-mobile-encoder.test.js b/test/broadcast-mobile-encoder.test.js
nnew file mode 100644
iindex 00000000..7f85f9c4
---- /dev/null
++++ b/test/broadcast-mobile-encoder.test.js
@@@ -0,0 +1,180 @@
++'use strict';
++// Going live from a phone without draining it (owner, 2026-10-25: "drains less battery while still having the same
++// features … high as possible FPS and also adjustable settings for mobile"):
++// - the SFU producer sends H.264 from a phone or tablet (its hardware encoder; VP8, the router's first, is a software
++// encode on most phones), the codec chosen in settings anywhere, and VP8 from a computer as before;
++// - the settings' Max Bitrate and FPS reach the encoder, and it keeps the frame rate under pressure;
++// - battery saver (this device's own setting) holds 480p, 30 fps and 1 Mbps whatever the slot asks;
++// - the browser's fallback VOD recorder (a second encode) starts only when the server is not recording the stream,
++// and stops for good when the server refuses the recording (GET /api/vods/stream/:id/recording, the 403 answer).
++//
++// node test/broadcast-mobile-encoder.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++
++const root = path.join(__dirname, '..');
++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
++const state = read('public/js/broadcast-state.js');
++const bc = read('public/js/broadcast.js');
++
++function extract(src, name) {
++ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);
++ assert.ok(m, `${name}() must exist`);
++ let i = m.index + m[0].length;
++ for (let parens = 1; parens; i++) { if (src[i] === '(') parens++; else if (src[i] === ')') parens--; }
++ i = src.indexOf('{', i);
++ for (let depth = 0; i < src.length; i++) {
++ if (src[i] === '{') depth++;
++ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);
++ }
++ throw new Error(`unbalanced ${name}`);
++}
++
++const PHONE = { userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36', maxTouchPoints: 5 };
++const IPAD = { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15', maxTouchPoints: 5 };
++const DESKTOP = { userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', maxTouchPoints: 0 };
++
++/** broadcast-state.js in a page-like context: navigator, localStorage. */
++function page(navigator, stored = {}) {
++ const ctx = {
++ navigator, console: { log() {}, warn() {} },
++ localStorage: { getItem: (k) => (k in stored ? stored[k] : null), setItem: (k, v) => { stored[k] = String(v); } },
++ document: { addEventListener() {}, hidden: false },
++ };
++ vm.createContext(ctx);
++ vm.runInContext(`${state}\nthis.broadcastState = broadcastState;`, ctx);
++ return { ctx, stored };
++}
++
++const codec = (mimeType) => ({ kind: 'video', mimeType, clockRate: 90000 });
++const both = { rtpCapabilities: { codecs: [codec('video/VP8'), codec('video/rtx'), codec('video/H264'), { kind: 'audio', mimeType: 'audio/opus' }] } };
++const vp8only = { rtpCapabilities: { codecs: [codec('video/VP8')] } };
++const track = { kind: 'video' };
++
++(async () => {
++ // ── The codec ──
++ let p = page(PHONE);
++ assert.ok(p.ctx.isMobileBroadcaster());
++ let o = p.ctx.sfuVideoProduceOptions(both, track);
++ assert.strictEqual(o.codec.mimeType, 'video/H264', 'a phone sends H.264');
++ assert.strictEqual(o.stopTracks, false, 'the broadcast owns its tracks');
++ assert.deepStrictEqual(JSON.parse(JSON.stringify(o.encodings)), [{ maxBitrate: 2500000, maxFramerate: 30 }], "the settings' Max Bitrate and FPS reach the encoder");
++ assert.strictEqual(o.codecOptions.videoGoogleStartBitrate, 2500, 'H.264 starts near the target too (the router gives only VP8 a start bitrate)');
++ assert.strictEqual(page(IPAD).ctx.sfuVideoProduceOptions(both, track).codec.mimeType, 'video/H264', 'an iPad (it says Macintosh, with touch)');
++ assert.strictEqual(p.ctx.sfuVideoProduceOptions(vp8only, track).codec, undefined, 'a phone that cannot send H.264 sends the default');
++ p.ctx.broadcastState.settings.screenShare = true;
++ assert.strictEqual(p.ctx.sfuVideoProduceOptions(both, track).codec, undefined, 'a screen share keeps the default');
++ p.ctx.broadcastState.settings.screenShare = false;
++ p.ctx.broadcastState.settings.broadcastCodec = 'vp8';
++ assert.strictEqual(p.ctx.sfuVideoProduceOptions(both, track).codec.mimeType, 'video/VP8', "the settings' Codec Preference wins");
++
++ let d = page(DESKTOP);
++ assert.ok(!d.ctx.isMobileBroadcaster());
++ assert.strictEqual(d.ctx.sfuVideoProduceOptions(both, track).codec, undefined, 'a computer sends the router default (VP8) as before');
++ d.ctx.broadcastState.settings.broadcastCodec = 'h264';
++ assert.strictEqual(d.ctx.sfuVideoProduceOptions(both, track).codec.mimeType, 'video/H264');
++ d.ctx.broadcastState.settings.broadcastBps = '4000';
++ d.ctx.broadcastState.settings.broadcastFps = '60';
++ assert.deepStrictEqual(JSON.parse(JSON.stringify(d.ctx.sfuVideoProduceOptions(both, track).encodings)), [{ maxBitrate: 4000000, maxFramerate: 60 }]);
++
++ // ── Battery saver: this device's own setting ──
++ p = page(PHONE);
++ Object.assign(p.ctx.broadcastState.settings, { broadcastRes: '1080', broadcastFps: '60', broadcastBps: '6800' });
++ assert.deepStrictEqual([p.ctx.getBroadcastResolution(), p.ctx.getBroadcastFrameRate(), p.ctx.getTargetVideoBitrate()], ['1080', 60, 6800000]);
++ p.ctx.setBatterySaver(true);
++ assert.deepStrictEqual([p.ctx.getBroadcastResolution(), p.ctx.getBroadcastFrameRate(), p.ctx.getTargetVideoBitrate()], ['480', 30, 1000000]);
++ assert.strictEqual(p.ctx.sfuVideoProduceOptions(both, track).codecOptions.videoGoogleStartBitrate, 1000);
++ assert.strictEqual(p.stored.openvibe_battery_saver, '1');
++ assert.ok(!('batterySaver' in p.ctx.broadcastState.settings), 'not in the settings that go to the slot');
++ assert.strictEqual(page(PHONE, { openvibe_battery_saver: '1' }).ctx.batterySaverOn(), true, 'remembered on this device');
++ p.ctx.setBatterySaver(false);
++ assert.strictEqual(p.ctx.getBroadcastResolution(), '1080', 'off again: the slot choice is back');
++
++ // ── The sender keeps the frame rate (a camera) or the detail (a screen share) ──
++ const sender = { params: { encodings: [{}] }, getParameters() { return { ...this.params }; }, async setParameters(x) { this.params = x; } };
++ await p.ctx.applyVideoSenderPrefs({ rtpSender: sender });
++ assert.strictEqual(sender.params.degradationPreference, 'maintain-framerate');
++ p.ctx.broadcastState.settings.screenShare = true;
++ await p.ctx.applyVideoSenderPrefs({ rtpSender: sender });
++ assert.strictEqual(sender.params.degradationPreference, 'maintain-resolution');
++ await p.ctx.applyVideoSenderPrefs({ rtpSender: { getParameters() { throw new Error('old browser'); } } });
++ await p.ctx.applyVideoSenderPrefs(null);
++
++ // ── The browser's fallback recorder asks the server first ──
++ const recorderRun = async (answers) => {
++ const made = [];
++ const timers = [];
++ const ss = { localStream: { id: 'cam' }, streamData: { id: 42 } };
++ const ctx = {
++ console: { log() {}, warn() {} },
++ getStreamState: () => ss,
++ setTimeout: (fn) => { timers.push(fn); return timers.length; },
++ api: async (url) => { assert.strictEqual(url, '/vods/stream/42/recording'); const a = answers.shift(); if (a instanceof Error) throw a; return a; },
++ MediaRecorder: Object.assign(function (stream, opts) { made.push(opts); this.state = 'recording'; this.start = () => {}; }, { isTypeSupported: () => true }),
++ getTargetVideoBitrate: () => 2500000,
++ uploadVodChunk() {},
++ };
++ vm.createContext(ctx);
++ const consts = ['VOD_ASK_TRIES', 'VOD_ASK_EVERY_MS'].map((n) => new RegExp(`const ${n} = [^;]+;`).exec(bc)[0]).join('\n');
++ vm.runInContext(`${consts}\n${extract(bc, 'startVodRecording')}\n${extract(bc, '_vodAskServer')}`, ctx);
++ ctx.startVodRecording(7);
++ ctx.startVodRecording(7); // a second start while asking (a media recovery) waits for the answer
++ for (let i = 0; i < 10; i++) {
++ await new Promise((r) => setImmediate(r));
++ const next = timers.shift();
++ if (next) next();
++ }
++ await new Promise((r) => setImmediate(r));
++ return { made: made.length, ss };
++ };
++ let r = await recorderRun([{ recording: true, server: false }, { recording: true, server: true }]);
++ assert.deepStrictEqual([r.made, r.ss.vodServerRecording], [0, true], 'the server records it: no second encode on the phone');
++ r = await recorderRun([{ recording: false, server: false }]);
++ assert.deepStrictEqual([r.made, r.ss.vodOff], [0, true], 'the channel records nothing: neither does the browser');
++ r = await recorderRun([{ recording: true, server: false }, { recording: true, server: false }, { recording: true, server: false }, { recording: true, server: false }]);
++ assert.strictEqual(r.made, 1, 'the server never started recording: the browser records, once');
++ r = await recorderRun([new Error('404 (an older server)')]);
++ assert.strictEqual(r.made, 1, 'the server could not be asked: the browser records, as before');
++ assert.ok(/resp\.status === 403\)[\s\S]{0,400}ss\.vodOff = true/.test(bc), 'a refused recording (403) stops the recorder for good');
++
++ // ── The route the browser asks ──
++ const db = require('../server/db/database');
++ await db.initDb();
++ const raw = db.getDb();
++ await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE
++ VALUES (5, 'alice', 'Alice', 'alice@x', 'x', 'streamer', '2025-01-01 00:00:00'), (6, 'bob', 'Bob', 'bob@x', 'x', 'user', '2025-01-01 00:00:00')`).run();
++ const sid = (await db.get(`INSERT INTO streams (user_id, title, protocol, is_live) VALUES (5, 'Phone stream', 'webrtc', 1) RETURNING id`)).id;
++ const auth = require('../server/auth/auth');
++ auth.requireAuth = async (req, res, next) => { const u = await db.getUserById(Number(req.headers['x-test-user'] || 0)); if (!u) return res.status(401).json({}); req.user = u; next(); };
++ const recorder = require('../server/streaming/recorder');
++ let active = null;
++ recorder.getActiveRecording = () => active;
++ let enabled = true;
++ db.getChannelVodRecordingPolicyByUserId = async () => ({ recordingEnabled: enabled });
++ const express = require('express');
++ const app = express();
++ app.use('/api/vods', require('../server/media-proxy/vods'));
++ const server = require('http').createServer(app).listen(0);
++ if (!server.listening) await new Promise((res) => server.once('listening', res));
++ const ask = async (user, id = sid) => { const res = await fetch(`http://127.0.0.1:${server.address().port}/api/vods/stream/${id}/recording`, { headers: { 'x-test-user': String(user) } }); return [res.status, await res.json()]; };
++ assert.deepStrictEqual(await ask(5), [200, { recording: true, server: false }], 'nothing records it yet');
++ active = { type: 'rtp', vodId: 9 };
++ assert.deepStrictEqual(await ask(5), [200, { recording: true, server: true }], 'Media ingest records it');
++ active = { type: 'chunks', vodId: 9 };
++ assert.deepStrictEqual((await ask(5))[1].server, false, "the browser's own chunks are not the server recording");
++ enabled = false;
++ assert.deepStrictEqual((await ask(5))[1].recording, false);
++ assert.strictEqual((await ask(6))[0], 403, "someone else's stream");
++ assert.strictEqual((await ask(5, 999999))[0], 404);
++ server.close();
++
++ quiet('broadcast mobile encoder: all checks passed');
++ process.exit(0);
++})().catch((err) => { quiet(err); process.exit(1); });