openvibers/openvibe.live Commits
Delete the P2P viewer fallback (ALLOW_P2P_FALLBACK, C-77): viewer media is SFU-only. (#37)
Changes
@@@ -130,7 +130,6 @@ TURN_CREDENTIAL=# Preferred: coturn `use-auth-secret` + `static-auth-secret=<this>` — every ICE list then carries a# credential that expires after an hour instead of the static pair above.TURN_AUTH_SECRET=--ALLOW_P2P_FALLBACK=false# PayPal (for Vibes cashout — Vibes stay Live-local)PAYPAL_CLIENT_ID=@@@ -130,7 +130,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.- `MEDIASOUP_LISTEN_IP`, `MEDIASOUP_ANNOUNCED_IP`, `MEDIASOUP_MIN_PORT` / `MEDIASOUP_MAX_PORT`, `WEBRTC_PORT`.- `TURN_URL` / `TURN_USERNAME` / `TURN_CREDENTIAL` — required in production for reliable NAT traversal (default `turn:turn.openvibe.live`).--- `ALLOW_P2P_FALLBACK` — emergency rollback flag; leave `false` (legacy P2P relays can expose viewer IPs).++- Viewer media is SFU-only (mediasoup); there is no peer-to-peer fallback.### WHIP@@@ -321,7 +321,7 @@ console.log(endpoint.whipUrlBase); // POST your SDP offer here (see WebRTC tab)<table class="doc-table"><thead><tr><th>Type</th><th>Dir</th><th>Key Fields</th><th>Description</th></tr></thead><tbody>--<tr><td>welcome</td><td>S→C</td><td>peerId, role, streamId, viewerCount, iceServers, allowP2pFallback</td><td>Sent immediately on connect</td></tr>++<tr><td>welcome</td><td>S→C</td><td>peerId, role, streamId, viewerCount, iceServers</td><td>Sent immediately on connect</td></tr><tr><td>watch</td><td>C→S</td><td>—</td><td>Viewer requests to start watching (triggers SFU consumer)</td></tr><tr><td>broadcaster-ready</td><td>S→Viewer</td><td>peerId</td><td>Broadcaster or SFU source is live, safe to send/refresh <code>watch</code></td></tr><tr><td>watch-queued</td><td>S→Viewer</td><td>reason, detail</td><td>Viewer is queued while the live source warms up or reconnects</td></tr>@@@ -342,7 +342,7 @@ console.log(endpoint.whipUrlBase); // POST your SDP offer here (see WebRTC tab)<tr><td>stream-ended</td><td>S→Viewers</td><td>—</td><td>Stream has ended</td></tr></tbody></table>--<p class="muted" style="margin-top:10px">Legacy <code>offer</code> / <code>answer</code> / <code>ice-candidate</code> relay messages are disabled by default. They are only honored when <code>ALLOW_P2P_FALLBACK=true</code> is explicitly set for emergency rollback.</p>++<p class="muted" style="margin-top:10px">Viewer media is delivered exclusively through the Mediasoup SFU. There is no direct peer-to-peer relay: legacy <code>offer</code> / <code>answer</code> / <code>ice-candidate</code> messages are not supported.</p><h3>RTMP Ingest (OBS)</h3><pre><code>Server: rtmp://openvibe.live/live@@@ -18,10 +18,6 @@ function createStreamState(streamData) {return {streamData,localStream: null,-- /** @type {Map<string, RTCPeerConnection>} peerId → PC */-- viewerConnections: new Map(),-- viewerReconnectTimers: new Map(),-- _allowP2pFallback: false,signalingWs: null,heartbeatInterval: null,startedAt: streamData?.started_at || null,@@@ -327,27 +327,6 @@ function _ensureSfuBroadcastReady(streamId, reason = 'startup') {});}--function clearViewerReconnectTimer(ss, viewerPeerId) {-- const timer = ss?.viewerReconnectTimers?.get(viewerPeerId);-- if (timer) clearTimeout(timer);-- ss?.viewerReconnectTimers?.delete(viewerPeerId);--}----function scheduleViewerReconnect(streamId, viewerPeerId, delay = 2000) {-- const ss = getStreamState(streamId);-- if (!ss || !viewerPeerId || !ss._allowP2pFallback) return;-- clearViewerReconnectTimer(ss, viewerPeerId);-- ss.viewerReconnectTimers.set(viewerPeerId, setTimeout(() => {-- clearViewerReconnectTimer(ss, viewerPeerId);-- if (!broadcastState.streams.has(streamId)) return;-- if (!ss.localStream) return;-- if (!ss.signalingWs || ss.signalingWs.readyState !== WebSocket.OPEN) return;-- createViewerConnection(streamId, viewerPeerId).catch((err) => {-- console.warn(`[Broadcast] Viewer reconnect failed for ${viewerPeerId}:`, err.message);-- });-- }, delay));--}--/*** Attach diagnostic logging to track/stream events.*@@@ -2307,12 +2286,6 @@ function cleanupStream(streamId) {// Clear RS restream slot if this stream had itif (getRsRestreamSlotStreamId() === streamId) setRsRestreamSlot(null);-- // Close viewer connections-- for (const [, pc] of ss.viewerConnections) { try { pc.close(); } catch {} }-- ss.viewerConnections.clear();-- for (const [, timer] of ss.viewerReconnectTimers) clearTimeout(timer);-- ss.viewerReconnectTimers.clear();--// Close signaling WSss.signalingIntentionalClose = true;ss.signalingReconnectDelay = 3000;@@@ -2416,7 +2389,7 @@ function startGlobalDisplayTimers() {// Primary source: the SFU produce transport — this IS the broadcaster's// real ingest uplink, so its byte counter and connection state are the-- // authoritative numbers to show. (Viewer PCs are only P2P-fallback downlinks.)++ // authoritative numbers to show.const sfuState = _sfuProduceStates.get(ss.streamData?.id);if (sfuState?.transport) {try {@@@ -2426,20 +2399,6 @@ function startGlobalDisplayTimers() {} catch {}}-- // Fallback: direct P2P viewer connections. Pick the healthiest one-- // (most bytes sent) rather than blindly sampling the first — the first-- // entry may be a still-negotiating or failed peer.-- if (!hasStats && ss.viewerConnections.size > 0) {-- for (const [, pc] of ss.viewerConnections) {-- try {-- const s = pc.iceConnectionState;-- const bytes = extractStats(await pc.getStats());-- if (bytes > totalBytesSent) { totalBytesSent = bytes; connState = s; }-- } catch {}-- }-- hasStats = totalBytesSent > 0;-- }--// Fallback: local stream settings (when no WebRTC stats available yet).// Don't claim "connected" here — report the true transport/signaling state// so the overlay never shows a fake healthy status.@@@ -4023,96 +3982,6 @@ function connectSignaling(streamId) {};}--async function createViewerConnection(streamId, viewerPeerId) {-- const ss = getStreamState(streamId);-- if (!ss) return;-- if (!ss._allowP2pFallback) {-- console.warn(`[Broadcast] Ignoring createViewerConnection(${streamId}, ${viewerPeerId}) — SFU-only mode is active`);-- return;-- }-- if (ss.viewerConnections.has(viewerPeerId)) closeViewerConnection(streamId, viewerPeerId);-- const s = broadcastState.settings;-- const maxBitrate = getTargetVideoBitrate();-- const maxFrameRate = getBroadcastFrameRate();-- const scaleDownBy = getSuggestedScaleDown(s);-- // Use server-provided ICE servers (with TURN support) if available-- const iceServers = (ss._serverIceServers && ss._serverIceServers.length > 0)-- ? ss._serverIceServers-- : [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];-- const pc = new RTCPeerConnection({ iceServers });-- ss.viewerConnections.set(viewerPeerId, pc);-- ss.localStream.getTracks().forEach(track => {-- try {-- track.contentHint = track.kind === 'audio' ? 'speech' : (s.screenShare ? 'detail' : 'motion');-- } catch {}-- pc.addTrack(track, ss.localStream);-- });---- pc.getTransceivers().forEach(t => {-- if (t.sender?.track?.kind === 'video') {-- try { t.direction = 'sendonly'; } catch {}-- try { t.setCodecPreferences?.((RTCRtpReceiver.getCapabilities('video')?.codecs || []).filter(Boolean)); } catch {}-- }-- });---- const codec = s.broadcastCodec || 'auto';-- if (codec !== 'auto' && pc.getTransceivers) {-- const vt = pc.getTransceivers().find(t => t.sender?.track?.kind === 'video');-- if (vt && typeof vt.setCodecPreferences === 'function') {-- try {-- const codecs = RTCRtpReceiver.getCapabilities('video')?.codecs || [];-- const mimeMap = { vp8: 'video/VP8', vp9: 'video/VP9', h264: 'video/H264' };-- const pref = codecs.filter(c => c.mimeType === mimeMap[codec]);-- const rest = codecs.filter(c => c.mimeType !== mimeMap[codec]);-- if (pref.length) vt.setCodecPreferences([...pref, ...rest]);-- } catch {}-- }-- }---- pc.getSenders().forEach(sender => {-- if (sender.track?.kind === 'video') {-- const params = sender.getParameters(); if (!params.encodings) params.encodings = [{}];-- params.encodings[0].maxBitrate = maxBitrate;-- params.encodings[0].maxFramerate = maxFrameRate;-- params.encodings[0].scaleResolutionDownBy = scaleDownBy;-- params.encodings[0].priority = 'high';-- const minBps = parseInt(s.broadcastBpsMin); if (minBps > 50) params.encodings[0].minBitrate = minBps * 1000;-- params.degradationPreference = s.screenShare ? 'maintain-resolution' : 'balanced';-- sender.setParameters(params).catch(() => {});-- } else if (sender.track?.kind === 'audio') {-- const params = sender.getParameters();-- if (!params.encodings) params.encodings = [{}];-- params.encodings[0].priority = 'high';-- sender.setParameters(params).catch(() => {});-- }-- });---- pc.onicecandidate = (e) => { if (e.candidate) sendBroadcastSignal(ss, { type: 'ice-candidate', candidate: e.candidate, targetPeerId: viewerPeerId }); };-- pc.oniceconnectionstatechange = () => {-- const iceState = pc.iceConnectionState;-- console.log(`[Broadcast] Stream ${streamId} viewer ${viewerPeerId} ICE state: ${iceState}`);-- if (iceState === 'connected' || iceState === 'completed') clearViewerReconnectTimer(ss, viewerPeerId);-- if (iceState === 'failed' || iceState === 'disconnected') {-- scheduleViewerReconnect(streamId, viewerPeerId, iceState === 'failed' ? 1500 : 4000);-- }-- if (iceState === 'closed') {-- closeViewerConnection(streamId, viewerPeerId);-- }-- if (broadcastState.activeStreamId === streamId) updateBroadcastStatusFromConnections(streamId);-- };---- const offer = await pc.createOffer(); await pc.setLocalDescription(offer);-- sendBroadcastSignal(ss, { type: 'offer', sdp: pc.localDescription, targetPeerId: viewerPeerId });--}----function closeViewerConnection(streamId, viewerPeerId) {-- const ss = getStreamState(streamId);-- if (!ss) return;-- clearViewerReconnectTimer(ss, viewerPeerId);-- const pc = ss.viewerConnections.get(viewerPeerId);-- if (pc) { try { pc.close(); } catch {} ss.viewerConnections.delete(viewerPeerId); }--}--function updateBroadcastStatusFromConnections(streamId) {const ss = getStreamState(streamId);if (!ss) return;@@@ -4127,19 +3996,6 @@ function updateBroadcastStatusFromConnections(streamId) {return;}-- if (ss._allowP2pFallback && ss.viewerConnections.size > 0) {-- let hasConnectedP2p = false;-- for (const [, pc] of ss.viewerConnections) {-- const state = pc.iceConnectionState;-- if (state === 'connected' || state === 'completed') {-- hasConnectedP2p = true;-- break;-- }-- }-- updateBroadcastStatus(hasConnectedP2p ? 'connected' : (signalingOpen ? 'checking' : 'disconnected'));-- return;-- }--updateBroadcastStatus(signalingOpen ? 'checking' : 'disconnected');}@@@ -4512,43 +4368,6 @@ async function handleSignalingMessage(streamId, msg) {const ss = getStreamState(streamId);if (!ss) return;switch (msg.type) {-- case 'viewer-joined':-- if (!ss._allowP2pFallback) {-- console.warn(`[Broadcast] Ignoring legacy viewer-joined for stream ${streamId} — SFU-only mode is active`);-- break;-- }-- if (msg.peerId && ss.localStream) {-- // Skip re-negotiation if the existing peer connection is still healthy-- const existingPc = ss.viewerConnections.get(msg.peerId);-- if (existingPc) {-- const state = existingPc.iceConnectionState;-- if (state === 'connected' || state === 'completed') {-- console.log(`[Broadcast] Stream ${streamId} viewer ${msg.peerId} already connected (ICE: ${state}), skipping re-negotiate`);-- break;-- }-- }-- await createViewerConnection(streamId, msg.peerId);-- }-- break;-- case 'viewer-left':-- if (ss._allowP2pFallback && msg.peerId) closeViewerConnection(streamId, msg.peerId);-- break;-- case 'answer':-- if (ss._allowP2pFallback) {-- const pc = ss.viewerConnections.get(msg.fromPeerId);-- if (pc && msg.sdp) await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));-- } else {-- console.warn(`[Broadcast] Ignoring legacy answer for stream ${streamId} — SFU-only mode is active`);-- }-- break;-- case 'ice-candidate':-- if (ss._allowP2pFallback) {-- const pc = ss.viewerConnections.get(msg.fromPeerId);-- if (pc && msg.candidate) await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));-- } else {-- console.warn(`[Broadcast] Ignoring legacy ice-candidate for stream ${streamId} — SFU-only mode is active`);-- }-- break;case 'viewer-count':_perStreamViewerCounts.set(streamId, msg.count || 0);_updateTabViewerCount(streamId, msg.count || 0);@@@ -4560,7 +4379,6 @@ async function handleSignalingMessage(streamId, msg) {if (msg.iceServers && Array.isArray(msg.iceServers)) {ss._serverIceServers = msg.iceServers;}-- ss._allowP2pFallback = !!msg.allowP2pFallback;break;case 'error': toast(msg.message || 'Broadcast error', 'error'); break;@@@ -4989,10 +4807,6 @@ async function _switchActiveCamera(cameraId) {const newTrack = newStream.getVideoTracks()[0];ss.localStream.addTrack(newTrack);-- for (const [, pc] of ss.viewerConnections) {-- const sender = pc.getSenders().find(s => s.track === null || s.track?.kind === 'video');-- if (sender) sender.replaceTrack(newTrack);-- }syncRobotStreamerTracks(broadcastState.activeStreamId).catch(() => {});_syncSfuProducerTracks(broadcastState.activeStreamId).catch(() => {});const preview = document.getElementById('bc-video-preview'); if (preview) preview.srcObject = ss.localStream;@@@ -5014,10 +4828,6 @@ async function _switchActiveCamera(cameraId) {const recovery = await _getUserMediaWithTimeout({ video: { deviceId: oldDeviceId ? { ideal: oldDeviceId } : undefined, facingMode: { ideal: oldSettings.facingMode || 'user' }, width: { ideal: trackWidth }, height: { ideal: trackHeight }, frameRate: { ideal: trackFrameRate } } });const recoveryTrack = recovery.getVideoTracks()[0];ss.localStream.addTrack(recoveryTrack);-- for (const [, pc] of ss.viewerConnections) {-- const sender = pc.getSenders().find(s => s.track === null || s.track?.kind === 'video');-- if (sender) sender.replaceTrack(recoveryTrack);-- }syncRobotStreamerTracks(broadcastState.activeStreamId).catch(() => {});_syncSfuProducerTracks(broadcastState.activeStreamId).catch(() => {});const preview = document.getElementById('bc-video-preview'); if (preview) preview.srcObject = ss.localStream;@@@ -5111,20 +4921,10 @@ async function toggleCameraOverlay() {}}--/** Replace live tracks across all active outputs (legacy P2P only when enabled, RobotStreamer, and SFU producers). */++/** Replace live tracks across all active outputs (RobotStreamer and SFU producers). */function _replaceAllViewerTracks(streamId) {const ss = getStreamState(streamId);if (!ss || !ss.localStream) return;-- const nvt = ss.localStream.getVideoTracks()[0];-- const nat = ss.localStream.getAudioTracks()[0];-- if (ss._allowP2pFallback) {-- for (const [, pc] of ss.viewerConnections) {-- const vs = pc.getSenders().find(s => s.track?.kind === 'video' || s.track === null);-- if (vs && nvt) vs.replaceTrack(nvt);-- const as = pc.getSenders().find(s => s.track?.kind === 'audio');-- if (as && nat) as.replaceTrack(nat);-- }-- }syncRobotStreamerTracks(streamId).catch(() => {});// Also update the local SFU mediasoup producers so server-side consumers get the new tracks_syncSfuProducerTracks(streamId).catch(() => {});@@@ -5367,14 +5167,6 @@ function _republishAudioTrack(streamId, track) {ss.robotStreamer?.audioProducer?.replaceTrack({ track }).catch((e) => console.warn('[RS Restream] audio replaceTrack failed:', e.message));} catch { /* */ }-- // Direct viewer peer connections-- try {-- ss.viewerConnections?.forEach((pc) => {-- const sender = pc.getSenders?.().find(x => x.track && x.track.kind === 'audio');-- sender?.replaceTrack(track).catch(() => {});-- });-- } catch { /* */ }--// VOD: MediaRecorder is bound to the tracks it was constructed with, so roll the// segment. uploadVodRecording({finalizeStream:false}) flushes what has been captured// so far without ending the recording server-side; startVodRecording() then opens a@@@ -253,7 +253,6 @@ function _collectPlayerRuntimeSnapshot() {videoSize: video ? `${video.videoWidth || 0}x${video.videoHeight || 0}` : 'n/a',signalingWs: player?.ws ? player.ws.readyState : 'n/a',sfuTransport: player?._sfuRecvTransport?.connectionState || 'n/a',-- legacyPc: player?.pc?.iceConnectionState || 'n/a',trackSummary: tracks.length? tracks.map((track) => `${track.kind}:${track.readyState}:muted=${track.muted}:enabled=${track.enabled}`).join(', '): 'none',@@@ -283,7 +282,6 @@ function _buildPlayerDiagnosticsText() {`video_size=${snapshot.videoSize}`,`signaling_ws=${snapshot.signalingWs}`,`sfu_transport=${snapshot.sfuTransport}`,-- `legacy_pc=${snapshot.legacyPc}`,`tracks=${snapshot.trackSummary}`,`network_hint=${snapshot.networkHint}`,`user_agent=${navigator.userAgent}`,@@@ -1115,7 +1113,7 @@ function startJSMPEG(wsUrl, canvas, placeholder, bufferProfile = getJsmpegBufferasync function initWebRTC(stream) {const video = document.getElementById('video-element');const placeholder = document.querySelector('.video-placeholder');-- streamRef = stream; // set module-level ref for clip recording in handleViewerOffer++ streamRef = stream; // set module-level ref for clip recordingtry {video.playsInline = true;@@@ -1152,7 +1150,7 @@ async function initWebRTC(stream) {const wsUrl = `${protocol}://${host}${portSuffix}/ws/broadcast?streamId=${streamRef.id}&role=viewer&token=${token}`;const ws = new WebSocket(wsUrl);-- player = { ws, video, pc: null, myPeerId: null, watchSent: false, _wsUrl: wsUrl, _serverIceServers: null };++ player = { ws, video, myPeerId: null, watchSent: false, _wsUrl: wsUrl, _serverIceServers: null };const _myGen = _playerGen; // this session's generation; stale once destroyPlayer bumps itlet _broadcasterDisconnectTimer = null;let _viewerReconnectTimer = null;@@@ -1212,9 +1210,6 @@ async function initWebRTC(stream) {}, delay);};-- // Expose startWatchOfferTimeout on player so handleViewerOffer's triggerRewatch can use it-- player._startWatchOfferTimeout = startWatchOfferTimeout;--ws.onopen = () => {console.log('[Player] Broadcast signaling connected');_updateStatus('Looking for the live feed', {@@@ -1224,8 +1219,8 @@ async function initWebRTC(stream) {showDiagnostics: true,});_viewerReconnectDelay = 3000; // reset backoff on successful connect-- const pcState = player?.pc?.iceConnectionState;-- if (!player.watchSent || pcState === 'failed' || pcState === 'disconnected' || pcState === 'closed') {++ const sfuState = player?._sfuRecvTransport?.connectionState;++ if (!player.watchSent || sfuState === 'failed' || sfuState === 'disconnected' || sfuState === 'closed') {scheduleViewerRewatch(250);}};@@@ -1240,7 +1235,6 @@ async function initWebRTC(stream) {if (msg.iceServers && Array.isArray(msg.iceServers)) {player._serverIceServers = msg.iceServers;}-- player._allowP2pFallback = !!msg.allowP2pFallback;console.log('[Player] Welcome, peerId:', msg.peerId, 'iceServers:', (player._serverIceServers || []).length);_updateStatus('Waiting for the live feed', {phase: 'queue',@@@ -1270,13 +1264,6 @@ async function initWebRTC(stream) {// Hide reconnecting indicator if shown_hideReconnectingIndicator();// Skip re-negotiation if the existing media path is still healthy.-- if (player.pc) {-- const state = player.pc.iceConnectionState;-- if (state === 'connected' || state === 'completed') {-- console.log(`[Player] Peer connection still healthy (ICE: ${state}), skipping re-negotiate`);-- break;-- }-- }if (player._sfuRecvTransport) {const state = player._sfuRecvTransport.connectionState;if (state === 'connected') {@@@ -1290,28 +1277,6 @@ async function initWebRTC(stream) {player.watchSent = true;startWatchOfferTimeout();break;-- case 'offer':-- if (!player._allowP2pFallback) {-- console.warn('[Player] Ignoring legacy P2P offer — SFU-only mode is active');-- _pushPlayerDiagnostic('p2p.offer.ignored', 'Received unexpected offer while SFU-only mode was active');-- break;-- }-- // Broadcaster sent us an offer (legacy P2P rollback path) — create answer-- console.log('[Player] Received offer from broadcaster');-- _updateStatus('Switching to the fallback path', {-- phase: 'transport',-- detail: 'Emergency P2P fallback is enabled for this session. Negotiating direct media.',-- hint: 'This path should normally stay disabled. Copy the log if you were not expecting it.',-- showDiagnostics: true,-- });-- // Clear the watch-to-offer timeout — offer received successfully-- if (_watchOfferTimer) { clearTimeout(_watchOfferTimer); _watchOfferTimer = null; }-- _rewatchCount = 0; // reset retry count on successful offer-- _hideReconnectingIndicator();-- // Clear any stale error overlay — we got a valid offer-- _clearStreamError();-- await handleViewerOffer(msg, player.ws, video);-- break;case 'sfu-viewer-ready':// Server has SFU producers — use mediasoup-client RecvTransportconsole.log('[Player] SFU viewer ready — starting mediasoup-client flow');@@@ -1337,16 +1302,6 @@ async function initWebRTC(stream) {_sfuViewerSetupInProgress = false;}break;-- case 'ice-candidate':-- if (!player._allowP2pFallback) {-- console.warn('[Player] Ignoring legacy P2P ICE candidate — SFU-only mode is active');-- break;-- }-- // ICE candidate from broadcaster-- if (player.pc && msg.candidate) {-- await player.pc.addIceCandidate(new RTCIceCandidate(msg.candidate));-- }-- break;case 'broadcaster-disconnected':console.log('[Player] Broadcaster signaling disconnected — media may still be active');_pushPlayerDiagnostic('signal.disconnect', 'Broadcaster signaling disconnected while viewer session remained open');@@@ -1358,14 +1313,14 @@ async function initWebRTC(stream) {if (_broadcasterDisconnectTimer) clearTimeout(_broadcasterDisconnectTimer);_broadcasterDisconnectTimer = setTimeout(() => {_broadcasterDisconnectTimer = null;-- // Check if PeerConnection is still delivering media-- const pcState = player?.pc?.iceConnectionState;-- if (pcState === 'connected' || pcState === 'completed') {-- // PC still working — don't show "ended", just log-- console.log('[Player] Broadcaster signaling gone but PC still connected, waiting...');++ // Check if the SFU receive transport is still delivering media++ const sfuState = player?._sfuRecvTransport?.connectionState;++ if (sfuState === 'connected') {++ // Transport still working — don't show "ended", just log++ console.log('[Player] Broadcaster signaling gone but SFU transport still connected, waiting...');return;}-- console.log('[Player] Broadcaster did not reconnect and PC is dead, stream ended');++ console.log('[Player] Broadcaster did not reconnect and SFU transport is dead, stream ended');_hideReconnectingIndicator();showStreamEnded();}, 60000);@@@ -1402,7 +1357,7 @@ async function initWebRTC(stream) {_showReconnectingIndicator();// Clear the SFU frozen checker — a new one will start after recoveryif (player._sfuFrozenInterval) { clearInterval(player._sfuFrozenInterval); player._sfuFrozenInterval = null; }-- // Gentle poll: send watch every 10 s WITHOUT starting the P2P offer timeout.++ // Gentle poll: send watch every 10 s WITHOUT starting the watch-response timeout.// The server will respond with sfu-viewer-ready, sfu-source-unavailable, or watch-queued._viewerRewatchTimer = setTimeout(() => {_viewerRewatchTimer = null;@@@ -1490,308 +1445,9 @@ async function initWebRTC(stream) {}}--async function handleViewerOffer(msg, ws, video) {-- // Safety: player must be set by initWebRTC before we get here-- if (!player) {-- console.warn('[Player] handleViewerOffer called but player is null');-- return;-- }-- // Close existing PC if re-negotiating — detach handlers first to prevent-- // the old PC's 'closed' state from triggering a cascading re-watch loop-- if (player.pc) {-- const oldPc = player.pc;-- oldPc.oniceconnectionstatechange = null;-- oldPc.ontrack = null;-- oldPc.onicecandidate = null;-- try { oldPc.close(); } catch (ignored) {}-- }-- // Clear any pending stall/ICE timers from the previous connection-- if (player._iceTimeout) { clearTimeout(player._iceTimeout); player._iceTimeout = null; }-- if (player._stallTimer) { clearTimeout(player._stallTimer); player._stallTimer = null; }-- if (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }---- // Use server-provided ICE servers (with TURN support) if available, else fallback to STUN-only-- const iceServers = (player._serverIceServers && player._serverIceServers.length > 0)-- ? player._serverIceServers-- : [-- { urls: 'stun:stun.l.google.com:19302' },-- { urls: 'stun:stun1.l.google.com:19302' },-- { urls: 'stun:stun2.l.google.com:19302' },-- { urls: 'stun:stun3.l.google.com:19302' },-- { urls: 'stun:stun4.l.google.com:19302' },-- ];---- const pc = new RTCPeerConnection({ iceServers });-- player.pc = pc;-- let _iceConnected = false;-- let _hasVideoFrames = false;-- let _playPending = false; // debounce play() across multiple ontrack events---- // Schedules a re-watch if the current PC is still ours-- const triggerRewatch = (reason) => {-- if (!player || player.pc !== pc) return;-- // Track ICE failures separately — these never reset (prevents infinite loop)-- if (reason.includes('ICE') || reason.includes('stall') || reason.includes('SDP')) {-- _totalIceFailures++;-- console.log(`[Player] Re-watching: ${reason} (ICE failures: ${_totalIceFailures}/${MAX_ICE_FAILURES})`);-- if (_totalIceFailures >= MAX_ICE_FAILURES) {-- console.error('[Player] Too many ICE/connection failures — giving up');-- showStreamError('Could not establish media connection. Your network may be blocking WebRTC traffic. Try a different network or refresh the page.');-- return;-- }-- } else {-- console.log(`[Player] Re-watching: ${reason}`);-- }-- player.watchSent = false;-- sendPlayerSignal({ type: 'watch' });-- player.watchSent = true;-- // Start watch-to-offer timeout for this re-watch too-- if (player._startWatchOfferTimeout) player._startWatchOfferTimeout();-- };---- // Debounced play() — always starts muted (guaranteed autoplay), then unmutes if allowed.-- // Previous approach tried unmuted first, hit NotAllowedError, then retried muted — but the-- // muted retry could also fail with AbortError if the first play() was still settling,-- // leading to "Playback failed even muted" and no frames ever rendering.-- const tryPlay = () => {-- if (_playPending || !player || player.pc !== pc) return;-- // Viewer deliberately paused — don't force playback on a reconnect.-- if (viewerWantsPaused()) { try { video.pause(); } catch {} return; }-- _playPending = true;-- if (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }---- // Always start muted — this is the only way to guarantee autoplay across all browsers.-- // We unmute after playback starts if the user hasn't muted.-- video.muted = true;-- video.volume = 1;---- video.play().then(() => {-- _playPending = false;-- console.log('[Player] Playback started (muted autoplay)');---- // Now try to unmute based on user prefs-- const audioPrefs = getSavedPlayerAudioState();-- if (!audioPrefs.muted) {-- video.volume = Math.max(0.01, audioPrefs.volume);-- video.muted = false;-- // If unmuting fails (autoplay policy), show overlay-- // Some browsers will pause on unmute — re-play muted if needed-- const playPromise = video.play();-- if (playPromise) {-- playPromise.catch(() => {-- video.muted = true;-- video.play().catch(() => {});-- showUnmuteOverlay(video);-- });-- }-- } else {-- video.muted = true;-- video.volume = 0;-- // Already muted — remove stale overlay if present-- document.getElementById('unmute-overlay')?.remove();-- }-- }).catch((err) => {-- _playPending = false;-- if (err.name === 'AbortError') {-- // play() interrupted — retry after a tick (second ontrack can cause this)-- console.log('[Player] play() interrupted, retrying in 300ms');-- player._playRetryTimer = setTimeout(() => {-- player._playRetryTimer = null;-- _playPending = false;-- tryPlay();-- }, 300);-- } else {-- console.warn('[Player] Muted play() failed:', err.name, err.message);-- // Retry once after short delay — video element may not have enough data yet-- player._playRetryTimer = setTimeout(() => {-- player._playRetryTimer = null;-- _playPending = false;-- tryPlay();-- }, 1000);-- }-- });-- };---- let _trackCount = 0; // count received tracks — only tryPlay after both video+audio arrive---- pc.ontrack = (e) => {-- _trackCount++;-- console.log('[Player] Got remote track:', e.track.kind, `(${_trackCount} total)`);-- if (e.streams && e.streams[0]) {-- video.srcObject = e.streams[0];-- startClipRecordingIfNeeded(e.streams[0], streamRef?.id);-- } else {-- let mediaStream = video.srcObject;-- if (!mediaStream) {-- mediaStream = new MediaStream();-- video.srcObject = mediaStream;-- }-- mediaStream.addTrack(e.track);-- startClipRecordingIfNeeded(mediaStream, streamRef?.id);-- }---- // Monitor remote track health — if it ends or mutes, trigger re-watch-- const track = e.track;-- track.addEventListener('ended', () => {-- if (player?.pc !== pc) return;-- console.warn(`[Player] Remote ${track.kind} track ended`);-- triggerRewatch(`remote ${track.kind} track ended`);-- }, { once: true });-- track.addEventListener('mute', () => {-- if (player?.pc !== pc) return;-- console.warn(`[Player] Remote ${track.kind} track muted`);-- // Give muted tracks a grace period — they may unmute on their own (e.g. track replacement)-- setTimeout(() => {-- if (player?.pc !== pc || !track.muted) return;-- triggerRewatch(`remote ${track.kind} track stayed muted`);-- }, 5000);-- }, { once: true });---- // Show the video element now that we have tracks-- video.style.display = 'block';---- // Register the playing event listener for EACH new PC (fresh _hasVideoFrames each time)-- if (_trackCount === 1) {-- const onPlaying = () => {-- video.removeEventListener('playing', onPlaying);-- _hasVideoFrames = true;-- if (player._stallTimer) { clearTimeout(player._stallTimer); player._stallTimer = null; }-- _hidePlayerPlaceholder();-- // Only remove unmute overlay if video is actually playing with audio-- if (!video.muted) {-- document.getElementById('unmute-overlay')?.remove();-- }-- };-- video.addEventListener('playing', onPlaying);-- }---- // Video stall detection — if no frames render within 8s of getting tracks, re-watch-- if (!player._stallTimer && !_hasVideoFrames) {-- player._stallTimer = setTimeout(() => {-- player._stallTimer = null;-- if (player?.pc !== pc || _hasVideoFrames) return;-- // Check if video element is actually rendering-- if (video.videoWidth === 0 || video.paused || video.readyState < 2) {-- console.warn('[Player] Video stall detected — no frames after 8s');-- triggerRewatch('video stall — no frames rendered');-- }-- }, 8000);-- }---- // Delay tryPlay until we have at least 2 tracks (video + audio) or 500ms-- // after first track (handles audio-only/video-only streams).-- // This prevents the first tryPlay() from racing with the second ontrack-- // which causes AbortError on the first play() call.-- if (_trackCount >= 2) {-- tryPlay();-- } else if (_trackCount === 1) {-- player._playRetryTimer = setTimeout(() => {-- player._playRetryTimer = null;-- _playPending = false;-- tryPlay();-- }, 500);-- }-- };---- pc.onicecandidate = (e) => {-- // Use player.ws (not the passed ws param) so this works after WS reconnection-- if (e.candidate) {-- sendPlayerSignal({-- type: 'ice-candidate',-- candidate: e.candidate,-- });-- }-- };---- pc.oniceconnectionstatechange = () => {-- // Ignore state changes from a stale (replaced) PC-- if (!player || player.pc !== pc) return;-- console.log('[Player] ICE state:', pc.iceConnectionState);-- if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {-- _iceConnected = true;-- if (player._iceTimeout) { clearTimeout(player._iceTimeout); player._iceTimeout = null; }-- // If the stall timer is already running and no frames yet, restart it from ICE-connected-- // time — ICE negotiation itself can take several seconds, so the 8s clock should start-- // from when media can actually flow, not from when ontrack fired.-- if (player._stallTimer && !_hasVideoFrames) {-- clearTimeout(player._stallTimer);-- player._stallTimer = setTimeout(() => {-- player._stallTimer = null;-- if (player?.pc !== pc || _hasVideoFrames) return;-- if (video.videoWidth === 0 || video.paused || video.readyState < 2) {-- console.warn('[Player] Video stall detected — no frames after ICE connected + 8s');-- triggerRewatch('video stall — no frames after ICE connect');-- }-- }, 8000);-- }-- return;-- }-- if (pc.iceConnectionState === 'failed') {-- if (player._iceTimeout) { clearTimeout(player._iceTimeout); player._iceTimeout = null; }-- triggerRewatch('ICE failed');-- return;-- }-- if (pc.iceConnectionState === 'disconnected') {-- setTimeout(() => {-- if (!player?.pc || player.pc !== pc) return;-- const state = pc.iceConnectionState;-- if (state === 'disconnected' || state === 'failed') {-- triggerRewatch('ICE disconnected/failed after grace period');-- }-- }, 2500);-- }-- // 'disconnected' is transient and often recovers on its own — don't show error-- // 'closed' is handled by detaching handlers before close — no cascading re-watch-- };---- // Wrap SDP operations in try/catch — retry on failure instead of silent black screen-- try {-- await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));-- const answer = await pc.createAnswer();-- await pc.setLocalDescription(answer);---- // Use player.ws (not the passed ws param) so this works after WS reconnection-- sendPlayerSignal({-- type: 'answer',-- sdp: answer,-- });-- console.log('[Player] Sent answer to broadcaster');-- } catch (sdpErr) {-- console.error('[Player] SDP negotiation failed:', sdpErr);-- // Close the failed PC and retry after a short delay-- pc.oniceconnectionstatechange = null;-- pc.ontrack = null;-- try { pc.close(); } catch {}-- if (player.pc === pc) player.pc = null;-- setTimeout(() => triggerRewatch('SDP negotiation failed'), 2000);-- return;-- }---- // ICE connection timeout — if not connected within 10s, something is stuck-- player._iceTimeout = setTimeout(() => {-- player._iceTimeout = null;-- if (!player || player.pc !== pc || _iceConnected) return;-- const state = pc.iceConnectionState;-- if (state !== 'connected' && state !== 'completed') {-- console.warn(`[Player] ICE timeout after 10s (state: ${state})`);-- triggerRewatch('ICE connection timeout');-- }-- }, 10000);--}----/* ── SFU Viewer via mediasoup-client (replaces hand-built SDP) ── */async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatch) {if (!player) return;-- // Clean up old PC if any (from P2P fallback or previous SFU attempt)-- if (player.pc) {-- const oldPc = player.pc;-- oldPc.oniceconnectionstatechange = null;-- oldPc.ontrack = null;-- oldPc.onicecandidate = null;-- try { oldPc.close(); } catch {}-- player.pc = null;-- }if (player._iceTimeout) { clearTimeout(player._iceTimeout); player._iceTimeout = null; }if (player._stallTimer) { clearTimeout(player._stallTimer); player._stallTimer = null; }if (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }@@@ -2989,7 +2645,7 @@ function setupVideoControls() {const audioPrefs = getSavedPlayerAudioState();const savedVol = Math.round(audioPrefs.volume * 100);volSlider.value = savedVol;-- // For WebRTC, tryPlay() in handleViewerOffer sets volume + handles autoplay policy.++ // For WebRTC (SFU), the player start path sets volume + handles autoplay policy.// For JSMPEG, onSourceEstablished handles it. For HLS, set it here.if (playerType === 'hls') {setVolume(savedVol / 100, { muted: audioPrefs.muted });@@@ -3757,7 +3413,6 @@ function destroyPlayer() {if (player) {if (playerType === 'jsmpeg' && player.destroy) player.destroy();if (playerType === 'webrtc') {-- if (player.pc) player.pc.close();if (player.ws) player.ws.close();}if (playerType === 'hls') {@@@ -3921,9 +3576,8 @@ function toggleStreamStats(force) {}function _getStatsSource() {-- // Both RTCPeerConnection (legacy) and the mediasoup recv Transport expose getStats()++ // The mediasoup recv Transport exposes getStats()if (typeof player !== 'undefined' && player) {-- if (player.pc && typeof player.pc.getStats === 'function') return player.pc;if (player._sfuRecvTransport && typeof player._sfuRecvTransport.getStats === 'function') return player._sfuRecvTransport;}return null;@@@ -86,14 +86,10 @@ function buildConfig(registryValues) {const rtmpEntry = getRegistryEntry('RTMP_HOST');const turnEntry = getRegistryEntry('TURN_URL');const turnUrl = normalizeTurnUrl(turnEntry.value || process.env.TURN_URL || DEFAULTS.TURN_URL);-- const allowP2pFallback = normalizeValue(process.env.ALLOW_P2P_FALLBACK, 'boolean') === true;if (turnEntry.source !== 'default' && !turnUrl) {console.warn('[Config] Invalid TURN_URL configured; ICE metadata will be skipped. Expected a turn: or turns: URL with hostname and optional port/path.');}-- if (allowP2pFallback) {-- console.warn('[Config] ALLOW_P2P_FALLBACK is enabled. Legacy peer-to-peer viewer relays may expose client IP addresses and should be used only for emergency rollback.');-- }const baseUrl = baseEntry.source !== 'default' ? baseEntry.value : DEFAULTS.BASE_URL;// Derive the public URL for openvibe.network (the SSO/auth provider).@@@ -185,7 +181,6 @@ function buildConfig(registryValues) {username: process.env.TURN_USERNAME || '',credential: process.env.TURN_CREDENTIAL || '',},-- allowP2pFallback,mediasoup: {listenIp: process.env.MEDIASOUP_LISTEN_IP || '0.0.0.0',announcedIp: mediasoupAnnouncedIp,@@@ -7,9 +7,9 @@* 1. Broadcaster connects with JWT token + streamId* 2. Broadcaster auto-publishes audio/video into the Mediasoup SFU* 3. Viewer connects, sends 'watch', and is either queued or attached to the SFU-- * 4. Legacy P2P relay messages are ignored unless ALLOW_P2P_FALLBACK is enabled*-- * Each stream has ONE broadcaster and MANY viewers.++ * Each stream has ONE broadcaster and MANY viewers. The SFU (mediasoup) is the++ * only media path — there is no direct peer-to-peer viewer relay.* The server primarily orchestrates SFU transports and viewer queueing.*/const { EventEmitter } = require('events');@@@ -101,7 +101,6 @@ class BroadcastServer extends EventEmitter {type: 'watch-queued',reason,detail,-- allowP2pFallback: !!config.allowP2pFallback,});this._logMetric('viewer.queued', {streamId: client.streamId,@@@ -327,7 +326,6 @@ class BroadcastServer extends EventEmitter {streamId,viewerCount: room.viewers.size,iceServers: this._getIceServers(),-- allowP2pFallback: !!config.allowP2pFallback,});// Broadcast viewer count@@@ -361,24 +359,6 @@ class BroadcastServer extends EventEmitter {if (!room) return;switch (msg.type) {-- case 'offer':-- case 'answer':-- case 'ice-candidate':-- if (!config.allowP2pFallback) {-- this._logMetric('p2p.relay.attempt', {-- streamId: client.streamId,-- peerId: client.peerId,-- type: msg.type,-- outcome: 'ignored',-- });-- console.warn(`[Broadcast] Ignoring legacy ${msg.type} for stream ${client.streamId} — SFU-only mode is active`);-- break;-- }-- // For SFU viewers, ignore direct P2P signaling — mediasoup handles everything.-- if (client._sfuViewerTransportId) break;-- this.relaySignaling(ws, client, room, msg);-- break;--case 'watch':// Viewer requests to watchif (client.role === 'viewer') {@@@ -407,20 +387,6 @@ class BroadcastServer extends EventEmitter {}}).catch(err => {console.error(`[Broadcast] SFU viewer error for ${client.peerId}:`, err.message);-- if (config.allowP2pFallback && room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {-- this._logMetric('p2p.relay.attempt', {-- streamId: client.streamId,-- peerId: client.peerId,-- type: 'viewer-joined',-- outcome: 'fallback',-- });-- this.safeSend(room.broadcaster, {-- type: 'viewer-joined',-- peerId: client.peerId,-- });-- return;-- }--if (room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {this._requestSfuProduceWarmup(room, client.streamId);}@@@ -525,46 +491,6 @@ class BroadcastServer extends EventEmitter {}}-- relaySignaling(ws, client, room, msg) {-- this._logMetric('p2p.relay.attempt', {-- streamId: client.streamId,-- peerId: client.peerId,-- type: msg.type,-- outcome: 'relayed',-- });-- if (client.role === 'broadcaster') {-- // Broadcaster sending to a specific viewer-- const targetPeerId = msg.targetPeerId;-- if (targetPeerId && room.viewers.has(targetPeerId)) {-- const viewerWs = room.viewers.get(targetPeerId);-- if (viewerWs.readyState === WebSocket.OPEN) {-- this.safeSend(viewerWs, {-- type: msg.type,-- sdp: msg.sdp,-- candidate: msg.candidate,-- fromPeerId: 'broadcaster',-- });-- } else {-- console.warn(`[Broadcast] Cannot relay ${msg.type} to ${targetPeerId} — viewer WS not open (state: ${viewerWs.readyState})`);-- }-- } else if (targetPeerId) {-- console.warn(`[Broadcast] Cannot relay ${msg.type} — viewer ${targetPeerId} not found in room (stream ${client.streamId})`);-- }-- } else {-- // Viewer sending to broadcaster-- if (room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {-- this.safeSend(room.broadcaster, {-- type: msg.type,-- sdp: msg.sdp,-- candidate: msg.candidate,-- fromPeerId: client.peerId,-- });-- } else {-- console.warn(`[Broadcast] Cannot relay ${msg.type} from viewer ${client.peerId} — broadcaster not connected (stream ${client.streamId})`);-- }-- }-- }--handleDisconnect(ws) {const client = this.clients.get(ws);if (!client) return;@@@ -617,14 +543,6 @@ class BroadcastServer extends EventEmitter {this._cleanupSfuViewerTransport(client);console.log(`[Broadcast] Viewer disconnected: stream ${client.streamId} (${client.peerId})`);---- // Notify broadcaster only when legacy P2P fallback is explicitly enabled.-- if (config.allowP2pFallback && room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {-- this.safeSend(room.broadcaster, {-- type: 'viewer-left',-- peerId: client.peerId,-- });-- }}this.broadcastViewerCount(client.streamId);@@@ -885,9 +803,9 @@ class BroadcastServer extends EventEmitter {// True stale: ICE never connected or has definitively failed.console.log(`[Broadcast] All ${allProducers.length} producer(s) are stale for stream ${client.streamId} — sending source-unavailable to ${client.peerId}`);this.safeSend(ws, { type: 'sfu-source-unavailable', reason: 'ingest_stale' });-- return true; // handled — do not fall through to P2P offer path++ return true; // handled — source-unavailable already sent}-- console.log(`[Broadcast] No live producers for stream ${client.streamId} (${allProducers.length} total, all dead/disconnected) — falling back to P2P`);++ console.log(`[Broadcast] No live SFU producers for stream ${client.streamId} (${allProducers.length} total, all dead/disconnected) — viewer will be queued`);return false;}@@@ -4,7 +4,7 @@** Every consumer of the broadcast audio binds to a track ONCE and keeps its own* reference — MediaRecorder snapshots its tracks at construction, mediasoup Producers-- * wrap a specific track, viewer RTCPeerConnection senders hold theirs. So the old++ * wrap a specific track, the RobotStreamer relay holds its own. So the old* removeTrack/addTrack dance on ss.localStream changed nothing for anyone: toggling the* mic mid-stream left viewers hearing the previous track and the VOD recording it,* silently, for the rest of the session.@@@ -51,12 +51,11 @@ const republish = bc.slice(bc.indexOf('function _republishAudioTrack('));for (const [what, re] of [['the local SFU producer', /audioProducer\?\.replaceTrack\(\{ track \}\)/],['the RobotStreamer producer', /robotStreamer\?\.audioProducer\?\.replaceTrack/],-- ['viewer peer connections', /getSenders\?\.\(\)[\s\S]{0,120}replaceTrack\(track\)/],['the VOD recorder', /uploadVodRecording\(streamId, \{ finalizeStream: false \}\)[\s\S]{0,120}startVodRecording\(streamId\)/],]) {assert.ok(re.test(republish), `_republishAudioTrack must update ${what}`);}--console.log('OK D: SFU, RobotStreamer, viewer PCs and the VOD segment are all updated on a real track change');++console.log('OK D: the SFU, RobotStreamer and the VOD segment are all updated on a real track change');// ── E: the mixer is torn down with the capture ───────────────────────────────────────assert.ok(/_teardownAudioMixer\(ss\)/.test(bc), 'the mixer must be closed when capture stops');nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++++/**++ * C-77 — ALLOW_P2P_FALLBACK is gone; the env name is inert.++ *++ * A broadcast must behave exactly the same whether the legacy variable is set++ * or not. We prove that at two levels:++ * 1. Building the server config with ALLOW_P2P_FALLBACK set to various values++ * yields a config byte-identical to the default build, and no++ * `allowP2pFallback` field exists to read.++ * 2. No broadcast code path (server or client) names the variable at all, so++ * every downstream decision is taken without it.++ */++++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const CONFIG_PATH = require.resolve('../server/config');++++function buildConfig(envValue) {++ delete require.cache[CONFIG_PATH];++ if (envValue === undefined) {++ delete process.env.ALLOW_P2P_FALLBACK;++ } else {++ process.env.ALLOW_P2P_FALLBACK = envValue;++ }++ // JSON round-trip drops functions (refreshRegistry, _normalizeTurnUrl) so the++ // snapshot is a pure comparison of the config values a broadcast would read.++ return JSON.stringify(require('../server/config'));++}++++const withoutFlag = buildConfig(undefined);++delete process.env.ALLOW_P2P_FALLBACK; // leave the process clean for any later test++++assert.ok(++ !Object.prototype.hasOwnProperty.call(JSON.parse(withoutFlag), 'allowP2pFallback'),++ 'server config must not expose an allowP2pFallback field'++);++assert.strictEqual(++ buildConfig('true'), withoutFlag,++ 'config with ALLOW_P2P_FALLBACK=true must be identical to config without it'++);++assert.strictEqual(++ buildConfig('1'), withoutFlag,++ 'config with ALLOW_P2P_FALLBACK=1 must be identical to config without it'++);++assert.strictEqual(++ buildConfig('false'), withoutFlag,++ 'config with ALLOW_P2P_FALLBACK=false must be identical to config without it'++);++delete process.env.ALLOW_P2P_FALLBACK;++++// No broadcast code reads or emits the name — the variable cannot influence a broadcast.++const SOURCES = [++ '../server/config.js',++ '../server/streaming/broadcast-server.js',++ '../public/js/broadcast.js',++ '../public/js/broadcast-state.js',++ '../public/js/stream-player.js',++];++for (const rel of SOURCES) {++ const src = fs.readFileSync(path.join(__dirname, rel), 'utf8');++ assert.ok(++ !src.includes('ALLOW_P2P_FALLBACK') && !src.includes('allowP2pFallback'),++ `${rel} must not reference ALLOW_P2P_FALLBACK (it is ignored)`++ );++}++++console.log('OK: ALLOW_P2P_FALLBACK is ignored — a broadcast behaves the same with or without it');@@@ -11,9 +11,9 @@* E. Client-side: 15s transport connect timeout + null-before-close cascade guard* F. WHIP ICE disconnect: grace timer + explicit producer-removed emit in cleanupSession* G. Broadcast-server: ICE-state filter + stale-source path + watch-queued message-- * H. Client: sfu-source-unavailable and watch-queued handled without P2P offer timeout++ * H. Client: sfu-source-unavailable and watch-queued handled without the watch-response timeout* I. Client: frozen-video detector starts after play, escalates keyframe -> rebuild-- * J. Broadcaster auto-publishes into SFU and legacy P2P is explicitly gated++ * J. Broadcaster auto-publishes into SFU (the SFU is the only media path)* K. Server startup logs TURN / announced-IP diagnostics for SFU viewers*/@@@ -159,8 +159,8 @@ assert.ok('broadcast-server.js must send watch-queued when viewer is added to pending queue');assert.ok(-- bcastSrc.includes('config.allowP2pFallback') && bcastSrc.includes('p2p.relay.attempt'),-- 'broadcast-server.js must gate legacy P2P relay behind config.allowP2pFallback and log relay attempts'++ !bcastSrc.includes('allowP2pFallback') && !bcastSrc.includes('p2p.relay.attempt') && !bcastSrc.includes('relaySignaling'),++ 'broadcast-server.js must carry no legacy P2P relay path — the SFU is the only media path');assert.ok(bcastSrc.includes('viewer.queued') && bcastSrc.includes('viewer.notified'),@@@ -234,24 +234,20 @@ assert.ok();console.log('OK I: frozen-video detector nudges with a keyframe, then rebuilds the transport');--// ── J: broadcaster auto-publishes into SFU, P2P rollback gated ──++// ── J: broadcaster auto-publishes into SFU (SFU is the only media path) ──assert.ok(broadcastClientSrc.includes("_ensureSfuBroadcastReady(streamId, 'signaling-open')"),'broadcast.js must auto-start SFU publishing when broadcaster signaling opens');assert.ok(-- broadcastClientSrc.includes('ss._allowP2pFallback = !!msg.allowP2pFallback'),-- 'broadcast.js must track whether legacy P2P rollback is enabled'++ !broadcastClientSrc.includes('_allowP2pFallback') && !broadcastClientSrc.includes('createViewerConnection'),++ 'broadcast.js must carry no legacy P2P viewer-connection path — SFU-only');assert.ok(-- broadcastClientSrc.includes('SFU-only mode is active'),-- 'broadcast.js must ignore legacy P2P viewer signaling while SFU-only mode is active'++ !configSrc.includes('ALLOW_P2P_FALLBACK') && !configSrc.includes('allowP2pFallback'),++ 'server/config.js must not expose the removed ALLOW_P2P_FALLBACK flag');--assert.ok(-- configSrc.includes('ALLOW_P2P_FALLBACK') && configSrc.includes('allowP2pFallback'),-- 'server/config.js must expose the ALLOW_P2P_FALLBACK feature flag'--);--console.log('OK J: broadcaster auto-publishes into SFU and P2P rollback is explicitly gated');++console.log('OK J: broadcaster auto-publishes into SFU — the SFU is the only media path');// ── K: server startup TURN / announced-IP diagnostics ───────────assert.ok(
@@ -130,7 +130,6 @@ TURN_CREDENTIAL=
@@ -130,7 +130,6 @@ TURN_CREDENTIAL=
# Preferred: coturn `use-auth-secret` + `static-auth-secret=<this>` — every ICE list then carries a
# Preferred: coturn `use-auth-secret` + `static-auth-secret=<this>` — every ICE list then carries a
# credential that expires after an hour instead of the static pair above.
# credential that expires after an hour instead of the static pair above.
TURN_AUTH_SECRET=
TURN_AUTH_SECRET=
-ALLOW_P2P_FALLBACK=false
# PayPal (for Vibes cashout — Vibes stay Live-local)
# PayPal (for Vibes cashout — Vibes stay Live-local)
PAYPAL_CLIENT_ID=
PAYPAL_CLIENT_ID=
@@ -130,7 +130,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.
@@ -130,7 +130,7 @@ Completion arrives via the `vod.ready` / `clip.ready` webhooks.
- `MEDIASOUP_LISTEN_IP`, `MEDIASOUP_ANNOUNCED_IP`, `MEDIASOUP_MIN_PORT` / `MEDIASOUP_MAX_PORT`, `WEBRTC_PORT`.
- `MEDIASOUP_LISTEN_IP`, `MEDIASOUP_ANNOUNCED_IP`, `MEDIASOUP_MIN_PORT` / `MEDIASOUP_MAX_PORT`, `WEBRTC_PORT`.
- `TURN_URL` / `TURN_USERNAME` / `TURN_CREDENTIAL` — required in production for reliable NAT traversal (default `turn:turn.openvibe.live`).
- `TURN_URL` / `TURN_USERNAME` / `TURN_CREDENTIAL` — required in production for reliable NAT traversal (default `turn:turn.openvibe.live`).
-- `ALLOW_P2P_FALLBACK` — emergency rollback flag; leave `false` (legacy P2P relays can expose viewer IPs).
+- Viewer media is SFU-only (mediasoup); there is no peer-to-peer fallback.
### WHIP
### WHIP
@@ -321,7 +321,7 @@ console.log(endpoint.whipUrlBase); // POST your SDP offer here (see WebRTC tab)
@@ -321,7 +321,7 @@ console.log(endpoint.whipUrlBase); // POST your SDP offer here (see WebRTC tab)
<table class="doc-table">
<table class="doc-table">
<thead><tr><th>Type</th><th>Dir</th><th>Key Fields</th><th>Description</th></tr></thead>
<thead><tr><th>Type</th><th>Dir</th><th>Key Fields</th><th>Description</th></tr></thead>
<tbody>
<tbody>
-<tr><td>welcome</td><td>S→C</td><td>peerId, role, streamId, viewerCount, iceServers, allowP2pFallback</td><td>Sent immediately on connect</td></tr>
+<tr><td>welcome</td><td>S→C</td><td>peerId, role, streamId, viewerCount, iceServers</td><td>Sent immediately on connect</td></tr>
<tr><td>watch</td><td>C→S</td><td>—</td><td>Viewer requests to start watching (triggers SFU consumer)</td></tr>
<tr><td>watch</td><td>C→S</td><td>—</td><td>Viewer requests to start watching (triggers SFU consumer)</td></tr>
<tr><td>broadcaster-ready</td><td>S→Viewer</td><td>peerId</td><td>Broadcaster or SFU source is live, safe to send/refresh <code>watch</code></td></tr>
<tr><td>broadcaster-ready</td><td>S→Viewer</td><td>peerId</td><td>Broadcaster or SFU source is live, safe to send/refresh <code>watch</code></td></tr>
<tr><td>watch-queued</td><td>S→Viewer</td><td>reason, detail</td><td>Viewer is queued while the live source warms up or reconnects</td></tr>
<tr><td>watch-queued</td><td>S→Viewer</td><td>reason, detail</td><td>Viewer is queued while the live source warms up or reconnects</td></tr>
@@ -342,7 +342,7 @@ console.log(endpoint.whipUrlBase); // POST your SDP offer here (see WebRTC tab)
@@ -342,7 +342,7 @@ console.log(endpoint.whipUrlBase); // POST your SDP offer here (see WebRTC tab)
<tr><td>stream-ended</td><td>S→Viewers</td><td>—</td><td>Stream has ended</td></tr>
<tr><td>stream-ended</td><td>S→Viewers</td><td>—</td><td>Stream has ended</td></tr>
</tbody>
</tbody>
</table>
</table>
-<p class="muted" style="margin-top:10px">Legacy <code>offer</code> / <code>answer</code> / <code>ice-candidate</code> relay messages are disabled by default. They are only honored when <code>ALLOW_P2P_FALLBACK=true</code> is explicitly set for emergency rollback.</p>
+<p class="muted" style="margin-top:10px">Viewer media is delivered exclusively through the Mediasoup SFU. There is no direct peer-to-peer relay: legacy <code>offer</code> / <code>answer</code> / <code>ice-candidate</code> messages are not supported.</p>
<h3>RTMP Ingest (OBS)</h3>
<h3>RTMP Ingest (OBS)</h3>
<pre><code>Server: rtmp://openvibe.live/live
<pre><code>Server: rtmp://openvibe.live/live
@@ -18,10 +18,6 @@ function createStreamState(streamData) {
@@ -18,10 +18,6 @@ function createStreamState(streamData) {
return {
return {
streamData,
streamData,
localStream: null,
localStream: null,
- /** @type {Map<string, RTCPeerConnection>} peerId → PC */
- viewerConnections: new Map(),
- viewerReconnectTimers: new Map(),
- _allowP2pFallback: false,
signalingWs: null,
signalingWs: null,
heartbeatInterval: null,
heartbeatInterval: null,
startedAt: streamData?.started_at || null,
startedAt: streamData?.started_at || null,
@@ -327,27 +327,6 @@ function _ensureSfuBroadcastReady(streamId, reason = 'startup') {
@@ -327,27 +327,6 @@ function _ensureSfuBroadcastReady(streamId, reason = 'startup') {
});
});
}
}
-function clearViewerReconnectTimer(ss, viewerPeerId) {
- const timer = ss?.viewerReconnectTimers?.get(viewerPeerId);
- if (timer) clearTimeout(timer);
- ss?.viewerReconnectTimers?.delete(viewerPeerId);
-}
-
-function scheduleViewerReconnect(streamId, viewerPeerId, delay = 2000) {
- const ss = getStreamState(streamId);
- if (!ss || !viewerPeerId || !ss._allowP2pFallback) return;
- clearViewerReconnectTimer(ss, viewerPeerId);
- ss.viewerReconnectTimers.set(viewerPeerId, setTimeout(() => {
- clearViewerReconnectTimer(ss, viewerPeerId);
- if (!broadcastState.streams.has(streamId)) return;
- if (!ss.localStream) return;
- if (!ss.signalingWs || ss.signalingWs.readyState !== WebSocket.OPEN) return;
- createViewerConnection(streamId, viewerPeerId).catch((err) => {
- console.warn(`[Broadcast] Viewer reconnect failed for ${viewerPeerId}:`, err.message);
- });
- }, delay));
-}
-
/**
/**
* Attach diagnostic logging to track/stream events.
* Attach diagnostic logging to track/stream events.
*
*
@@ -2307,12 +2286,6 @@ function cleanupStream(streamId) {
@@ -2307,12 +2286,6 @@ function cleanupStream(streamId) {
// Clear RS restream slot if this stream had it
// Clear RS restream slot if this stream had it
if (getRsRestreamSlotStreamId() === streamId) setRsRestreamSlot(null);
if (getRsRestreamSlotStreamId() === streamId) setRsRestreamSlot(null);
- // Close viewer connections
- for (const [, pc] of ss.viewerConnections) { try { pc.close(); } catch {} }
- ss.viewerConnections.clear();
- for (const [, timer] of ss.viewerReconnectTimers) clearTimeout(timer);
- ss.viewerReconnectTimers.clear();
-
// Close signaling WS
// Close signaling WS
ss.signalingIntentionalClose = true;
ss.signalingIntentionalClose = true;
ss.signalingReconnectDelay = 3000;
ss.signalingReconnectDelay = 3000;
@@ -2416,7 +2389,7 @@ function startGlobalDisplayTimers() {
@@ -2416,7 +2389,7 @@ function startGlobalDisplayTimers() {
// Primary source: the SFU produce transport — this IS the broadcaster's
// Primary source: the SFU produce transport — this IS the broadcaster's
// real ingest uplink, so its byte counter and connection state are the
// real ingest uplink, so its byte counter and connection state are the
- // authoritative numbers to show. (Viewer PCs are only P2P-fallback downlinks.)
+ // authoritative numbers to show.
const sfuState = _sfuProduceStates.get(ss.streamData?.id);
const sfuState = _sfuProduceStates.get(ss.streamData?.id);
if (sfuState?.transport) {
if (sfuState?.transport) {
try {
try {
@@ -2426,20 +2399,6 @@ function startGlobalDisplayTimers() {
@@ -2426,20 +2399,6 @@ function startGlobalDisplayTimers() {
} catch {}
} catch {}
}
}
- // Fallback: direct P2P viewer connections. Pick the healthiest one
- // (most bytes sent) rather than blindly sampling the first — the first
- // entry may be a still-negotiating or failed peer.
- if (!hasStats && ss.viewerConnections.size > 0) {
- for (const [, pc] of ss.viewerConnections) {
- try {
- const s = pc.iceConnectionState;
- const bytes = extractStats(await pc.getStats());
- if (bytes > totalBytesSent) { totalBytesSent = bytes; connState = s; }
- } catch {}
- }
- hasStats = totalBytesSent > 0;
- }
-
// Fallback: local stream settings (when no WebRTC stats available yet).
// Fallback: local stream settings (when no WebRTC stats available yet).
// Don't claim "connected" here — report the true transport/signaling state
// Don't claim "connected" here — report the true transport/signaling state
// so the overlay never shows a fake healthy status.
// so the overlay never shows a fake healthy status.
@@ -4023,96 +3982,6 @@ function connectSignaling(streamId) {
@@ -4023,96 +3982,6 @@ function connectSignaling(streamId) {
};
};
}
}
-async function createViewerConnection(streamId, viewerPeerId) {
- const ss = getStreamState(streamId);
- if (!ss) return;
- if (!ss._allowP2pFallback) {
- console.warn(`[Broadcast] Ignoring createViewerConnection(${streamId}, ${viewerPeerId}) — SFU-only mode is active`);
- return;
- }
- if (ss.viewerConnections.has(viewerPeerId)) closeViewerConnection(streamId, viewerPeerId);
- const s = broadcastState.settings;
- const maxBitrate = getTargetVideoBitrate();
- const maxFrameRate = getBroadcastFrameRate();
- const scaleDownBy = getSuggestedScaleDown(s);
- // Use server-provided ICE servers (with TURN support) if available
- const iceServers = (ss._serverIceServers && ss._serverIceServers.length > 0)
- ? ss._serverIceServers
- : [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
- const pc = new RTCPeerConnection({ iceServers });
- ss.viewerConnections.set(viewerPeerId, pc);
- ss.localStream.getTracks().forEach(track => {
- try {
- track.contentHint = track.kind === 'audio' ? 'speech' : (s.screenShare ? 'detail' : 'motion');
- } catch {}
- pc.addTrack(track, ss.localStream);
- });
-
- pc.getTransceivers().forEach(t => {
- if (t.sender?.track?.kind === 'video') {
- try { t.direction = 'sendonly'; } catch {}
- try { t.setCodecPreferences?.((RTCRtpReceiver.getCapabilities('video')?.codecs || []).filter(Boolean)); } catch {}
- }
- });
-
- const codec = s.broadcastCodec || 'auto';
- if (codec !== 'auto' && pc.getTransceivers) {
- const vt = pc.getTransceivers().find(t => t.sender?.track?.kind === 'video');
- if (vt && typeof vt.setCodecPreferences === 'function') {
- try {
- const codecs = RTCRtpReceiver.getCapabilities('video')?.codecs || [];
- const mimeMap = { vp8: 'video/VP8', vp9: 'video/VP9', h264: 'video/H264' };
- const pref = codecs.filter(c => c.mimeType === mimeMap[codec]);
- const rest = codecs.filter(c => c.mimeType !== mimeMap[codec]);
- if (pref.length) vt.setCodecPreferences([...pref, ...rest]);
- } catch {}
- }
- }
-
- pc.getSenders().forEach(sender => {
- if (sender.track?.kind === 'video') {
- const params = sender.getParameters(); if (!params.encodings) params.encodings = [{}];
- params.encodings[0].maxBitrate = maxBitrate;
- params.encodings[0].maxFramerate = maxFrameRate;
- params.encodings[0].scaleResolutionDownBy = scaleDownBy;
- params.encodings[0].priority = 'high';
- const minBps = parseInt(s.broadcastBpsMin); if (minBps > 50) params.encodings[0].minBitrate = minBps * 1000;
- params.degradationPreference = s.screenShare ? 'maintain-resolution' : 'balanced';
- sender.setParameters(params).catch(() => {});
- } else if (sender.track?.kind === 'audio') {
- const params = sender.getParameters();
- if (!params.encodings) params.encodings = [{}];
- params.encodings[0].priority = 'high';
- sender.setParameters(params).catch(() => {});
- }
- });
-
- pc.onicecandidate = (e) => { if (e.candidate) sendBroadcastSignal(ss, { type: 'ice-candidate', candidate: e.candidate, targetPeerId: viewerPeerId }); };
- pc.oniceconnectionstatechange = () => {
- const iceState = pc.iceConnectionState;
- console.log(`[Broadcast] Stream ${streamId} viewer ${viewerPeerId} ICE state: ${iceState}`);
- if (iceState === 'connected' || iceState === 'completed') clearViewerReconnectTimer(ss, viewerPeerId);
- if (iceState === 'failed' || iceState === 'disconnected') {
- scheduleViewerReconnect(streamId, viewerPeerId, iceState === 'failed' ? 1500 : 4000);
- }
- if (iceState === 'closed') {
- closeViewerConnection(streamId, viewerPeerId);
- }
- if (broadcastState.activeStreamId === streamId) updateBroadcastStatusFromConnections(streamId);
- };
-
- const offer = await pc.createOffer(); await pc.setLocalDescription(offer);
- sendBroadcastSignal(ss, { type: 'offer', sdp: pc.localDescription, targetPeerId: viewerPeerId });
-}
-
-function closeViewerConnection(streamId, viewerPeerId) {
- const ss = getStreamState(streamId);
- if (!ss) return;
- clearViewerReconnectTimer(ss, viewerPeerId);
- const pc = ss.viewerConnections.get(viewerPeerId);
- if (pc) { try { pc.close(); } catch {} ss.viewerConnections.delete(viewerPeerId); }
-}
-
function updateBroadcastStatusFromConnections(streamId) {
function updateBroadcastStatusFromConnections(streamId) {
const ss = getStreamState(streamId);
const ss = getStreamState(streamId);
if (!ss) return;
if (!ss) return;
@@ -4127,19 +3996,6 @@ function updateBroadcastStatusFromConnections(streamId) {
@@ -4127,19 +3996,6 @@ function updateBroadcastStatusFromConnections(streamId) {
return;
return;
}
}
- if (ss._allowP2pFallback && ss.viewerConnections.size > 0) {
- let hasConnectedP2p = false;
- for (const [, pc] of ss.viewerConnections) {
- const state = pc.iceConnectionState;
- if (state === 'connected' || state === 'completed') {
- hasConnectedP2p = true;
- break;
- }
- }
- updateBroadcastStatus(hasConnectedP2p ? 'connected' : (signalingOpen ? 'checking' : 'disconnected'));
- return;
- }
-
updateBroadcastStatus(signalingOpen ? 'checking' : 'disconnected');
updateBroadcastStatus(signalingOpen ? 'checking' : 'disconnected');
}
}
@@ -4512,43 +4368,6 @@ async function handleSignalingMessage(streamId, msg) {
@@ -4512,43 +4368,6 @@ async function handleSignalingMessage(streamId, msg) {
const ss = getStreamState(streamId);
const ss = getStreamState(streamId);
if (!ss) return;
if (!ss) return;
switch (msg.type) {
switch (msg.type) {
- case 'viewer-joined':
- if (!ss._allowP2pFallback) {
- console.warn(`[Broadcast] Ignoring legacy viewer-joined for stream ${streamId} — SFU-only mode is active`);
- break;
- }
- if (msg.peerId && ss.localStream) {
- // Skip re-negotiation if the existing peer connection is still healthy
- const existingPc = ss.viewerConnections.get(msg.peerId);
- if (existingPc) {
- const state = existingPc.iceConnectionState;
- if (state === 'connected' || state === 'completed') {
- console.log(`[Broadcast] Stream ${streamId} viewer ${msg.peerId} already connected (ICE: ${state}), skipping re-negotiate`);
- break;
- }
- }
- await createViewerConnection(streamId, msg.peerId);
- }
- break;
- case 'viewer-left':
- if (ss._allowP2pFallback && msg.peerId) closeViewerConnection(streamId, msg.peerId);
- break;
- case 'answer':
- if (ss._allowP2pFallback) {
- const pc = ss.viewerConnections.get(msg.fromPeerId);
- if (pc && msg.sdp) await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
- } else {
- console.warn(`[Broadcast] Ignoring legacy answer for stream ${streamId} — SFU-only mode is active`);
- }
- break;
- case 'ice-candidate':
- if (ss._allowP2pFallback) {
- const pc = ss.viewerConnections.get(msg.fromPeerId);
- if (pc && msg.candidate) await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
- } else {
- console.warn(`[Broadcast] Ignoring legacy ice-candidate for stream ${streamId} — SFU-only mode is active`);
- }
- break;
case 'viewer-count':
case 'viewer-count':
_perStreamViewerCounts.set(streamId, msg.count || 0);
_perStreamViewerCounts.set(streamId, msg.count || 0);
_updateTabViewerCount(streamId, msg.count || 0);
_updateTabViewerCount(streamId, msg.count || 0);
@@ -4560,7 +4379,6 @@ async function handleSignalingMessage(streamId, msg) {
@@ -4560,7 +4379,6 @@ async function handleSignalingMessage(streamId, msg) {
if (msg.iceServers && Array.isArray(msg.iceServers)) {
if (msg.iceServers && Array.isArray(msg.iceServers)) {
ss._serverIceServers = msg.iceServers;
ss._serverIceServers = msg.iceServers;
}
}
- ss._allowP2pFallback = !!msg.allowP2pFallback;
break;
break;
case 'error': toast(msg.message || 'Broadcast error', 'error'); break;
case 'error': toast(msg.message || 'Broadcast error', 'error'); break;
@@ -4989,10 +4807,6 @@ async function _switchActiveCamera(cameraId) {
@@ -4989,10 +4807,6 @@ async function _switchActiveCamera(cameraId) {
const newTrack = newStream.getVideoTracks()[0];
const newTrack = newStream.getVideoTracks()[0];
ss.localStream.addTrack(newTrack);
ss.localStream.addTrack(newTrack);
- for (const [, pc] of ss.viewerConnections) {
- const sender = pc.getSenders().find(s => s.track === null || s.track?.kind === 'video');
- if (sender) sender.replaceTrack(newTrack);
- }
syncRobotStreamerTracks(broadcastState.activeStreamId).catch(() => {});
syncRobotStreamerTracks(broadcastState.activeStreamId).catch(() => {});
_syncSfuProducerTracks(broadcastState.activeStreamId).catch(() => {});
_syncSfuProducerTracks(broadcastState.activeStreamId).catch(() => {});
const preview = document.getElementById('bc-video-preview'); if (preview) preview.srcObject = ss.localStream;
const preview = document.getElementById('bc-video-preview'); if (preview) preview.srcObject = ss.localStream;
@@ -5014,10 +4828,6 @@ async function _switchActiveCamera(cameraId) {
@@ -5014,10 +4828,6 @@ async function _switchActiveCamera(cameraId) {
const recovery = await _getUserMediaWithTimeout({ video: { deviceId: oldDeviceId ? { ideal: oldDeviceId } : undefined, facingMode: { ideal: oldSettings.facingMode || 'user' }, width: { ideal: trackWidth }, height: { ideal: trackHeight }, frameRate: { ideal: trackFrameRate } } });
const recovery = await _getUserMediaWithTimeout({ video: { deviceId: oldDeviceId ? { ideal: oldDeviceId } : undefined, facingMode: { ideal: oldSettings.facingMode || 'user' }, width: { ideal: trackWidth }, height: { ideal: trackHeight }, frameRate: { ideal: trackFrameRate } } });
const recoveryTrack = recovery.getVideoTracks()[0];
const recoveryTrack = recovery.getVideoTracks()[0];
ss.localStream.addTrack(recoveryTrack);
ss.localStream.addTrack(recoveryTrack);
- for (const [, pc] of ss.viewerConnections) {
- const sender = pc.getSenders().find(s => s.track === null || s.track?.kind === 'video');
- if (sender) sender.replaceTrack(recoveryTrack);
- }
syncRobotStreamerTracks(broadcastState.activeStreamId).catch(() => {});
syncRobotStreamerTracks(broadcastState.activeStreamId).catch(() => {});
_syncSfuProducerTracks(broadcastState.activeStreamId).catch(() => {});
_syncSfuProducerTracks(broadcastState.activeStreamId).catch(() => {});
const preview = document.getElementById('bc-video-preview'); if (preview) preview.srcObject = ss.localStream;
const preview = document.getElementById('bc-video-preview'); if (preview) preview.srcObject = ss.localStream;
@@ -5111,20 +4921,10 @@ async function toggleCameraOverlay() {
@@ -5111,20 +4921,10 @@ async function toggleCameraOverlay() {
}
}
}
}
-/** Replace live tracks across all active outputs (legacy P2P only when enabled, RobotStreamer, and SFU producers). */
+/** Replace live tracks across all active outputs (RobotStreamer and SFU producers). */
function _replaceAllViewerTracks(streamId) {
function _replaceAllViewerTracks(streamId) {
const ss = getStreamState(streamId);
const ss = getStreamState(streamId);
if (!ss || !ss.localStream) return;
if (!ss || !ss.localStream) return;
- const nvt = ss.localStream.getVideoTracks()[0];
- const nat = ss.localStream.getAudioTracks()[0];
- if (ss._allowP2pFallback) {
- for (const [, pc] of ss.viewerConnections) {
- const vs = pc.getSenders().find(s => s.track?.kind === 'video' || s.track === null);
- if (vs && nvt) vs.replaceTrack(nvt);
- const as = pc.getSenders().find(s => s.track?.kind === 'audio');
- if (as && nat) as.replaceTrack(nat);
- }
- }
syncRobotStreamerTracks(streamId).catch(() => {});
syncRobotStreamerTracks(streamId).catch(() => {});
// Also update the local SFU mediasoup producers so server-side consumers get the new tracks
// Also update the local SFU mediasoup producers so server-side consumers get the new tracks
_syncSfuProducerTracks(streamId).catch(() => {});
_syncSfuProducerTracks(streamId).catch(() => {});
@@ -5367,14 +5167,6 @@ function _republishAudioTrack(streamId, track) {
@@ -5367,14 +5167,6 @@ function _republishAudioTrack(streamId, track) {
ss.robotStreamer?.audioProducer?.replaceTrack({ track }).catch((e) => console.warn('[RS Restream] audio replaceTrack failed:', e.message));
ss.robotStreamer?.audioProducer?.replaceTrack({ track }).catch((e) => console.warn('[RS Restream] audio replaceTrack failed:', e.message));
} catch { /* */ }
} catch { /* */ }
- // Direct viewer peer connections
- try {
- ss.viewerConnections?.forEach((pc) => {
- const sender = pc.getSenders?.().find(x => x.track && x.track.kind === 'audio');
- sender?.replaceTrack(track).catch(() => {});
- });
- } catch { /* */ }
-
// VOD: MediaRecorder is bound to the tracks it was constructed with, so roll the
// VOD: MediaRecorder is bound to the tracks it was constructed with, so roll the
// segment. uploadVodRecording({finalizeStream:false}) flushes what has been captured
// segment. uploadVodRecording({finalizeStream:false}) flushes what has been captured
// so far without ending the recording server-side; startVodRecording() then opens a
// so far without ending the recording server-side; startVodRecording() then opens a
@@ -253,7 +253,6 @@ function _collectPlayerRuntimeSnapshot() {
@@ -253,7 +253,6 @@ function _collectPlayerRuntimeSnapshot() {
videoSize: video ? `${video.videoWidth || 0}x${video.videoHeight || 0}` : 'n/a',
videoSize: video ? `${video.videoWidth || 0}x${video.videoHeight || 0}` : 'n/a',
signalingWs: player?.ws ? player.ws.readyState : 'n/a',
signalingWs: player?.ws ? player.ws.readyState : 'n/a',
sfuTransport: player?._sfuRecvTransport?.connectionState || 'n/a',
sfuTransport: player?._sfuRecvTransport?.connectionState || 'n/a',
- legacyPc: player?.pc?.iceConnectionState || 'n/a',
trackSummary: tracks.length
trackSummary: tracks.length
? tracks.map((track) => `${track.kind}:${track.readyState}:muted=${track.muted}:enabled=${track.enabled}`).join(', ')
? tracks.map((track) => `${track.kind}:${track.readyState}:muted=${track.muted}:enabled=${track.enabled}`).join(', ')
: 'none',
: 'none',
@@ -283,7 +282,6 @@ function _buildPlayerDiagnosticsText() {
@@ -283,7 +282,6 @@ function _buildPlayerDiagnosticsText() {
`video_size=${snapshot.videoSize}`,
`video_size=${snapshot.videoSize}`,
`signaling_ws=${snapshot.signalingWs}`,
`signaling_ws=${snapshot.signalingWs}`,
`sfu_transport=${snapshot.sfuTransport}`,
`sfu_transport=${snapshot.sfuTransport}`,
- `legacy_pc=${snapshot.legacyPc}`,
`tracks=${snapshot.trackSummary}`,
`tracks=${snapshot.trackSummary}`,
`network_hint=${snapshot.networkHint}`,
`network_hint=${snapshot.networkHint}`,
`user_agent=${navigator.userAgent}`,
`user_agent=${navigator.userAgent}`,
@@ -1115,7 +1113,7 @@ function startJSMPEG(wsUrl, canvas, placeholder, bufferProfile = getJsmpegBuffer
@@ -1115,7 +1113,7 @@ function startJSMPEG(wsUrl, canvas, placeholder, bufferProfile = getJsmpegBuffer
async function initWebRTC(stream) {
async function initWebRTC(stream) {
const video = document.getElementById('video-element');
const video = document.getElementById('video-element');
const placeholder = document.querySelector('.video-placeholder');
const placeholder = document.querySelector('.video-placeholder');
- streamRef = stream; // set module-level ref for clip recording in handleViewerOffer
+ streamRef = stream; // set module-level ref for clip recording
try {
try {
video.playsInline = true;
video.playsInline = true;
@@ -1152,7 +1150,7 @@ async function initWebRTC(stream) {
@@ -1152,7 +1150,7 @@ async function initWebRTC(stream) {
const wsUrl = `${protocol}://${host}${portSuffix}/ws/broadcast?streamId=${streamRef.id}&role=viewer&token=${token}`;
const wsUrl = `${protocol}://${host}${portSuffix}/ws/broadcast?streamId=${streamRef.id}&role=viewer&token=${token}`;
const ws = new WebSocket(wsUrl);
const ws = new WebSocket(wsUrl);
- player = { ws, video, pc: null, myPeerId: null, watchSent: false, _wsUrl: wsUrl, _serverIceServers: null };
+ player = { ws, video, myPeerId: null, watchSent: false, _wsUrl: wsUrl, _serverIceServers: null };
const _myGen = _playerGen; // this session's generation; stale once destroyPlayer bumps it
const _myGen = _playerGen; // this session's generation; stale once destroyPlayer bumps it
let _broadcasterDisconnectTimer = null;
let _broadcasterDisconnectTimer = null;
let _viewerReconnectTimer = null;
let _viewerReconnectTimer = null;
@@ -1212,9 +1210,6 @@ async function initWebRTC(stream) {
@@ -1212,9 +1210,6 @@ async function initWebRTC(stream) {
}, delay);
}, delay);
};
};
- // Expose startWatchOfferTimeout on player so handleViewerOffer's triggerRewatch can use it
- player._startWatchOfferTimeout = startWatchOfferTimeout;
-
ws.onopen = () => {
ws.onopen = () => {
console.log('[Player] Broadcast signaling connected');
console.log('[Player] Broadcast signaling connected');
_updateStatus('Looking for the live feed', {
_updateStatus('Looking for the live feed', {
@@ -1224,8 +1219,8 @@ async function initWebRTC(stream) {
@@ -1224,8 +1219,8 @@ async function initWebRTC(stream) {
showDiagnostics: true,
showDiagnostics: true,
});
});
_viewerReconnectDelay = 3000; // reset backoff on successful connect
_viewerReconnectDelay = 3000; // reset backoff on successful connect
- const pcState = player?.pc?.iceConnectionState;
- if (!player.watchSent || pcState === 'failed' || pcState === 'disconnected' || pcState === 'closed') {
+ const sfuState = player?._sfuRecvTransport?.connectionState;
+ if (!player.watchSent || sfuState === 'failed' || sfuState === 'disconnected' || sfuState === 'closed') {
scheduleViewerRewatch(250);
scheduleViewerRewatch(250);
}
}
};
};
@@ -1240,7 +1235,6 @@ async function initWebRTC(stream) {
@@ -1240,7 +1235,6 @@ async function initWebRTC(stream) {
if (msg.iceServers && Array.isArray(msg.iceServers)) {
if (msg.iceServers && Array.isArray(msg.iceServers)) {
player._serverIceServers = msg.iceServers;
player._serverIceServers = msg.iceServers;
}
}
- player._allowP2pFallback = !!msg.allowP2pFallback;
console.log('[Player] Welcome, peerId:', msg.peerId, 'iceServers:', (player._serverIceServers || []).length);
console.log('[Player] Welcome, peerId:', msg.peerId, 'iceServers:', (player._serverIceServers || []).length);
_updateStatus('Waiting for the live feed', {
_updateStatus('Waiting for the live feed', {
phase: 'queue',
phase: 'queue',
@@ -1270,13 +1264,6 @@ async function initWebRTC(stream) {
@@ -1270,13 +1264,6 @@ async function initWebRTC(stream) {
// Hide reconnecting indicator if shown
// Hide reconnecting indicator if shown
_hideReconnectingIndicator();
_hideReconnectingIndicator();
// Skip re-negotiation if the existing media path is still healthy.
// Skip re-negotiation if the existing media path is still healthy.
- if (player.pc) {
- const state = player.pc.iceConnectionState;
- if (state === 'connected' || state === 'completed') {
- console.log(`[Player] Peer connection still healthy (ICE: ${state}), skipping re-negotiate`);
- break;
- }
- }
if (player._sfuRecvTransport) {
if (player._sfuRecvTransport) {
const state = player._sfuRecvTransport.connectionState;
const state = player._sfuRecvTransport.connectionState;
if (state === 'connected') {
if (state === 'connected') {
@@ -1290,28 +1277,6 @@ async function initWebRTC(stream) {
@@ -1290,28 +1277,6 @@ async function initWebRTC(stream) {
player.watchSent = true;
player.watchSent = true;
startWatchOfferTimeout();
startWatchOfferTimeout();
break;
break;
- case 'offer':
- if (!player._allowP2pFallback) {
- console.warn('[Player] Ignoring legacy P2P offer — SFU-only mode is active');
- _pushPlayerDiagnostic('p2p.offer.ignored', 'Received unexpected offer while SFU-only mode was active');
- break;
- }
- // Broadcaster sent us an offer (legacy P2P rollback path) — create answer
- console.log('[Player] Received offer from broadcaster');
- _updateStatus('Switching to the fallback path', {
- phase: 'transport',
- detail: 'Emergency P2P fallback is enabled for this session. Negotiating direct media.',
- hint: 'This path should normally stay disabled. Copy the log if you were not expecting it.',
- showDiagnostics: true,
- });
- // Clear the watch-to-offer timeout — offer received successfully
- if (_watchOfferTimer) { clearTimeout(_watchOfferTimer); _watchOfferTimer = null; }
- _rewatchCount = 0; // reset retry count on successful offer
- _hideReconnectingIndicator();
- // Clear any stale error overlay — we got a valid offer
- _clearStreamError();
- await handleViewerOffer(msg, player.ws, video);
- break;
case 'sfu-viewer-ready':
case 'sfu-viewer-ready':
// Server has SFU producers — use mediasoup-client RecvTransport
// Server has SFU producers — use mediasoup-client RecvTransport
console.log('[Player] SFU viewer ready — starting mediasoup-client flow');
console.log('[Player] SFU viewer ready — starting mediasoup-client flow');
@@ -1337,16 +1302,6 @@ async function initWebRTC(stream) {
@@ -1337,16 +1302,6 @@ async function initWebRTC(stream) {
_sfuViewerSetupInProgress = false;
_sfuViewerSetupInProgress = false;
}
}
break;
break;
- case 'ice-candidate':
- if (!player._allowP2pFallback) {
- console.warn('[Player] Ignoring legacy P2P ICE candidate — SFU-only mode is active');
- break;
- }
- // ICE candidate from broadcaster
- if (player.pc && msg.candidate) {
- await player.pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
- }
- break;
case 'broadcaster-disconnected':
case 'broadcaster-disconnected':
console.log('[Player] Broadcaster signaling disconnected — media may still be active');
console.log('[Player] Broadcaster signaling disconnected — media may still be active');
_pushPlayerDiagnostic('signal.disconnect', 'Broadcaster signaling disconnected while viewer session remained open');
_pushPlayerDiagnostic('signal.disconnect', 'Broadcaster signaling disconnected while viewer session remained open');
@@ -1358,14 +1313,14 @@ async function initWebRTC(stream) {
@@ -1358,14 +1313,14 @@ async function initWebRTC(stream) {
if (_broadcasterDisconnectTimer) clearTimeout(_broadcasterDisconnectTimer);
if (_broadcasterDisconnectTimer) clearTimeout(_broadcasterDisconnectTimer);
_broadcasterDisconnectTimer = setTimeout(() => {
_broadcasterDisconnectTimer = setTimeout(() => {
_broadcasterDisconnectTimer = null;
_broadcasterDisconnectTimer = null;
- // Check if PeerConnection is still delivering media
- const pcState = player?.pc?.iceConnectionState;
- if (pcState === 'connected' || pcState === 'completed') {
- // PC still working — don't show "ended", just log
- console.log('[Player] Broadcaster signaling gone but PC still connected, waiting...');
+ // Check if the SFU receive transport is still delivering media
+ const sfuState = player?._sfuRecvTransport?.connectionState;
+ if (sfuState === 'connected') {
+ // Transport still working — don't show "ended", just log
+ console.log('[Player] Broadcaster signaling gone but SFU transport still connected, waiting...');
return;
return;
}
}
- console.log('[Player] Broadcaster did not reconnect and PC is dead, stream ended');
+ console.log('[Player] Broadcaster did not reconnect and SFU transport is dead, stream ended');
_hideReconnectingIndicator();
_hideReconnectingIndicator();
showStreamEnded();
showStreamEnded();
}, 60000);
}, 60000);
@@ -1402,7 +1357,7 @@ async function initWebRTC(stream) {
@@ -1402,7 +1357,7 @@ async function initWebRTC(stream) {
_showReconnectingIndicator();
_showReconnectingIndicator();
// Clear the SFU frozen checker — a new one will start after recovery
// Clear the SFU frozen checker — a new one will start after recovery
if (player._sfuFrozenInterval) { clearInterval(player._sfuFrozenInterval); player._sfuFrozenInterval = null; }
if (player._sfuFrozenInterval) { clearInterval(player._sfuFrozenInterval); player._sfuFrozenInterval = null; }
- // Gentle poll: send watch every 10 s WITHOUT starting the P2P offer timeout.
+ // Gentle poll: send watch every 10 s WITHOUT starting the watch-response timeout.
// The server will respond with sfu-viewer-ready, sfu-source-unavailable, or watch-queued.
// The server will respond with sfu-viewer-ready, sfu-source-unavailable, or watch-queued.
_viewerRewatchTimer = setTimeout(() => {
_viewerRewatchTimer = setTimeout(() => {
_viewerRewatchTimer = null;
_viewerRewatchTimer = null;
@@ -1490,308 +1445,9 @@ async function initWebRTC(stream) {
@@ -1490,308 +1445,9 @@ async function initWebRTC(stream) {
}
}
}
}
-async function handleViewerOffer(msg, ws, video) {
- // Safety: player must be set by initWebRTC before we get here
- if (!player) {
- console.warn('[Player] handleViewerOffer called but player is null');
- return;
- }
- // Close existing PC if re-negotiating — detach handlers first to prevent
- // the old PC's 'closed' state from triggering a cascading re-watch loop
- if (player.pc) {
- const oldPc = player.pc;
- oldPc.oniceconnectionstatechange = null;
- oldPc.ontrack = null;
- oldPc.onicecandidate = null;
- try { oldPc.close(); } catch (ignored) {}
- }
- // Clear any pending stall/ICE timers from the previous connection
- if (player._iceTimeout) { clearTimeout(player._iceTimeout); player._iceTimeout = null; }
- if (player._stallTimer) { clearTimeout(player._stallTimer); player._stallTimer = null; }
- if (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }
-
- // Use server-provided ICE servers (with TURN support) if available, else fallback to STUN-only
- const iceServers = (player._serverIceServers && player._serverIceServers.length > 0)
- ? player._serverIceServers
- : [
- { urls: 'stun:stun.l.google.com:19302' },
- { urls: 'stun:stun1.l.google.com:19302' },
- { urls: 'stun:stun2.l.google.com:19302' },
- { urls: 'stun:stun3.l.google.com:19302' },
- { urls: 'stun:stun4.l.google.com:19302' },
- ];
-
- const pc = new RTCPeerConnection({ iceServers });
- player.pc = pc;
- let _iceConnected = false;
- let _hasVideoFrames = false;
- let _playPending = false; // debounce play() across multiple ontrack events
-
- // Schedules a re-watch if the current PC is still ours
- const triggerRewatch = (reason) => {
- if (!player || player.pc !== pc) return;
- // Track ICE failures separately — these never reset (prevents infinite loop)
- if (reason.includes('ICE') || reason.includes('stall') || reason.includes('SDP')) {
- _totalIceFailures++;
- console.log(`[Player] Re-watching: ${reason} (ICE failures: ${_totalIceFailures}/${MAX_ICE_FAILURES})`);
- if (_totalIceFailures >= MAX_ICE_FAILURES) {
- console.error('[Player] Too many ICE/connection failures — giving up');
- showStreamError('Could not establish media connection. Your network may be blocking WebRTC traffic. Try a different network or refresh the page.');
- return;
- }
- } else {
- console.log(`[Player] Re-watching: ${reason}`);
- }
- player.watchSent = false;
- sendPlayerSignal({ type: 'watch' });
- player.watchSent = true;
- // Start watch-to-offer timeout for this re-watch too
- if (player._startWatchOfferTimeout) player._startWatchOfferTimeout();
- };
-
- // Debounced play() — always starts muted (guaranteed autoplay), then unmutes if allowed.
- // Previous approach tried unmuted first, hit NotAllowedError, then retried muted — but the
- // muted retry could also fail with AbortError if the first play() was still settling,
- // leading to "Playback failed even muted" and no frames ever rendering.
- const tryPlay = () => {
- if (_playPending || !player || player.pc !== pc) return;
- // Viewer deliberately paused — don't force playback on a reconnect.
- if (viewerWantsPaused()) { try { video.pause(); } catch {} return; }
- _playPending = true;
- if (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }
-
- // Always start muted — this is the only way to guarantee autoplay across all browsers.
- // We unmute after playback starts if the user hasn't muted.
- video.muted = true;
- video.volume = 1;
-
- video.play().then(() => {
- _playPending = false;
- console.log('[Player] Playback started (muted autoplay)');
-
- // Now try to unmute based on user prefs
- const audioPrefs = getSavedPlayerAudioState();
- if (!audioPrefs.muted) {
- video.volume = Math.max(0.01, audioPrefs.volume);
- video.muted = false;
- // If unmuting fails (autoplay policy), show overlay
- // Some browsers will pause on unmute — re-play muted if needed
- const playPromise = video.play();
- if (playPromise) {
- playPromise.catch(() => {
- video.muted = true;
- video.play().catch(() => {});
- showUnmuteOverlay(video);
- });
- }
- } else {
- video.muted = true;
- video.volume = 0;
- // Already muted — remove stale overlay if present
- document.getElementById('unmute-overlay')?.remove();
- }
- }).catch((err) => {
- _playPending = false;
- if (err.name === 'AbortError') {
- // play() interrupted — retry after a tick (second ontrack can cause this)
- console.log('[Player] play() interrupted, retrying in 300ms');
- player._playRetryTimer = setTimeout(() => {
- player._playRetryTimer = null;
- _playPending = false;
- tryPlay();
- }, 300);
- } else {
- console.warn('[Player] Muted play() failed:', err.name, err.message);
- // Retry once after short delay — video element may not have enough data yet
- player._playRetryTimer = setTimeout(() => {
- player._playRetryTimer = null;
- _playPending = false;
- tryPlay();
- }, 1000);
- }
- });
- };
-
- let _trackCount = 0; // count received tracks — only tryPlay after both video+audio arrive
-
- pc.ontrack = (e) => {
- _trackCount++;
- console.log('[Player] Got remote track:', e.track.kind, `(${_trackCount} total)`);
- if (e.streams && e.streams[0]) {
- video.srcObject = e.streams[0];
- startClipRecordingIfNeeded(e.streams[0], streamRef?.id);
- } else {
- let mediaStream = video.srcObject;
- if (!mediaStream) {
- mediaStream = new MediaStream();
- video.srcObject = mediaStream;
- }
- mediaStream.addTrack(e.track);
- startClipRecordingIfNeeded(mediaStream, streamRef?.id);
- }
-
- // Monitor remote track health — if it ends or mutes, trigger re-watch
- const track = e.track;
- track.addEventListener('ended', () => {
- if (player?.pc !== pc) return;
- console.warn(`[Player] Remote ${track.kind} track ended`);
- triggerRewatch(`remote ${track.kind} track ended`);
- }, { once: true });
- track.addEventListener('mute', () => {
- if (player?.pc !== pc) return;
- console.warn(`[Player] Remote ${track.kind} track muted`);
- // Give muted tracks a grace period — they may unmute on their own (e.g. track replacement)
- setTimeout(() => {
- if (player?.pc !== pc || !track.muted) return;
- triggerRewatch(`remote ${track.kind} track stayed muted`);
- }, 5000);
- }, { once: true });
-
- // Show the video element now that we have tracks
- video.style.display = 'block';
-
- // Register the playing event listener for EACH new PC (fresh _hasVideoFrames each time)
- if (_trackCount === 1) {
- const onPlaying = () => {
- video.removeEventListener('playing', onPlaying);
- _hasVideoFrames = true;
- if (player._stallTimer) { clearTimeout(player._stallTimer); player._stallTimer = null; }
- _hidePlayerPlaceholder();
- // Only remove unmute overlay if video is actually playing with audio
- if (!video.muted) {
- document.getElementById('unmute-overlay')?.remove();
- }
- };
- video.addEventListener('playing', onPlaying);
- }
-
- // Video stall detection — if no frames render within 8s of getting tracks, re-watch
- if (!player._stallTimer && !_hasVideoFrames) {
- player._stallTimer = setTimeout(() => {
- player._stallTimer = null;
- if (player?.pc !== pc || _hasVideoFrames) return;
- // Check if video element is actually rendering
- if (video.videoWidth === 0 || video.paused || video.readyState < 2) {
- console.warn('[Player] Video stall detected — no frames after 8s');
- triggerRewatch('video stall — no frames rendered');
- }
- }, 8000);
- }
-
- // Delay tryPlay until we have at least 2 tracks (video + audio) or 500ms
- // after first track (handles audio-only/video-only streams).
- // This prevents the first tryPlay() from racing with the second ontrack
- // which causes AbortError on the first play() call.
- if (_trackCount >= 2) {
- tryPlay();
- } else if (_trackCount === 1) {
- player._playRetryTimer = setTimeout(() => {
- player._playRetryTimer = null;
- _playPending = false;
- tryPlay();
- }, 500);
- }
- };
-
- pc.onicecandidate = (e) => {
- // Use player.ws (not the passed ws param) so this works after WS reconnection
- if (e.candidate) {
- sendPlayerSignal({
- type: 'ice-candidate',
- candidate: e.candidate,
- });
- }
- };
-
- pc.oniceconnectionstatechange = () => {
- // Ignore state changes from a stale (replaced) PC
- if (!player || player.pc !== pc) return;
- console.log('[Player] ICE state:', pc.iceConnectionState);
- if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
- _iceConnected = true;
- if (player._iceTimeout) { clearTimeout(player._iceTimeout); player._iceTimeout = null; }
- // If the stall timer is already running and no frames yet, restart it from ICE-connected
- // time — ICE negotiation itself can take several seconds, so the 8s clock should start
- // from when media can actually flow, not from when ontrack fired.
- if (player._stallTimer && !_hasVideoFrames) {
- clearTimeout(player._stallTimer);
- player._stallTimer = setTimeout(() => {
- player._stallTimer = null;
- if (player?.pc !== pc || _hasVideoFrames) return;
- if (video.videoWidth === 0 || video.paused || video.readyState < 2) {
- console.warn('[Player] Video stall detected — no frames after ICE connected + 8s');
- triggerRewatch('video stall — no frames after ICE connect');
- }
- }, 8000);
- }
- return;
- }
- if (pc.iceConnectionState === 'failed') {
- if (player._iceTimeout) { clearTimeout(player._iceTimeout); player._iceTimeout = null; }
- triggerRewatch('ICE failed');
- return;
- }
- if (pc.iceConnectionState === 'disconnected') {
- setTimeout(() => {
- if (!player?.pc || player.pc !== pc) return;
- const state = pc.iceConnectionState;
- if (state === 'disconnected' || state === 'failed') {
- triggerRewatch('ICE disconnected/failed after grace period');
- }
- }, 2500);
- }
- // 'disconnected' is transient and often recovers on its own — don't show error
- // 'closed' is handled by detaching handlers before close — no cascading re-watch
- };
-
- // Wrap SDP operations in try/catch — retry on failure instead of silent black screen
- try {
- await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
- const answer = await pc.createAnswer();
- await pc.setLocalDescription(answer);
-
- // Use player.ws (not the passed ws param) so this works after WS reconnection
- sendPlayerSignal({
- type: 'answer',
- sdp: answer,
- });
- console.log('[Player] Sent answer to broadcaster');
- } catch (sdpErr) {
- console.error('[Player] SDP negotiation failed:', sdpErr);
- // Close the failed PC and retry after a short delay
- pc.oniceconnectionstatechange = null;
- pc.ontrack = null;
- try { pc.close(); } catch {}
- if (player.pc === pc) player.pc = null;
- setTimeout(() => triggerRewatch('SDP negotiation failed'), 2000);
- return;
- }
-
- // ICE connection timeout — if not connected within 10s, something is stuck
- player._iceTimeout = setTimeout(() => {
- player._iceTimeout = null;
- if (!player || player.pc !== pc || _iceConnected) return;
- const state = pc.iceConnectionState;
- if (state !== 'connected' && state !== 'completed') {
- console.warn(`[Player] ICE timeout after 10s (state: ${state})`);
- triggerRewatch('ICE connection timeout');
- }
- }, 10000);
-}
-
-/* ── SFU Viewer via mediasoup-client (replaces hand-built SDP) ── */
async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatch) {
async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatch) {
if (!player) return;
if (!player) return;
- // Clean up old PC if any (from P2P fallback or previous SFU attempt)
- if (player.pc) {
- const oldPc = player.pc;
- oldPc.oniceconnectionstatechange = null;
- oldPc.ontrack = null;
- oldPc.onicecandidate = null;
- try { oldPc.close(); } catch {}
- player.pc = null;
- }
if (player._iceTimeout) { clearTimeout(player._iceTimeout); player._iceTimeout = null; }
if (player._iceTimeout) { clearTimeout(player._iceTimeout); player._iceTimeout = null; }
if (player._stallTimer) { clearTimeout(player._stallTimer); player._stallTimer = null; }
if (player._stallTimer) { clearTimeout(player._stallTimer); player._stallTimer = null; }
if (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }
if (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }
@@ -2989,7 +2645,7 @@ function setupVideoControls() {
@@ -2989,7 +2645,7 @@ function setupVideoControls() {
const audioPrefs = getSavedPlayerAudioState();
const audioPrefs = getSavedPlayerAudioState();
const savedVol = Math.round(audioPrefs.volume * 100);
const savedVol = Math.round(audioPrefs.volume * 100);
volSlider.value = savedVol;
volSlider.value = savedVol;
- // For WebRTC, tryPlay() in handleViewerOffer sets volume + handles autoplay policy.
+ // For WebRTC (SFU), the player start path sets volume + handles autoplay policy.
// For JSMPEG, onSourceEstablished handles it. For HLS, set it here.
// For JSMPEG, onSourceEstablished handles it. For HLS, set it here.
if (playerType === 'hls') {
if (playerType === 'hls') {
setVolume(savedVol / 100, { muted: audioPrefs.muted });
setVolume(savedVol / 100, { muted: audioPrefs.muted });
@@ -3757,7 +3413,6 @@ function destroyPlayer() {
@@ -3757,7 +3413,6 @@ function destroyPlayer() {
if (player) {
if (player) {
if (playerType === 'jsmpeg' && player.destroy) player.destroy();
if (playerType === 'jsmpeg' && player.destroy) player.destroy();
if (playerType === 'webrtc') {
if (playerType === 'webrtc') {
- if (player.pc) player.pc.close();
if (player.ws) player.ws.close();
if (player.ws) player.ws.close();
}
}
if (playerType === 'hls') {
if (playerType === 'hls') {
@@ -3921,9 +3576,8 @@ function toggleStreamStats(force) {
@@ -3921,9 +3576,8 @@ function toggleStreamStats(force) {
}
}
function _getStatsSource() {
function _getStatsSource() {
- // Both RTCPeerConnection (legacy) and the mediasoup recv Transport expose getStats()
+ // The mediasoup recv Transport exposes getStats()
if (typeof player !== 'undefined' && player) {
if (typeof player !== 'undefined' && player) {
- if (player.pc && typeof player.pc.getStats === 'function') return player.pc;
if (player._sfuRecvTransport && typeof player._sfuRecvTransport.getStats === 'function') return player._sfuRecvTransport;
if (player._sfuRecvTransport && typeof player._sfuRecvTransport.getStats === 'function') return player._sfuRecvTransport;
}
}
return null;
return null;
@@ -86,14 +86,10 @@ function buildConfig(registryValues) {
@@ -86,14 +86,10 @@ function buildConfig(registryValues) {
const rtmpEntry = getRegistryEntry('RTMP_HOST');
const rtmpEntry = getRegistryEntry('RTMP_HOST');
const turnEntry = getRegistryEntry('TURN_URL');
const turnEntry = getRegistryEntry('TURN_URL');
const turnUrl = normalizeTurnUrl(turnEntry.value || process.env.TURN_URL || DEFAULTS.TURN_URL);
const turnUrl = normalizeTurnUrl(turnEntry.value || process.env.TURN_URL || DEFAULTS.TURN_URL);
- const allowP2pFallback = normalizeValue(process.env.ALLOW_P2P_FALLBACK, 'boolean') === true;
if (turnEntry.source !== 'default' && !turnUrl) {
if (turnEntry.source !== 'default' && !turnUrl) {
console.warn('[Config] Invalid TURN_URL configured; ICE metadata will be skipped. Expected a turn: or turns: URL with hostname and optional port/path.');
console.warn('[Config] Invalid TURN_URL configured; ICE metadata will be skipped. Expected a turn: or turns: URL with hostname and optional port/path.');
}
}
- if (allowP2pFallback) {
- console.warn('[Config] ALLOW_P2P_FALLBACK is enabled. Legacy peer-to-peer viewer relays may expose client IP addresses and should be used only for emergency rollback.');
- }
const baseUrl = baseEntry.source !== 'default' ? baseEntry.value : DEFAULTS.BASE_URL;
const baseUrl = baseEntry.source !== 'default' ? baseEntry.value : DEFAULTS.BASE_URL;
// Derive the public URL for openvibe.network (the SSO/auth provider).
// Derive the public URL for openvibe.network (the SSO/auth provider).
@@ -185,7 +181,6 @@ function buildConfig(registryValues) {
@@ -185,7 +181,6 @@ function buildConfig(registryValues) {
username: process.env.TURN_USERNAME || '',
username: process.env.TURN_USERNAME || '',
credential: process.env.TURN_CREDENTIAL || '',
credential: process.env.TURN_CREDENTIAL || '',
},
},
- allowP2pFallback,
mediasoup: {
mediasoup: {
listenIp: process.env.MEDIASOUP_LISTEN_IP || '0.0.0.0',
listenIp: process.env.MEDIASOUP_LISTEN_IP || '0.0.0.0',
announcedIp: mediasoupAnnouncedIp,
announcedIp: mediasoupAnnouncedIp,
@@ -7,9 +7,9 @@
@@ -7,9 +7,9 @@
* 1. Broadcaster connects with JWT token + streamId
* 1. Broadcaster connects with JWT token + streamId
* 2. Broadcaster auto-publishes audio/video into the Mediasoup SFU
* 2. Broadcaster auto-publishes audio/video into the Mediasoup SFU
* 3. Viewer connects, sends 'watch', and is either queued or attached to the SFU
* 3. Viewer connects, sends 'watch', and is either queued or attached to the SFU
- * 4. Legacy P2P relay messages are ignored unless ALLOW_P2P_FALLBACK is enabled
*
*
- * Each stream has ONE broadcaster and MANY viewers.
+ * Each stream has ONE broadcaster and MANY viewers. The SFU (mediasoup) is the
+ * only media path — there is no direct peer-to-peer viewer relay.
* The server primarily orchestrates SFU transports and viewer queueing.
* The server primarily orchestrates SFU transports and viewer queueing.
*/
*/
const { EventEmitter } = require('events');
const { EventEmitter } = require('events');
@@ -101,7 +101,6 @@ class BroadcastServer extends EventEmitter {
@@ -101,7 +101,6 @@ class BroadcastServer extends EventEmitter {
type: 'watch-queued',
type: 'watch-queued',
reason,
reason,
detail,
detail,
- allowP2pFallback: !!config.allowP2pFallback,
});
});
this._logMetric('viewer.queued', {
this._logMetric('viewer.queued', {
streamId: client.streamId,
streamId: client.streamId,
@@ -327,7 +326,6 @@ class BroadcastServer extends EventEmitter {
@@ -327,7 +326,6 @@ class BroadcastServer extends EventEmitter {
streamId,
streamId,
viewerCount: room.viewers.size,
viewerCount: room.viewers.size,
iceServers: this._getIceServers(),
iceServers: this._getIceServers(),
- allowP2pFallback: !!config.allowP2pFallback,
});
});
// Broadcast viewer count
// Broadcast viewer count
@@ -361,24 +359,6 @@ class BroadcastServer extends EventEmitter {
@@ -361,24 +359,6 @@ class BroadcastServer extends EventEmitter {
if (!room) return;
if (!room) return;
switch (msg.type) {
switch (msg.type) {
- case 'offer':
- case 'answer':
- case 'ice-candidate':
- if (!config.allowP2pFallback) {
- this._logMetric('p2p.relay.attempt', {
- streamId: client.streamId,
- peerId: client.peerId,
- type: msg.type,
- outcome: 'ignored',
- });
- console.warn(`[Broadcast] Ignoring legacy ${msg.type} for stream ${client.streamId} — SFU-only mode is active`);
- break;
- }
- // For SFU viewers, ignore direct P2P signaling — mediasoup handles everything.
- if (client._sfuViewerTransportId) break;
- this.relaySignaling(ws, client, room, msg);
- break;
-
case 'watch':
case 'watch':
// Viewer requests to watch
// Viewer requests to watch
if (client.role === 'viewer') {
if (client.role === 'viewer') {
@@ -407,20 +387,6 @@ class BroadcastServer extends EventEmitter {
@@ -407,20 +387,6 @@ class BroadcastServer extends EventEmitter {
}
}
}).catch(err => {
}).catch(err => {
console.error(`[Broadcast] SFU viewer error for ${client.peerId}:`, err.message);
console.error(`[Broadcast] SFU viewer error for ${client.peerId}:`, err.message);
- if (config.allowP2pFallback && room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
- this._logMetric('p2p.relay.attempt', {
- streamId: client.streamId,
- peerId: client.peerId,
- type: 'viewer-joined',
- outcome: 'fallback',
- });
- this.safeSend(room.broadcaster, {
- type: 'viewer-joined',
- peerId: client.peerId,
- });
- return;
- }
-
if (room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
if (room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
this._requestSfuProduceWarmup(room, client.streamId);
this._requestSfuProduceWarmup(room, client.streamId);
}
}
@@ -525,46 +491,6 @@ class BroadcastServer extends EventEmitter {
@@ -525,46 +491,6 @@ class BroadcastServer extends EventEmitter {
}
}
}
}
- relaySignaling(ws, client, room, msg) {
- this._logMetric('p2p.relay.attempt', {
- streamId: client.streamId,
- peerId: client.peerId,
- type: msg.type,
- outcome: 'relayed',
- });
- if (client.role === 'broadcaster') {
- // Broadcaster sending to a specific viewer
- const targetPeerId = msg.targetPeerId;
- if (targetPeerId && room.viewers.has(targetPeerId)) {
- const viewerWs = room.viewers.get(targetPeerId);
- if (viewerWs.readyState === WebSocket.OPEN) {
- this.safeSend(viewerWs, {
- type: msg.type,
- sdp: msg.sdp,
- candidate: msg.candidate,
- fromPeerId: 'broadcaster',
- });
- } else {
- console.warn(`[Broadcast] Cannot relay ${msg.type} to ${targetPeerId} — viewer WS not open (state: ${viewerWs.readyState})`);
- }
- } else if (targetPeerId) {
- console.warn(`[Broadcast] Cannot relay ${msg.type} — viewer ${targetPeerId} not found in room (stream ${client.streamId})`);
- }
- } else {
- // Viewer sending to broadcaster
- if (room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
- this.safeSend(room.broadcaster, {
- type: msg.type,
- sdp: msg.sdp,
- candidate: msg.candidate,
- fromPeerId: client.peerId,
- });
- } else {
- console.warn(`[Broadcast] Cannot relay ${msg.type} from viewer ${client.peerId} — broadcaster not connected (stream ${client.streamId})`);
- }
- }
- }
-
handleDisconnect(ws) {
handleDisconnect(ws) {
const client = this.clients.get(ws);
const client = this.clients.get(ws);
if (!client) return;
if (!client) return;
@@ -617,14 +543,6 @@ class BroadcastServer extends EventEmitter {
@@ -617,14 +543,6 @@ class BroadcastServer extends EventEmitter {
this._cleanupSfuViewerTransport(client);
this._cleanupSfuViewerTransport(client);
console.log(`[Broadcast] Viewer disconnected: stream ${client.streamId} (${client.peerId})`);
console.log(`[Broadcast] Viewer disconnected: stream ${client.streamId} (${client.peerId})`);
-
- // Notify broadcaster only when legacy P2P fallback is explicitly enabled.
- if (config.allowP2pFallback && room.broadcaster && room.broadcaster.readyState === WebSocket.OPEN) {
- this.safeSend(room.broadcaster, {
- type: 'viewer-left',
- peerId: client.peerId,
- });
- }
}
}
this.broadcastViewerCount(client.streamId);
this.broadcastViewerCount(client.streamId);
@@ -885,9 +803,9 @@ class BroadcastServer extends EventEmitter {
@@ -885,9 +803,9 @@ class BroadcastServer extends EventEmitter {
// True stale: ICE never connected or has definitively failed.
// True stale: ICE never connected or has definitively failed.
console.log(`[Broadcast] All ${allProducers.length} producer(s) are stale for stream ${client.streamId} — sending source-unavailable to ${client.peerId}`);
console.log(`[Broadcast] All ${allProducers.length} producer(s) are stale for stream ${client.streamId} — sending source-unavailable to ${client.peerId}`);
this.safeSend(ws, { type: 'sfu-source-unavailable', reason: 'ingest_stale' });
this.safeSend(ws, { type: 'sfu-source-unavailable', reason: 'ingest_stale' });
- return true; // handled — do not fall through to P2P offer path
+ return true; // handled — source-unavailable already sent
}
}
- console.log(`[Broadcast] No live producers for stream ${client.streamId} (${allProducers.length} total, all dead/disconnected) — falling back to P2P`);
+ console.log(`[Broadcast] No live SFU producers for stream ${client.streamId} (${allProducers.length} total, all dead/disconnected) — viewer will be queued`);
return false;
return false;
}
}
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
*
*
* Every consumer of the broadcast audio binds to a track ONCE and keeps its own
* Every consumer of the broadcast audio binds to a track ONCE and keeps its own
* reference — MediaRecorder snapshots its tracks at construction, mediasoup Producers
* reference — MediaRecorder snapshots its tracks at construction, mediasoup Producers
- * wrap a specific track, viewer RTCPeerConnection senders hold theirs. So the old
+ * wrap a specific track, the RobotStreamer relay holds its own. So the old
* removeTrack/addTrack dance on ss.localStream changed nothing for anyone: toggling the
* removeTrack/addTrack dance on ss.localStream changed nothing for anyone: toggling the
* mic mid-stream left viewers hearing the previous track and the VOD recording it,
* mic mid-stream left viewers hearing the previous track and the VOD recording it,
* silently, for the rest of the session.
* silently, for the rest of the session.
@@ -51,12 +51,11 @@ const republish = bc.slice(bc.indexOf('function _republishAudioTrack('));
@@ -51,12 +51,11 @@ const republish = bc.slice(bc.indexOf('function _republishAudioTrack('));
for (const [what, re] of [
for (const [what, re] of [
['the local SFU producer', /audioProducer\?\.replaceTrack\(\{ track \}\)/],
['the local SFU producer', /audioProducer\?\.replaceTrack\(\{ track \}\)/],
['the RobotStreamer producer', /robotStreamer\?\.audioProducer\?\.replaceTrack/],
['the RobotStreamer producer', /robotStreamer\?\.audioProducer\?\.replaceTrack/],
- ['viewer peer connections', /getSenders\?\.\(\)[\s\S]{0,120}replaceTrack\(track\)/],
['the VOD recorder', /uploadVodRecording\(streamId, \{ finalizeStream: false \}\)[\s\S]{0,120}startVodRecording\(streamId\)/],
['the VOD recorder', /uploadVodRecording\(streamId, \{ finalizeStream: false \}\)[\s\S]{0,120}startVodRecording\(streamId\)/],
]) {
]) {
assert.ok(re.test(republish), `_republishAudioTrack must update ${what}`);
assert.ok(re.test(republish), `_republishAudioTrack must update ${what}`);
}
}
-console.log('OK D: SFU, RobotStreamer, viewer PCs and the VOD segment are all updated on a real track change');
+console.log('OK D: the SFU, RobotStreamer and the VOD segment are all updated on a real track change');
// ── E: the mixer is torn down with the capture ───────────────────────────────────────
// ── E: the mixer is torn down with the capture ───────────────────────────────────────
assert.ok(/_teardownAudioMixer\(ss\)/.test(bc), 'the mixer must be closed when capture stops');
assert.ok(/_teardownAudioMixer\(ss\)/.test(bc), 'the mixer must be closed when capture stops');
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+
+/**
+ * C-77 — ALLOW_P2P_FALLBACK is gone; the env name is inert.
+ *
+ * A broadcast must behave exactly the same whether the legacy variable is set
+ * or not. We prove that at two levels:
+ * 1. Building the server config with ALLOW_P2P_FALLBACK set to various values
+ * yields a config byte-identical to the default build, and no
+ * `allowP2pFallback` field exists to read.
+ * 2. No broadcast code path (server or client) names the variable at all, so
+ * every downstream decision is taken without it.
+ */
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const CONFIG_PATH = require.resolve('../server/config');
+
+function buildConfig(envValue) {
+ delete require.cache[CONFIG_PATH];
+ if (envValue === undefined) {
+ delete process.env.ALLOW_P2P_FALLBACK;
+ } else {
+ process.env.ALLOW_P2P_FALLBACK = envValue;
+ }
+ // JSON round-trip drops functions (refreshRegistry, _normalizeTurnUrl) so the
+ // snapshot is a pure comparison of the config values a broadcast would read.
+ return JSON.stringify(require('../server/config'));
+}
+
+const withoutFlag = buildConfig(undefined);
+delete process.env.ALLOW_P2P_FALLBACK; // leave the process clean for any later test
+
+assert.ok(
+ !Object.prototype.hasOwnProperty.call(JSON.parse(withoutFlag), 'allowP2pFallback'),
+ 'server config must not expose an allowP2pFallback field'
+);
+assert.strictEqual(
+ buildConfig('true'), withoutFlag,
+ 'config with ALLOW_P2P_FALLBACK=true must be identical to config without it'
+);
+assert.strictEqual(
+ buildConfig('1'), withoutFlag,
+ 'config with ALLOW_P2P_FALLBACK=1 must be identical to config without it'
+);
+assert.strictEqual(
+ buildConfig('false'), withoutFlag,
+ 'config with ALLOW_P2P_FALLBACK=false must be identical to config without it'
+);
+delete process.env.ALLOW_P2P_FALLBACK;
+
+// No broadcast code reads or emits the name — the variable cannot influence a broadcast.
+const SOURCES = [
+ '../server/config.js',
+ '../server/streaming/broadcast-server.js',
+ '../public/js/broadcast.js',
+ '../public/js/broadcast-state.js',
+ '../public/js/stream-player.js',
+];
+for (const rel of SOURCES) {
+ const src = fs.readFileSync(path.join(__dirname, rel), 'utf8');
+ assert.ok(
+ !src.includes('ALLOW_P2P_FALLBACK') && !src.includes('allowP2pFallback'),
+ `${rel} must not reference ALLOW_P2P_FALLBACK (it is ignored)`
+ );
+}
+
+console.log('OK: ALLOW_P2P_FALLBACK is ignored — a broadcast behaves the same with or without it');
@@ -11,9 +11,9 @@
@@ -11,9 +11,9 @@
* E. Client-side: 15s transport connect timeout + null-before-close cascade guard
* E. Client-side: 15s transport connect timeout + null-before-close cascade guard
* F. WHIP ICE disconnect: grace timer + explicit producer-removed emit in cleanupSession
* F. WHIP ICE disconnect: grace timer + explicit producer-removed emit in cleanupSession
* G. Broadcast-server: ICE-state filter + stale-source path + watch-queued message
* G. Broadcast-server: ICE-state filter + stale-source path + watch-queued message
- * H. Client: sfu-source-unavailable and watch-queued handled without P2P offer timeout
+ * H. Client: sfu-source-unavailable and watch-queued handled without the watch-response timeout
* I. Client: frozen-video detector starts after play, escalates keyframe -> rebuild
* I. Client: frozen-video detector starts after play, escalates keyframe -> rebuild
- * J. Broadcaster auto-publishes into SFU and legacy P2P is explicitly gated
+ * J. Broadcaster auto-publishes into SFU (the SFU is the only media path)
* K. Server startup logs TURN / announced-IP diagnostics for SFU viewers
* K. Server startup logs TURN / announced-IP diagnostics for SFU viewers
*/
*/
@@ -159,8 +159,8 @@ assert.ok(
@@ -159,8 +159,8 @@ assert.ok(
'broadcast-server.js must send watch-queued when viewer is added to pending queue'
'broadcast-server.js must send watch-queued when viewer is added to pending queue'
);
);
assert.ok(
assert.ok(
- bcastSrc.includes('config.allowP2pFallback') && bcastSrc.includes('p2p.relay.attempt'),
- 'broadcast-server.js must gate legacy P2P relay behind config.allowP2pFallback and log relay attempts'
+ !bcastSrc.includes('allowP2pFallback') && !bcastSrc.includes('p2p.relay.attempt') && !bcastSrc.includes('relaySignaling'),
+ 'broadcast-server.js must carry no legacy P2P relay path — the SFU is the only media path'
);
);
assert.ok(
assert.ok(
bcastSrc.includes('viewer.queued') && bcastSrc.includes('viewer.notified'),
bcastSrc.includes('viewer.queued') && bcastSrc.includes('viewer.notified'),
@@ -234,24 +234,20 @@ assert.ok(
@@ -234,24 +234,20 @@ assert.ok(
);
);
console.log('OK I: frozen-video detector nudges with a keyframe, then rebuilds the transport');
console.log('OK I: frozen-video detector nudges with a keyframe, then rebuilds the transport');
-// ── J: broadcaster auto-publishes into SFU, P2P rollback gated ──
+// ── J: broadcaster auto-publishes into SFU (SFU is the only media path) ──
assert.ok(
assert.ok(
broadcastClientSrc.includes("_ensureSfuBroadcastReady(streamId, 'signaling-open')"),
broadcastClientSrc.includes("_ensureSfuBroadcastReady(streamId, 'signaling-open')"),
'broadcast.js must auto-start SFU publishing when broadcaster signaling opens'
'broadcast.js must auto-start SFU publishing when broadcaster signaling opens'
);
);
assert.ok(
assert.ok(
- broadcastClientSrc.includes('ss._allowP2pFallback = !!msg.allowP2pFallback'),
- 'broadcast.js must track whether legacy P2P rollback is enabled'
+ !broadcastClientSrc.includes('_allowP2pFallback') && !broadcastClientSrc.includes('createViewerConnection'),
+ 'broadcast.js must carry no legacy P2P viewer-connection path — SFU-only'
);
);
assert.ok(
assert.ok(
- broadcastClientSrc.includes('SFU-only mode is active'),
- 'broadcast.js must ignore legacy P2P viewer signaling while SFU-only mode is active'
+ !configSrc.includes('ALLOW_P2P_FALLBACK') && !configSrc.includes('allowP2pFallback'),
+ 'server/config.js must not expose the removed ALLOW_P2P_FALLBACK flag'
);
);
-assert.ok(
- configSrc.includes('ALLOW_P2P_FALLBACK') && configSrc.includes('allowP2pFallback'),
- 'server/config.js must expose the ALLOW_P2P_FALLBACK feature flag'
-);
-console.log('OK J: broadcaster auto-publishes into SFU and P2P rollback is explicitly gated');
+console.log('OK J: broadcaster auto-publishes into SFU — the SFU is the only media path');
// ── K: server startup TURN / announced-IP diagnostics ───────────
// ── K: server startup TURN / announced-IP diagnostics ───────────
assert.ok(
assert.ok(