openvibers/openvibe.live Commits

A stream stays live for five minutes when its broadcaster drops, and the studio reconnects the moment the network is back (#472)

OP OpenVibers committed 13 days ago · c58ad04ba0 · 4 files, +70 −9

The owner, 2026-10-31: "even if the client disconnects, their phone reboots… resuming should be a piece of cake and
it should be solid, always reliable and consistent."

- The server keeps the room, the chat and is_live for BROADCAST_RESUME_GRACE_MS (5 minutes by default; it was 60 s,
  shorter than a phone's reboot). The broadcaster's studio reconnects to the same stream, or any device on the same
  account picks it up with Resume on /broadcast.
- Viewers are told how long the server waits (broadcaster-disconnected carries graceMs), wait as long, and see
  "The streamer is reconnecting…" over the last frame; the broadcaster coming back sends broadcaster-ready as before.
- The studio reconnects its signalling socket at once when the network comes back or changes (Wi-Fi to cellular),
  and its backoff tops out at 15 s instead of 30 s.
- test/broadcast-always-live.test.js holds it, with the leave-asks-first prompt and the rule that a page change
  inside the site never stops a broadcast.

Changes

ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 531b42cd..40e6f689 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -4001,7 +4001,8 @@ function connectSignaling(streamId) {
if (broadcastState.streams.has(streamId) && ss.streamData) {
const delay = ss.signalingReconnectDelay;
-- ss.signalingReconnectDelay = Math.min(ss.signalingReconnectDelay * 1.5, 30000);
++ // At most 15 s between tries: the server keeps the stream live for five minutes.
++ ss.signalingReconnectDelay = Math.min(ss.signalingReconnectDelay * 1.5, 15000);
console.log(`[Broadcast] Reconnecting signaling for stream ${streamId} in ${Math.round(delay)}ms`);
if (broadcastState.activeStreamId === streamId) {
// Reconnecting, not dead — the server keeps the stream live during a grace
@@@ -5041,6 +5042,24 @@ document.addEventListener('visibilitychange', () => {
.finally(() => { _bcResuming = null; _bcSessionChanged(); });
});
++/* The network came back (Wi-Fi to cellular, a tunnel, a router restart): every live stream's signalling socket
++ reconnects now instead of waiting out a backoff that grew while the network was gone (plan L23 R1). The server
++ keeps the stream live for five minutes meanwhile, and the SFU's own recovery handlers rebuild a transport that
++ failed. */
++function _bcReconnectNow(reason) {
++ if (!isStreaming()) return;
++ for (const [streamId, ss] of broadcastState.streams) {
++ if (!ss || ss.signalingIntentionalClose || !ss.streamData) continue;
++ if (ss.signalingWs && ss.signalingWs.readyState <= WebSocket.OPEN) continue; // connecting or open
++ if (ss.signalingReconnectTimer) { clearTimeout(ss.signalingReconnectTimer); ss.signalingReconnectTimer = null; }
++ ss.signalingReconnectDelay = 3000;
++ console.log(`[Broadcast] ${reason}: reconnecting signalling for stream ${streamId} now`);
++ connectSignaling(streamId);
++ }
++}
++window.addEventListener('online', () => _bcReconnectNow('Network back'));
++try { if (navigator.connection) navigator.connection.addEventListener('change', () => { if (navigator.onLine !== false) _bcReconnectNow('Network changed'); }); } catch { /* */ }
++
async function chooseBroadcastCamera(cameraId, buttonEl = null) {
const previousCameraId = _getPreferredCameraId();
const buttons = Array.from(document.querySelectorAll('#bc-camera-sheet .bc-camera-choice'));
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex be2ac121..64881e33 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -721,7 +721,7 @@ function _showReconnectingIndicator() {
const el = document.createElement('div');
el.id = 'reconnecting-indicator';
el.style.cssText = 'position:absolute;top:12px;right:12px;z-index:25;background:rgba(0,0,0,0.7);color:#fbbf24;padding:6px 14px;border-radius:8px;font-size:0.85rem;display:flex;align-items:center;gap:8px;pointer-events:none;';
-- el.innerHTML = '<i class="fa-solid fa-spinner fa-spin" style="font-size:0.9rem"></i> Reconnecting...';
++ el.innerHTML = '<i class="fa-solid fa-spinner fa-spin" style="font-size:0.9rem"></i> The streamer is reconnecting…';
container.appendChild(el);
}
@@@ -1371,8 +1371,9 @@ async function initWebRTC(stream) {
// Show a subtle indicator — the WebRTC PeerConnection is independent
// of the signaling WS so video likely still works
_showReconnectingIndicator();
-- // Start a grace timer matching the server's 60s disconnect window.
-- // Only show "ended" if both the signaling AND PeerConnection are dead.
++ // Wait as long as the server keeps the stream (graceMs, 5 min by default: a phone that
++ // reboots comes back on the same stream). Only show "ended" if both the signaling AND
++ // the PeerConnection are dead by then.
if (_broadcasterDisconnectTimer) clearTimeout(_broadcasterDisconnectTimer);
_broadcasterDisconnectTimer = setTimeout(() => {
_broadcasterDisconnectTimer = null;
@@@ -1386,7 +1387,7 @@ async function initWebRTC(stream) {
console.log('[Player] Broadcaster did not reconnect and SFU transport is dead, stream ended');
_hideReconnectingIndicator();
showStreamEnded();
-- }, 60000);
++ }, Math.max(30000, Number(msg.graceMs) || 300000) + 5000);
break;
case 'stream-ended':
console.log('[Player] Stream ended by server');
ddiff --git a/server/streaming/broadcast-server.js b/server/streaming/broadcast-server.js
iindex 0cc8a677..3211c285 100644
---- a/server/streaming/broadcast-server.js
++++ b/server/streaming/broadcast-server.js
@@@ -20,6 +20,12 @@ const webrtcSFU = require('./webrtc-sfu');
// Per-address cap on viewer sockets (a household or office behind one NAT watching several tabs fits).
const MAX_VIEWER_SOCKETS_PER_IP = 16;
const config = require('../config');
++
++// How long a stream stays live after its broadcaster's connection drops (plan L23 R1, owner 2026-10-31: "even if the
++// client disconnects, their phone reboots… resuming should be a piece of cake"). A phone that reboots needs a minute or
++// two; 60 s ended those streams. During the grace the room, the chat and is_live stay, viewers see "Reconnecting…", and
++// the broadcaster's studio (or any device on the same account, through Resume) carries on the same stream.
++const RESUME_GRACE_MS = Math.max(30000, Number(process.env.BROADCAST_RESUME_GRACE_MS) || 5 * 60 * 1000);
const { turnEntries } = require('../net/turn');
const whipHandler = require('./whip-handler');
const viewerHealth = require('./viewer-health');
@@@ -528,7 +534,7 @@ class BroadcastServer extends EventEmitter {
console.log(`[Broadcast] Broadcaster disconnected: stream ${client.streamId}`);
require('./stream-history').broadcasterLeft(client.streamId);
-- // Start a grace timer — if broadcaster doesn't reconnect, end the stream cleanly
++ // Start a grace timer (RESUME_GRACE_MS) — if the broadcaster doesn't come back, end the stream cleanly
if (room._disconnectTimer) clearTimeout(room._disconnectTimer);
room._disconnectTimer = setTimeout(async () => {
// Check if broadcaster reconnected
@@@ -555,11 +561,11 @@ class BroadcastServer extends EventEmitter {
this.safeSend(vWs, { type: 'stream-ended' });
}
}
-- }, 60000);
++ }, RESUME_GRACE_MS);
-- // Notify all viewers (they may get a reconnection)
++ // Notify all viewers (they may get a reconnection): they wait as long as the server does.
for (const [peerId, viewerWs] of room.viewers) {
-- this.safeSend(viewerWs, { type: 'broadcaster-disconnected' });
++ this.safeSend(viewerWs, { type: 'broadcaster-disconnected', graceMs: RESUME_GRACE_MS });
}
} else {
room.viewers.delete(client.peerId);
ddiff --git a/test/broadcast-always-live.test.js b/test/broadcast-always-live.test.js
nnew file mode 100644
iindex 00000000..7a159bf1
---- /dev/null
++++ b/test/broadcast-always-live.test.js
@@@ -0,0 +1,35 @@
++'use strict';
++// The broadcaster always comes back (plan L23 R1, owner 2026-10-31: "even if the client disconnects, their phone
++// reboots… resuming should be a piece of cake"). The server keeps a stream live for five minutes after its broadcaster's
++// connection drops (it was 60 s, shorter than a phone's reboot); viewers wait as long and see "The streamer is
++// reconnecting…"; the studio reconnects the moment the network is back, and never waits more than 15 s between tries.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
++
++const server = read('server/streaming/broadcast-server.js');
++assert.match(server, /const RESUME_GRACE_MS = Math\.max\(30000, Number\(process\.env\.BROADCAST_RESUME_GRACE_MS\) \|\| 5 \* 60 \* 1000\);/, 'five minutes, settable');
++assert.match(server, /room\._disconnectTimer = setTimeout\(async \(\) => \{[\s\S]{0,2500}\}, RESUME_GRACE_MS\);/, 'the stream ends only after the grace');
++assert.match(server, /safeSend\(viewerWs, \{ type: 'broadcaster-disconnected', graceMs: RESUME_GRACE_MS \}\)/, 'viewers are told how long');
++assert.ok(!/\}, 60000\);\s*\n\s*\/\/ Notify all viewers/.test(server), 'no 60 s timer left');
++
++const player = read('public/js/stream-player.js');
++assert.match(player, /\}, Math\.max\(30000, Number\(msg\.graceMs\) \|\| 300000\) \+ 5000\);/, 'the viewer waits as long as the server');
++assert.ok(player.includes('The streamer is reconnecting…'), 'and says who is reconnecting');
++
++const bc = read('public/js/broadcast.js');
++assert.match(bc, /window\.addEventListener\('online', \(\) => _bcReconnectNow\('Network back'\)\);/, 'the network coming back reconnects now');
++assert.match(bc, /navigator\.connection\.addEventListener\('change'/, 'so does a network change');
++assert.match(bc, /function _bcReconnectNow\(reason\) \{[\s\S]{0,700}ss\.signalingReconnectDelay = 3000;[\s\S]{0,200}connectSignaling\(streamId\);/, 'the backoff starts over');
++assert.match(bc, /ss\.signalingReconnectDelay = Math\.min\(ss\.signalingReconnectDelay \* 1\.5, 15000\);/, 'at most 15 s between tries');
++
++// Leaving the site while live asks first (the studio module runs on every screen size).
++assert.match(read('public/js/broadcast-studio.js'), /window\.addEventListener\('beforeunload', \(e\) => \{\s*if \(!localStream\(\)\) return;\s*e\.preventDefault\(\);/);
++
++// Moving inside the site never stops a broadcast: the route teardown has no broadcast stop in it.
++const app = read('public/js/app.js');
++const teardown = app.slice(app.indexOf('function teardownRoute('), app.indexOf('\n}\n', app.indexOf('function teardownRoute(')));
++assert.ok(!/stopBroadcast|cleanupStream|endStream|stopAllStreams/.test(teardown), 'a page change leaves the broadcast running');
++
++console.log('always live: 5 min grace, viewers wait and say so, the studio reconnects at once, leave asks first');