let peerConnection = null; let localStream = null; let currentUsername = null; let currentChannelId = null; let availableCameras = []; let availableMics = []; const configuration = { iceServers:[ { urls:[ 'stun:stun1.l.google.com:19302', 'stun:stun2.l.google.com:19302', ], }, ], iceCandidatePoolSize: 10, } window.setUsername = function(name) { currentUsername = name; LogMessage("Username set to: " + currentUsername); }; window.setChannelId = function(channelId) { currentChannelId = channelId; LogMessage("Channel set to: " + currentChannelId); }; function LogMessage(msg) { const messageLog = document.getElementById("messageLog"); messageLog.value += '\r\n' + msg; messageLog.scrollTop = messageLog.scrollHeight; } function hasVideoTrack() { return !!localStream && localStream.getVideoTracks().length > 0; } function hasAudioTrack() { return !!localStream && localStream.getAudioTracks().length > 0; } async function ensurePeerConnection() { if (peerConnection) return; peerConnection = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }); peerConnection.onicecandidate = (event) => { if (event.candidate) { LogMessage("ICE candidate gathered"); } }; peerConnection.ontrack = (event) => { LogMessage("Remote track received"); const remoteVideo = document.getElementById("remoteVideo"); const remoteVideoStatus = document.getElementById("remoteVideoStatus"); const remoteMediaStatus = document.getElementById("remoteMediaStatus"); const stream = event.streams[0]; const hasVideo = stream.getVideoTracks().length > 0; const hasAudio = stream.getAudioTracks().length > 0; if (hasVideo) { remoteVideo.srcObject = stream; } else { remoteVideo.srcObject = null; } if (remoteVideoStatus) { remoteVideoStatus.textContent = hasVideo ? "Remote video: active" : "Remote video: unavailable"; } if (remoteMediaStatus) { remoteMediaStatus.textContent = `Remote media: audio=${hasAudio} video=${hasVideo}`; } }; peerConnection.onconnectionstatechange = () => { LogMessage("Connection state: " + peerConnection.connectionState); const remoteMediaStatus = document.getElementById("remoteMediaStatus"); if (remoteMediaStatus && peerConnection.connectionState === "connected") { remoteMediaStatus.textContent += " | connected"; } }; peerConnection.oniceconnectionstatechange = () => { LogMessage("ICE connection state: " + peerConnection.iceConnectionState); }; peerConnection.onicegatheringstatechange = () => { LogMessage("ICE gathering state: " + peerConnection.iceGatheringState); }; } async function ensureLocalMedia() { if (localStream) return; const localMediaStatus = document.getElementById("localMediaStatus"); const localVideoStatus = document.getElementById("localVideoStatus"); const cameraSelect = document.getElementById("cameraSelect"); const micSelect = document.getElementById("micSelect"); let selectedCameraId = cameraSelect ? cameraSelect.value : ""; let selectedMicId = micSelect ? micSelect.value : ""; let mediaError = null; const videoConstraint = selectedCameraId ? { deviceId: { exact: selectedCameraId } } : false; const audioConstraint = selectedMicId ? { deviceId: { exact: selectedMicId } } : true; try { localStream = await navigator.mediaDevices.getUserMedia({ video: videoConstraint, audio: audioConstraint }); LogMessage("Local media initialized"); } catch (err) { mediaError = err; LogMessage("selected media failed: " + err); try { localStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: audioConstraint }); LogMessage("Local media initialized with audio only fallback"); } catch (audioErr) { LogMessage("audio-only failed: " + audioErr); if (localMediaStatus) localMediaStatus.textContent = "Local media failed"; if (localVideoStatus) localVideoStatus.textContent = "Local video: unavailable"; throw mediaError; } } const localVideo = document.getElementById("localVideo"); if (localStream.getVideoTracks().length > 0) { localVideo.srcObject = localStream; if (localVideoStatus) localVideoStatus.textContent = "Local video: active"; if (localMediaStatus) localMediaStatus.textContent = "Local media: audio + video"; } else { localVideo.srcObject = null; if (localVideoStatus) localVideoStatus.textContent = "Local video: unavailable"; if (localMediaStatus) localMediaStatus.textContent = "Local media: audio only"; LogMessage("No camera available, continuing without video"); } for (const track of localStream.getTracks()) { peerConnection.addTrack(track, localStream); LogMessage(`Added local track: ${track.kind}`); } } async function joinChannelCall() { LogMessage("Current username: " + currentUsername); LogMessage("Current channel: " + currentChannelId); //TODO: Update Server DB to hold bool if channel has an active call //TODO: First check if channel already has an active offer, if it does join with an answer, otherwise make a new offer try { if (!currentChannelId) { LogMessage("No current channel set."); return; } await ensurePeerConnection(); await ensureLocalMedia(); LogMessage(`Joining call with media: audio=${hasAudioTrack()} video=${hasVideoTrack()}`); const payload = { type: "rtc_join", from: currentUsername, channelId: currentChannelId }; LogMessage("Requesting join for channel " + currentChannelId); await window.HybridWebView.InvokeDotNet("SendRtcSignal", [JSON.stringify(payload)]); } catch (err) { LogMessage("joinChannelCall failed: " + err); } } async function ensurePeerConnection2() { if (peerConnection) return; peerConnection = new RTCPeerConnection(configuration); peerConnection.addEventListener('icegatheringstatechange', () => { console.log( `ICE gathering state changed: ${peerConnection.iceGatheringState}`); }); peerConnection.addEventListener('connectionstatechange', () => { console.log(`Connection state change: ${peerConnection.connectionState}`); }); peerConnection.addEventListener('signalingstatechange', () => { console.log(`Signaling state change: ${peerConnection.signalingState}`); }); peerConnection.addEventListener('iceconnectionstatechange ', () => { console.log( `ICE connection state change: ${peerConnection.iceConnectionState}`); }); } async function channelCallJoin(activeCall) { await ensurePeerConnection2(); if (activeCall) { const offer = roomSnapshot.data().offer; //TODO: Replace with active call offer from DB using the active ID for current channel await peerConnection.setRemoteDescription(offer); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); const roomAnswer = { answer: { type: answer.type, sdp: answer.sdp } } await roomRef.update(roomAnswer); //TODO: Update offer in SurrealDB to include answer } else { const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); const roomOffer = { offer: { type: offer.type, sdp: offer.sdp } } await window.HybridWebView.InvokeDotNet("WriteRtcOffer", [JSON.stringify(offer)]); //TODO: Write roomId to surreal DB with channel id as active call //TODO: Add callback function for when call is answered to replace following code block roomRef.onSnapshot(async snapshot => { console.log('Got updated room:', snapshot.data()); const data = snapshot.data(); if (!peerConnection.currentRemoteDescription && data.answer) { console.log('Set remote description: ', data.answer); const answer = new RTCSessionDescription(data.answer) await peerConnection.setRemoteDescription(answer); } }); localStream.getTracks().forEach(track => { peerConnection.addTrack(track, localStream); }); //TODO: collect ICE candidates peerConnection.addEventListener('track', event => { LogMessage("Received track: " + event.streams[0]); event.streams[0].getTracks().forEach(track => { LogMessage(`Add a track to the remoteStream: ${track}`); remoteStream.addTrack(track); }); }); } } async function handleRtcSignal(rawJson) { try { const msg = typeof rawJson === "string" ? JSON.parse(rawJson) : rawJson; LogMessage("Received signal: " + msg.type + " from " + msg.from + " in " + msg.channelId); await ensurePeerConnection(); if (msg.type === "rtc_join_state") { if (msg.isInitiator) { LogMessage("No active call found. Becoming initiator."); const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); await waitForIceGatheringComplete(peerConnection); const payload = { type: "rtc_offer", from: currentUsername, channelId: currentChannelId, sdp: peerConnection.localDescription.sdp }; LogMessage("Sending offer to channel " + currentChannelId); await window.HybridWebView.InvokeDotNet("SendRtcSignal", [JSON.stringify(payload)]); } else { LogMessage("Active call exists. Waiting for stored offer."); } return; } if (msg.type === "rtc_offer") { LogMessage("Incoming channel call offer from " + msg.from); await ensureLocalMedia(); LogMessage(`Answering call with media: audio=${hasAudioTrack()} video=${hasVideoTrack()}`); LogMessage("Applying remote offer"); await peerConnection.setRemoteDescription({ type: "offer", sdp: msg.sdp }); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); await waitForIceGatheringComplete(peerConnection); const payload = { type: "rtc_answer", from: currentUsername, channelId: msg.channelId, sdp: peerConnection.localDescription.sdp }; LogMessage("Sending answer to channel " + msg.channelId); await window.HybridWebView.InvokeDotNet("SendRtcSignal", [JSON.stringify(payload)]); return; } if (msg.type === "rtc_answer") { LogMessage("Applying remote answer"); await peerConnection.setRemoteDescription({ type: "answer", sdp: msg.sdp }); LogMessage("Remote answer applied"); return; } if (msg.type === "rtc_ice_candidate") { LogMessage("Applying remote ICE candidate"); await peerConnection.addIceCandidate({ candidate: msg.candidate, sdpMid: msg.sdpMid, sdpMLineIndex: msg.sdpMLineIndex }); LogMessage("Remote ICE candidate applied"); return; } LogMessage("Unhandled signal type: " + msg.type); } catch (err) { LogMessage("handleRtcSignal failed: " + err); } } async function loadDevices() { try { const devices = await navigator.mediaDevices.enumerateDevices(); availableCameras = devices.filter(d => d.kind === "videoinput"); availableMics = devices.filter(d => d.kind === "audioinput"); const cameraSelect = document.getElementById("cameraSelect"); const micSelect = document.getElementById("micSelect"); if (!cameraSelect || !micSelect) { LogMessage("Device dropdowns not found."); return; } cameraSelect.innerHTML = ""; micSelect.innerHTML = ""; const noCameraOption = document.createElement("option"); noCameraOption.value = ""; noCameraOption.text = "No camera / audio-only"; cameraSelect.appendChild(noCameraOption); const noMicOption = document.createElement("option"); noMicOption.value = ""; noMicOption.text = "Default microphone"; micSelect.appendChild(noMicOption); for (const cam of availableCameras) { const option = document.createElement("option"); option.value = cam.deviceId; option.text = cam.label || `Camera ${cameraSelect.options.length}`; cameraSelect.appendChild(option); } for (const mic of availableMics) { const option = document.createElement("option"); option.value = mic.deviceId; option.text = mic.label || `Microphone ${micSelect.options.length + 1}`; micSelect.appendChild(option); } LogMessage(`Loaded devices: ${availableCameras.length} cameras, ${availableMics.length} mics`); } catch (err) { LogMessage("loadDevices failed: " + err); } } async function waitForIceGatheringComplete(pc) { if (pc.iceGatheringState === "complete") return; await new Promise(resolve => { function checkState() { if (pc.iceGatheringState === "complete") { pc.removeEventListener("icegatheringstatechange", checkState); resolve(); } } pc.addEventListener("icegatheringstatechange", checkState); }); } window.handleRtcSignal = handleRtcSignal; window.addEventListener("HybridWebViewMessageReceived", function (e) { LogMessage("Raw message: " + e.detail.message); }); window.addEventListener("load", async () => { LogMessage("RTC page loaded"); window.HybridWebView.SendRawMessage("rtc_page_ready"); await loadDevices(); });