feat(webRTC): enhance WebRTC connection management with improved state handling, add candidate queuing, and refine client stream visibility logic
This commit is contained in:
@@ -155,12 +155,17 @@ let peerSessionId = null;
|
|||||||
let peerTargetDeviceId = null;
|
let peerTargetDeviceId = null;
|
||||||
let remoteStreamWaitTimer = null;
|
let remoteStreamWaitTimer = null;
|
||||||
let frameRelayTimer = null;
|
let frameRelayTimer = null;
|
||||||
|
let frameRelayStartTimer = null;
|
||||||
let frameCanvas = null;
|
let frameCanvas = null;
|
||||||
let frameContext = null;
|
let frameContext = null;
|
||||||
let activeMediaRecorder = null;
|
let activeMediaRecorder = null;
|
||||||
let activeRecordingChunks = [];
|
let activeRecordingChunks = [];
|
||||||
let activeRecordingStartedAt = null;
|
let activeRecordingStartedAt = null;
|
||||||
let recordingModalUrl = null;
|
let recordingModalUrl = null;
|
||||||
|
let webrtcConnected = false;
|
||||||
|
let hasWebrtcEverConnected = false;
|
||||||
|
let lastPeerConnectionState = null;
|
||||||
|
let pendingRemoteCandidates = [];
|
||||||
const rtcConfig = {
|
const rtcConfig = {
|
||||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||||
};
|
};
|
||||||
@@ -217,7 +222,14 @@ const startCameraPreview = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localCameraStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
localCameraStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
width: { ideal: 640, max: 960 },
|
||||||
|
height: { ideal: 360, max: 540 },
|
||||||
|
frameRate: { ideal: 15, max: 24 },
|
||||||
|
},
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
videoEl.srcObject = localCameraStream;
|
videoEl.srcObject = localCameraStream;
|
||||||
videoEl.classList.remove('hidden');
|
videoEl.classList.remove('hidden');
|
||||||
addActivity('Camera', 'Camera access granted');
|
addActivity('Camera', 'Camera access granted');
|
||||||
@@ -241,19 +253,38 @@ const stopCameraPreview = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setClientStreamVisibility = (isVisible) => {
|
const setClientStreamPlaceholderText = (text) => {
|
||||||
|
const placeholderEl = $('clientStreamPlaceholder');
|
||||||
|
if (!placeholderEl) return;
|
||||||
|
const label = placeholderEl.querySelector('p');
|
||||||
|
if (label) {
|
||||||
|
label.textContent = text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setClientStreamMode = (mode) => {
|
||||||
const videoEl = $('clientStreamVideo');
|
const videoEl = $('clientStreamVideo');
|
||||||
const imageEl = $('clientStreamImage');
|
const imageEl = $('clientStreamImage');
|
||||||
const placeholderEl = $('clientStreamPlaceholder');
|
const placeholderEl = $('clientStreamPlaceholder');
|
||||||
if (videoEl) {
|
|
||||||
videoEl.classList.toggle('hidden', !isVisible);
|
if (videoEl) videoEl.classList.toggle('hidden', mode !== 'video');
|
||||||
|
if (imageEl) imageEl.classList.toggle('hidden', mode !== 'image');
|
||||||
|
|
||||||
|
if (!placeholderEl) return;
|
||||||
|
|
||||||
|
if (mode === 'video' || mode === 'image') {
|
||||||
|
placeholderEl.classList.add('hidden');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (imageEl) {
|
|
||||||
imageEl.classList.toggle('hidden', !isVisible);
|
if (mode === 'unavailable') {
|
||||||
}
|
setClientStreamPlaceholderText('Stream unavailable');
|
||||||
if (placeholderEl) {
|
} else if (mode === 'connecting') {
|
||||||
placeholderEl.classList.toggle('hidden', isVisible);
|
setClientStreamPlaceholderText('Connecting stream...');
|
||||||
|
} else {
|
||||||
|
setClientStreamPlaceholderText('Waiting for stream');
|
||||||
}
|
}
|
||||||
|
placeholderEl.classList.remove('hidden');
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearClientStream = () => {
|
const clearClientStream = () => {
|
||||||
@@ -273,7 +304,7 @@ const clearClientStream = () => {
|
|||||||
if (imageEl) {
|
if (imageEl) {
|
||||||
imageEl.src = '';
|
imageEl.src = '';
|
||||||
}
|
}
|
||||||
setClientStreamVisibility(false);
|
setClientStreamMode('none');
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCameraLabel = (cameraDeviceId) => `Camera ${cameraDeviceId?.substring(0, 6) ?? 'Unknown'}`;
|
const getCameraLabel = (cameraDeviceId) => `Camera ${cameraDeviceId?.substring(0, 6) ?? 'Unknown'}`;
|
||||||
@@ -341,6 +372,10 @@ const closeRecordingModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stopFrameRelay = () => {
|
const stopFrameRelay = () => {
|
||||||
|
if (frameRelayStartTimer) {
|
||||||
|
clearTimeout(frameRelayStartTimer);
|
||||||
|
frameRelayStartTimer = null;
|
||||||
|
}
|
||||||
if (frameRelayTimer) {
|
if (frameRelayTimer) {
|
||||||
clearInterval(frameRelayTimer);
|
clearInterval(frameRelayTimer);
|
||||||
frameRelayTimer = null;
|
frameRelayTimer = null;
|
||||||
@@ -349,6 +384,7 @@ const stopFrameRelay = () => {
|
|||||||
|
|
||||||
const startFrameRelay = async (streamSessionId, toDeviceId) => {
|
const startFrameRelay = async (streamSessionId, toDeviceId) => {
|
||||||
if (!socket || !streamSessionId || !toDeviceId) return;
|
if (!socket || !streamSessionId || !toDeviceId) return;
|
||||||
|
if (hasWebrtcEverConnected) return;
|
||||||
|
|
||||||
const ready = await startCameraPreview();
|
const ready = await startCameraPreview();
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
@@ -360,6 +396,7 @@ const startFrameRelay = async (streamSessionId, toDeviceId) => {
|
|||||||
|
|
||||||
stopFrameRelay();
|
stopFrameRelay();
|
||||||
frameRelayTimer = setInterval(() => {
|
frameRelayTimer = setInterval(() => {
|
||||||
|
if (webrtcConnected || hasWebrtcEverConnected) return;
|
||||||
if (!socket || cameraVideoEl.readyState < 2 || !cameraVideoEl.videoWidth || !cameraVideoEl.videoHeight) return;
|
if (!socket || cameraVideoEl.readyState < 2 || !cameraVideoEl.videoWidth || !cameraVideoEl.videoHeight) return;
|
||||||
|
|
||||||
if (!frameCanvas) {
|
if (!frameCanvas) {
|
||||||
@@ -379,7 +416,7 @@ const startFrameRelay = async (streamSessionId, toDeviceId) => {
|
|||||||
frame,
|
frame,
|
||||||
capturedAt: new Date().toISOString(),
|
capturedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}, 300);
|
}, 600);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPreferredRecordingMimeType = () => {
|
const getPreferredRecordingMimeType = () => {
|
||||||
@@ -459,6 +496,7 @@ const stopLocalRecording = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const teardownPeerConnection = () => {
|
const teardownPeerConnection = () => {
|
||||||
|
const previousSessionId = peerSessionId;
|
||||||
if (peerConnection) {
|
if (peerConnection) {
|
||||||
peerConnection.onicecandidate = null;
|
peerConnection.onicecandidate = null;
|
||||||
peerConnection.ontrack = null;
|
peerConnection.ontrack = null;
|
||||||
@@ -469,9 +507,46 @@ const teardownPeerConnection = () => {
|
|||||||
peerConnection = null;
|
peerConnection = null;
|
||||||
peerSessionId = null;
|
peerSessionId = null;
|
||||||
peerTargetDeviceId = null;
|
peerTargetDeviceId = null;
|
||||||
|
lastPeerConnectionState = null;
|
||||||
|
webrtcConnected = false;
|
||||||
|
hasWebrtcEverConnected = false;
|
||||||
|
if (previousSessionId) {
|
||||||
|
pendingRemoteCandidates = pendingRemoteCandidates.filter((item) => item.streamSessionId !== previousSessionId);
|
||||||
|
}
|
||||||
clearClientStream();
|
clearClientStream();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queueRemoteCandidate = ({ streamSessionId, fromDeviceId, data }) => {
|
||||||
|
if (!streamSessionId || !fromDeviceId || !data) return;
|
||||||
|
pendingRemoteCandidates.push({ streamSessionId, fromDeviceId, data, createdAt: Date.now() });
|
||||||
|
const cutoff = Date.now() - 120000;
|
||||||
|
pendingRemoteCandidates = pendingRemoteCandidates
|
||||||
|
.filter((item) => item.createdAt >= cutoff)
|
||||||
|
.slice(-200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const takeQueuedCandidates = (streamSessionId, fromDeviceId) => {
|
||||||
|
const queued = pendingRemoteCandidates.filter(
|
||||||
|
(item) => item.streamSessionId === streamSessionId && item.fromDeviceId === fromDeviceId
|
||||||
|
);
|
||||||
|
pendingRemoteCandidates = pendingRemoteCandidates.filter(
|
||||||
|
(item) => !(item.streamSessionId === streamSessionId && item.fromDeviceId === fromDeviceId)
|
||||||
|
);
|
||||||
|
return queued;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyQueuedCandidates = async (connection, streamSessionId, fromDeviceId) => {
|
||||||
|
if (!connection?.remoteDescription) return;
|
||||||
|
const queued = takeQueuedCandidates(streamSessionId, fromDeviceId);
|
||||||
|
for (const candidate of queued) {
|
||||||
|
try {
|
||||||
|
await connection.addIceCandidate(new RTCIceCandidate(candidate.data));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Dropping queued ICE candidate', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const ensurePeerConnection = async ({
|
const ensurePeerConnection = async ({
|
||||||
streamSessionId,
|
streamSessionId,
|
||||||
targetDeviceId,
|
targetDeviceId,
|
||||||
@@ -499,8 +574,35 @@ const ensurePeerConnection = async ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
connection.onconnectionstatechange = () => {
|
connection.onconnectionstatechange = () => {
|
||||||
|
if (connection.connectionState !== lastPeerConnectionState) {
|
||||||
|
if (connection.connectionState === 'connected') {
|
||||||
|
addActivity('WebRTC', 'Peer connected');
|
||||||
|
} else if (
|
||||||
|
connection.connectionState === 'failed' ||
|
||||||
|
connection.connectionState === 'disconnected' ||
|
||||||
|
connection.connectionState === 'closed'
|
||||||
|
) {
|
||||||
addActivity('WebRTC', `Peer ${connection.connectionState}`);
|
addActivity('WebRTC', `Peer ${connection.connectionState}`);
|
||||||
if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected' || connection.connectionState === 'closed') {
|
}
|
||||||
|
lastPeerConnectionState = connection.connectionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.connectionState === 'connected') {
|
||||||
|
webrtcConnected = true;
|
||||||
|
hasWebrtcEverConnected = true;
|
||||||
|
stopFrameRelay();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.connectionState === 'disconnected') {
|
||||||
|
if (!hasWebrtcEverConnected) {
|
||||||
|
webrtcConnected = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.connectionState === 'failed' || connection.connectionState === 'closed') {
|
||||||
|
webrtcConnected = false;
|
||||||
|
hasWebrtcEverConnected = false;
|
||||||
if (store.get().device?.role === 'client') {
|
if (store.get().device?.role === 'client') {
|
||||||
clearClientStream();
|
clearClientStream();
|
||||||
}
|
}
|
||||||
@@ -514,11 +616,14 @@ const ensurePeerConnection = async ({
|
|||||||
}
|
}
|
||||||
const [stream] = event.streams;
|
const [stream] = event.streams;
|
||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
|
webrtcConnected = true;
|
||||||
|
hasWebrtcEverConnected = true;
|
||||||
|
stopFrameRelay();
|
||||||
remoteClientStream = stream;
|
remoteClientStream = stream;
|
||||||
const videoEl = $('clientStreamVideo');
|
const videoEl = $('clientStreamVideo');
|
||||||
if (videoEl) {
|
if (videoEl) {
|
||||||
videoEl.srcObject = stream;
|
videoEl.srcObject = stream;
|
||||||
setClientStreamVisibility(true);
|
setClientStreamMode('video');
|
||||||
void videoEl.play().catch(() => {});
|
void videoEl.play().catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -662,7 +767,11 @@ const connectSocket = () => {
|
|||||||
await startLocalRecording();
|
await startLocalRecording();
|
||||||
if (payload.sourceDeviceId) {
|
if (payload.sourceDeviceId) {
|
||||||
await startOfferToClient(streamId, payload.sourceDeviceId);
|
await startOfferToClient(streamId, payload.sourceDeviceId);
|
||||||
await startFrameRelay(streamId, payload.sourceDeviceId);
|
frameRelayStartTimer = setTimeout(() => {
|
||||||
|
if (!webrtcConnected && !hasWebrtcEverConnected) {
|
||||||
|
void startFrameRelay(streamId, payload.sourceDeviceId);
|
||||||
|
}
|
||||||
|
}, 2500);
|
||||||
}
|
}
|
||||||
addActivity('Stream', 'Accepted & Published');
|
addActivity('Stream', 'Accepted & Published');
|
||||||
// Auto-stop after 15s for simulation
|
// Auto-stop after 15s for simulation
|
||||||
@@ -720,6 +829,7 @@ const connectSocket = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('stream:frame', (payload) => {
|
socket.on('stream:frame', (payload) => {
|
||||||
|
if (webrtcConnected) return;
|
||||||
if (!payload?.frame) return;
|
if (!payload?.frame) return;
|
||||||
if (remoteStreamWaitTimer) {
|
if (remoteStreamWaitTimer) {
|
||||||
clearTimeout(remoteStreamWaitTimer);
|
clearTimeout(remoteStreamWaitTimer);
|
||||||
@@ -733,7 +843,7 @@ const connectSocket = () => {
|
|||||||
if (videoEl) {
|
if (videoEl) {
|
||||||
videoEl.classList.add('hidden');
|
videoEl.classList.add('hidden');
|
||||||
}
|
}
|
||||||
setClientStreamVisibility(true);
|
setClientStreamMode('image');
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('stream:ended', (payload) => {
|
socket.on('stream:ended', (payload) => {
|
||||||
@@ -757,6 +867,7 @@ const connectSocket = () => {
|
|||||||
asCamera: false,
|
asCamera: false,
|
||||||
});
|
});
|
||||||
await connection.setRemoteDescription(new RTCSessionDescription(payload.data));
|
await connection.setRemoteDescription(new RTCSessionDescription(payload.data));
|
||||||
|
await applyQueuedCandidates(connection, payload.streamSessionId, payload.fromDeviceId);
|
||||||
const answer = await connection.createAnswer();
|
const answer = await connection.createAnswer();
|
||||||
await connection.setLocalDescription(answer);
|
await connection.setLocalDescription(answer);
|
||||||
socket.emit('webrtc:signal', {
|
socket.emit('webrtc:signal', {
|
||||||
@@ -771,15 +882,32 @@ const connectSocket = () => {
|
|||||||
|
|
||||||
if (payload.signalType === 'answer') {
|
if (payload.signalType === 'answer') {
|
||||||
if (device.role !== 'camera' || !peerConnection) return;
|
if (device.role !== 'camera' || !peerConnection) return;
|
||||||
|
if (peerSessionId !== payload.streamSessionId || peerTargetDeviceId !== payload.fromDeviceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (peerConnection.signalingState !== 'have-local-offer') {
|
||||||
|
if (peerConnection.signalingState === 'stable' && peerConnection.remoteDescription?.type === 'answer') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(payload.data));
|
await peerConnection.setRemoteDescription(new RTCSessionDescription(payload.data));
|
||||||
|
await applyQueuedCandidates(peerConnection, payload.streamSessionId, payload.fromDeviceId);
|
||||||
addActivity('WebRTC', 'Answer received');
|
addActivity('WebRTC', 'Answer received');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.signalType === 'candidate') {
|
if (payload.signalType === 'candidate') {
|
||||||
if (!peerConnection || !payload.data) return;
|
if (!payload.data) return;
|
||||||
|
if (!peerConnection || peerSessionId !== payload.streamSessionId || peerTargetDeviceId !== payload.fromDeviceId) {
|
||||||
|
queueRemoteCandidate(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!peerConnection.remoteDescription) {
|
||||||
|
queueRemoteCandidate(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await peerConnection.addIceCandidate(new RTCIceCandidate(payload.data));
|
await peerConnection.addIceCandidate(new RTCIceCandidate(payload.data));
|
||||||
addActivity('WebRTC', 'ICE candidate added');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,10 +933,14 @@ const startPolling = () => {
|
|||||||
if (pollInterval) clearInterval(pollInterval);
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
|
||||||
const poller = async () => {
|
const poller = async () => {
|
||||||
const { device, screen } = store.get();
|
const { device, screen, activeStreamSessionId } = store.get();
|
||||||
if (!device) return;
|
if (!device) return;
|
||||||
|
|
||||||
if (screen === 'home' && device.role === 'client') {
|
if (screen === 'home' && device.role === 'client') {
|
||||||
|
if (activeStreamSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const recs = await API.ops.listRecordings().catch(() => ({ recordings: [] }));
|
const recs = await API.ops.listRecordings().catch(() => ({ recordings: [] }));
|
||||||
store.update({ recordings: recs.recordings || [] });
|
store.update({ recordings: recs.recordings || [] });
|
||||||
|
|
||||||
@@ -1128,10 +1260,22 @@ const render = (state) => {
|
|||||||
const videoEl = $('clientStreamVideo');
|
const videoEl = $('clientStreamVideo');
|
||||||
if (videoEl && videoEl.srcObject !== remoteClientStream) {
|
if (videoEl && videoEl.srcObject !== remoteClientStream) {
|
||||||
videoEl.srcObject = remoteClientStream;
|
videoEl.srcObject = remoteClientStream;
|
||||||
setClientStreamVisibility(true);
|
setClientStreamMode('video');
|
||||||
void videoEl.play().catch(() => {});
|
void videoEl.play().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageEl = $('clientStreamImage');
|
||||||
|
if (imageEl && !imageEl.dataset.errorBound) {
|
||||||
|
imageEl.dataset.errorBound = '1';
|
||||||
|
imageEl.addEventListener('error', () => {
|
||||||
|
const videoEl = $('clientStreamVideo');
|
||||||
|
if (videoEl) {
|
||||||
|
videoEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
setClientStreamMode('unavailable');
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const recList = $('recordingsList');
|
const recList = $('recordingsList');
|
||||||
|
|||||||
Reference in New Issue
Block a user