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)
Changes
@@@ -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).@@@ -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 orshow 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, andthe stream ends after a minute away, so "Watch as a viewer" opens over the studio instead of in a new tab.@@@ -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);@@@ -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' readyStateconst 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;@@@ -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();@@@ -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) {@@@ -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 });@@@ -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);@@@ -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 = [];nnew file mode 100644@@@ -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); });
@@ -103,3 +103,23 @@ Configurable per-stream settings include:
@@ -103,3 +103,23 @@ Configurable per-stream settings include:
- TTS mode and voice settings
- TTS mode and voice settings
- Auto-reconnect behavior
- Auto-reconnect behavior
- Stream title and category
- 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).
@@ -35,8 +35,14 @@ You don't need any of the following:
@@ -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 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
- 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.
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.
- **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
- 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.
the stream ends after a minute away, so "Watch as a viewer" opens over the studio instead of in a new tab.
@@ -2646,6 +2646,32 @@ body.bc-studio-on .toast-container { z-index: 1500; }
@@ -2646,6 +2646,32 @@ body.bc-studio-on .toast-container { z-index: 1500; }
.bcs-sec { margin-top: 14px; }
.bcs-sec { margin-top: 14px; }
.bcs-sec h3 { margin: 0 0 8px; font-size: 0.9rem; }
.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; }
.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 {
.bcs-confirm {
width: 100%; max-width: 360px; padding: 20px; border-radius: 18px; text-align: center;
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);
background: var(--bg-secondary, #15151c); color: var(--text-primary, #f4f4f5); box-shadow: 0 20px 60px rgba(0,0,0,0.5);
@@ -82,6 +82,27 @@ const BROADCAST_THUMBNAIL_INTERVAL_MS = 115000;
@@ -82,6 +82,27 @@ const BROADCAST_THUMBNAIL_INTERVAL_MS = 115000;
const KEEPALIVE_INTERVAL_MS = 1000;
const KEEPALIVE_INTERVAL_MS = 1000;
const WATCHDOG_DEAD_THRESHOLD_MS = 15000; // 15s of grabFrame failures with 'live' readyState
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)
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) {
function startTrackKeepalive(streamId) {
const ss = getStreamState(streamId);
const ss = getStreamState(streamId);
@@ -96,6 +117,8 @@ function startTrackKeepalive(streamId) {
@@ -96,6 +117,8 @@ function startTrackKeepalive(streamId) {
let lastSuccessfulFrame = Date.now();
let lastSuccessfulFrame = Date.now();
let watchdogTriggered = false;
let watchdogTriggered = false;
const streamRef = ss.localStream; // capture reference for staleness check
const streamRef = ss.localStream; // capture reference for staleness check
+ const mobile = isMobileBroadcaster();
+ let ticks = 0, lastFrameCount = null;
ss._trackKeepaliveInterval = setInterval(() => {
ss._trackKeepaliveInterval = setInterval(() => {
const current = getStreamState(streamId);
const current = getStreamState(streamId);
@@ -144,6 +167,24 @@ function startTrackKeepalive(streamId) {
@@ -144,6 +167,24 @@ function startTrackKeepalive(streamId) {
return;
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;
if (!capture) return;
capture.grabFrame()
capture.grabFrame()
.then(bmp => {
.then(bmp => {
@@ -217,16 +258,75 @@ document.addEventListener('visibilitychange', () => {
@@ -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() {
function getBroadcastFrameRate() {
+ if (_batterySaver) return BATTERY_SAVER.fps;
const fps = parseInt(broadcastState.settings.broadcastFps, 10);
const fps = parseInt(broadcastState.settings.broadcastFps, 10);
return Number.isFinite(fps) && fps > 0 ? fps : 30;
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() {
function getTargetVideoBitrate() {
+ if (_batterySaver) return BATTERY_SAVER.kbps * 1000;
const kbps = parseInt(broadcastState.settings.broadcastBps, 10);
const kbps = parseInt(broadcastState.settings.broadcastBps, 10);
return (Number.isFinite(kbps) && kbps > 0 ? kbps : 2500) * 1000;
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) {
function getSuggestedScaleDown(settings) {
const res = String(settings.broadcastRes || '720');
const res = String(settings.broadcastRes || '720');
const kbps = parseInt(settings.broadcastBps, 10) || 2500;
const kbps = parseInt(settings.broadcastBps, 10) || 2500;
@@ -419,6 +419,50 @@
@@ -419,6 +419,50 @@
}
}
// ── More ──
// ── 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) {
function tile(ico, label, onClick, hint) {
return el('button', { type: 'button', class: 'bcs-tile', onclick: onClick },
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);
icon(ico), el('span', { class: 'bcs-tile-t', text: label }), hint ? el('small', { text: hint }) : null);
@@ -481,12 +525,13 @@
@@ -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('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-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'))),
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);
st.sheet = el('div', { class: 'bcs-sheet-wrap', onclick: (e) => { if (e.target === st.sheet) closeSheet(); } }, sheet);
document.body.append(st.sheet);
document.body.append(st.sheet);
const focus = section === 'restreams' && st.parked.length ? restreams : sheet.querySelector('.bcs-tile');
const focus = section === 'restreams' && st.parked.length ? restreams : sheet.querySelector('.bcs-tile');
if (section === 'restreams' && st.parked.length) restreams.scrollIntoView({ block: 'start' });
if (section === 'restreams' && st.parked.length) restreams.scrollIntoView({ block: 'start' });
if (focus && focus.focus) focus.focus({ preventScroll: section === 'restreams' });
if (focus && focus.focus) focus.focus({ preventScroll: section === 'restreams' });
+ if (typeof broadcastVideoInfo === 'function') showVideoNow(broadcastVideoInfo());
}
}
function closeSheet() {
function closeSheet() {
for (const { node, mark } of st.parked.splice(0)) { mark.replaceWith(node); }
for (const { node, mark } of st.parked.splice(0)) { mark.replaceWith(node); }
@@ -550,6 +595,8 @@
@@ -550,6 +595,8 @@
// ── Once a second: mount, update or leave ──
// ── Once a second: mount, update or leave ──
function tick() {
function tick() {
+ // A hidden tab draws nothing: no work until it shows again (visibilitychange below).
+ if (document.hidden) return;
const has = !!localStream();
const has = !!localStream();
if (has !== st.hadLocal) {
if (has !== st.hadLocal) {
st.hadLocal = has;
st.hadLocal = has;
@@ -563,6 +610,7 @@
@@ -563,6 +610,7 @@
syncGoLiveDock();
syncGoLiveDock();
}
}
setInterval(tick, 1000);
setInterval(tick, 1000);
+ document.addEventListener('visibilitychange', tick);
if (NARROW.addEventListener) NARROW.addEventListener('change', tick);
if (NARROW.addEventListener) NARROW.addEventListener('change', tick);
ensureReturn();
ensureReturn();
@@ -528,6 +528,11 @@ function _wsRenderPanel() {
@@ -528,6 +528,11 @@ function _wsRenderPanel() {
oninput="_wsMarkDirty()">
oninput="_wsMarkDirty()">
</div>
</div>
</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>
</details>
</div>
</div>
@@ -1024,6 +1029,23 @@ function _wsConfirmAction(message, { title = 'Are you sure?', okLabel = 'Confirm
@@ -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 ────────────────────────────────────────── */
/* ── Live stats timer ────────────────────────────────────────── */
function _wsStartLiveStatsTimer(liveSessionId, liveSessionData) {
function _wsStartLiveStatsTimer(liveSessionId, liveSessionData) {
@@ -1995,6 +1995,8 @@ function syncSettingsUI() {
@@ -1995,6 +1995,8 @@ function syncSettingsUI() {
function updateBroadcastSetting(key, value) {
function updateBroadcastSetting(key, value) {
broadcastState.settings[key] = value; saveBroadcastSettings();
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) {
@@ -2431,6 +2433,11 @@ function startVodRecording(streamId) {
if (!ss || !ss.localStream) return;
if (!ss || !ss.localStream) return;
if (ss.vodRecorder && ss.vodRecorder.state !== 'inactive') return;
if (ss.vodRecorder && ss.vodRecorder.state !== 'inactive') return;
if (ss.vodServerRecording) return; // server ingest is recording this stream — no browser chunks
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 {
try {
const mimeType = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'].find(m => MediaRecorder.isTypeSupported(m));
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; }
if (!mimeType) { console.warn('[VOD] No supported codec'); return; }
@@ -2460,6 +2467,22 @@ function startVodRecording(streamId) {
@@ -2460,6 +2467,22 @@ function startVodRecording(streamId) {
} catch (err) { console.warn('[VOD] Failed to start:', err); }
} 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
* 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
* 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) {
@@ -2512,6 +2535,14 @@ async function uploadVodChunk(streamId) {
ss.vodRecorder = null;
ss.vodRecorder = null;
console.log('[VOD] Server-side recording active — browser chunk upload stopped');
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) {
} else if (resp.status === 409) {
const data = await resp.json().catch(() => ({}));
const data = await resp.json().catch(() => ({}));
if (data.status === 'stream-ended') {
if (data.status === 'stream-ended') {
@@ -2599,6 +2630,45 @@ if (typeof window !== 'undefined') {
@@ -2599,6 +2630,45 @@ if (typeof window !== 'undefined') {
/* ── Per-Stream Media Capture ────────────────────────────────── */
/* ── 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.
* Build getDisplayMedia() constraints from the user's screen share settings.
* Modular helper — each constraint is independent so partial browser support is fine.
* Modular helper — each constraint is independent so partial browser support is fine.
@@ -2675,8 +2745,7 @@ async function startMediaCapture(streamId, opts = {}) {
@@ -2675,8 +2745,7 @@ async function startMediaCapture(streamId, opts = {}) {
const s = broadcastState.settings;
const s = broadcastState.settings;
const forceCamera = opts.cameraId || s.forceCamera;
const forceCamera = opts.cameraId || s.forceCamera;
const forceAudio = (opts.skipMic) ? null : (opts.audioId || s.forceAudio);
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;
let videoConstraints, audioConstraints;
// Phones and tablets cannot capture their screen (no getDisplayMedia): a screen-mode slot opened
// Phones and tablets cannot capture their screen (no getDisplayMedia): a screen-mode slot opened
@@ -4049,8 +4118,19 @@ async function _handleSfuProduceRequest(streamId) {
@@ -4049,8 +4118,19 @@ async function _handleSfuProduceRequest(streamId) {
const audioTrack = ss.localStream.getAudioTracks()[0];
const audioTrack = ss.localStream.getAudioTracks()[0];
if (videoTrack) {
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) {
if (audioTrack) {
state.audioProducer = await transport.produce({ track: audioTrack, stopTracks: false });
state.audioProducer = await transport.produce({ track: audioTrack, stopTracks: false });
@@ -272,6 +272,25 @@ router.post('/stream/:streamId/finalize', requireAuth, async (req, res) => {
@@ -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) => {
router.get('/stream/:streamId/live', optionalAuth, async (req, res) => {
try {
try {
const streamId = parseInt(req.params.streamId);
const streamId = parseInt(req.params.streamId);
@@ -37,6 +37,15 @@ function extract(src, name) {
@@ -37,6 +37,15 @@ function extract(src, name) {
throw new Error(`unbalanced ${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 = {}) {
function fakeTrack(kind, settings = {}) {
return { kind, readyState: 'live', enabled: true, label: '', getSettings: () => settings, stop() { this.readyState = 'ended'; }, addEventListener() {} };
return { kind, readyState: 'live', enabled: true, label: '', getSettings: () => settings, stop() { this.readyState = 'ended'; }, addEventListener() {} };
}
}
@@ -65,9 +74,7 @@ function fakeStream(tracks) {
@@ -65,9 +74,7 @@ function fakeStream(tracks) {
// A frame comes from a live track; a stopped one has none.
// 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'))); },
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');
const front = fakeTrack('video');
ss.localStream = fakeStream([front, fakeTrack('audio')]);
ss.localStream = fakeStream([front, fakeTrack('audio')]);
@@ -91,6 +98,56 @@ function fakeStream(tracks) {
@@ -91,6 +98,56 @@ function fakeStream(tracks) {
console.log(' ✓ the frame watchdog follows the track a camera switch puts in the stream');
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 ──
// ── The switch asks in landscape terms and keeps the browser recording going ──
{
{
const order = [];
const order = [];
new file mode 100644
new file mode 100644
@@ -0,0 +1,180 @@
@@ -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); });