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)
Changes
@@@ -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'));@@@ -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');@@@ -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 cleanlyif (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);nnew file mode 100644@@@ -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');
@@ -4001,7 +4001,8 @@ function connectSignaling(streamId) {
@@ -4001,7 +4001,8 @@ function connectSignaling(streamId) {
if (broadcastState.streams.has(streamId) && ss.streamData) {
if (broadcastState.streams.has(streamId) && ss.streamData) {
const delay = ss.signalingReconnectDelay;
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`);
console.log(`[Broadcast] Reconnecting signaling for stream ${streamId} in ${Math.round(delay)}ms`);
if (broadcastState.activeStreamId === streamId) {
if (broadcastState.activeStreamId === streamId) {
// Reconnecting, not dead — the server keeps the stream live during a grace
// Reconnecting, not dead — the server keeps the stream live during a grace
@@ -5041,6 +5042,24 @@ document.addEventListener('visibilitychange', () => {
@@ -5041,6 +5042,24 @@ document.addEventListener('visibilitychange', () => {
.finally(() => { _bcResuming = null; _bcSessionChanged(); });
.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) {
async function chooseBroadcastCamera(cameraId, buttonEl = null) {
const previousCameraId = _getPreferredCameraId();
const previousCameraId = _getPreferredCameraId();
const buttons = Array.from(document.querySelectorAll('#bc-camera-sheet .bc-camera-choice'));
const buttons = Array.from(document.querySelectorAll('#bc-camera-sheet .bc-camera-choice'));
@@ -721,7 +721,7 @@ function _showReconnectingIndicator() {
@@ -721,7 +721,7 @@ function _showReconnectingIndicator() {
const el = document.createElement('div');
const el = document.createElement('div');
el.id = 'reconnecting-indicator';
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.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);
container.appendChild(el);
}
}
@@ -1371,8 +1371,9 @@ async function initWebRTC(stream) {
@@ -1371,8 +1371,9 @@ async function initWebRTC(stream) {
// Show a subtle indicator — the WebRTC PeerConnection is independent
// Show a subtle indicator — the WebRTC PeerConnection is independent
// of the signaling WS so video likely still works
// of the signaling WS so video likely still works
_showReconnectingIndicator();
_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);
if (_broadcasterDisconnectTimer) clearTimeout(_broadcasterDisconnectTimer);
_broadcasterDisconnectTimer = setTimeout(() => {
_broadcasterDisconnectTimer = setTimeout(() => {
_broadcasterDisconnectTimer = null;
_broadcasterDisconnectTimer = null;
@@ -1386,7 +1387,7 @@ async function initWebRTC(stream) {
@@ -1386,7 +1387,7 @@ async function initWebRTC(stream) {
console.log('[Player] Broadcaster did not reconnect and SFU transport is dead, stream ended');
console.log('[Player] Broadcaster did not reconnect and SFU transport is dead, stream ended');
_hideReconnectingIndicator();
_hideReconnectingIndicator();
showStreamEnded();
showStreamEnded();
- }, 60000);
+ }, Math.max(30000, Number(msg.graceMs) || 300000) + 5000);
break;
break;
case 'stream-ended':
case 'stream-ended':
console.log('[Player] Stream ended by server');
console.log('[Player] Stream ended by server');
@@ -20,6 +20,12 @@ const webrtcSFU = require('./webrtc-sfu');
@@ -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).
// 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 MAX_VIEWER_SOCKETS_PER_IP = 16;
const config = require('../config');
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 { turnEntries } = require('../net/turn');
const whipHandler = require('./whip-handler');
const whipHandler = require('./whip-handler');
const viewerHealth = require('./viewer-health');
const viewerHealth = require('./viewer-health');
@@ -528,7 +534,7 @@ class BroadcastServer extends EventEmitter {
@@ -528,7 +534,7 @@ class BroadcastServer extends EventEmitter {
console.log(`[Broadcast] Broadcaster disconnected: stream ${client.streamId}`);
console.log(`[Broadcast] Broadcaster disconnected: stream ${client.streamId}`);
require('./stream-history').broadcasterLeft(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);
if (room._disconnectTimer) clearTimeout(room._disconnectTimer);
room._disconnectTimer = setTimeout(async () => {
room._disconnectTimer = setTimeout(async () => {
// Check if broadcaster reconnected
// Check if broadcaster reconnected
@@ -555,11 +561,11 @@ class BroadcastServer extends EventEmitter {
@@ -555,11 +561,11 @@ class BroadcastServer extends EventEmitter {
this.safeSend(vWs, { type: 'stream-ended' });
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) {
for (const [peerId, viewerWs] of room.viewers) {
- this.safeSend(viewerWs, { type: 'broadcaster-disconnected' });
+ this.safeSend(viewerWs, { type: 'broadcaster-disconnected', graceMs: RESUME_GRACE_MS });
}
}
} else {
} else {
room.viewers.delete(client.peerId);
room.viewers.delete(client.peerId);
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -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');