let peerConnection = null; let localStream = null; let currentUsername = null; let currentChannelId = null; let availableCameras = []; let availableMics = []; let candidateQueue = []; 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); }; } //Remove? async function ensureLocalMedia(forceReload = false) { const localMediaStatus = document.getElementById("localMediaStatus"); const localVideoStatus = document.getElementById("localVideoStatus"); const localVideo = document.getElementById("localVideo"); const cameraSelect = document.getElementById("cameraSelect"); const micSelect = document.getElementById("micSelect"); if (localStream && !forceReload) { return; } if (localStream) { localStream.getTracks().forEach(track => track.stop()); localStream = null; } let selectedCameraId = cameraSelect ? cameraSelect.value : ""; let selectedMicId = micSelect ? micSelect.value : ""; 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) { 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"; if (localVideo) localVideo.srcObject = null; throw audioErr; } } const hasVideo = localStream.getVideoTracks().length > 0; const hasAudio = localStream.getAudioTracks().length > 0; localVideo.srcObject = hasVideo ? localStream : null; if (localVideoStatus) { localVideoStatus.textContent = hasVideo ? "Local video: active" : "Local video: unavailable"; } if (localMediaStatus) { localMediaStatus.textContent = `Local media: audio=${hasAudio} video=${hasVideo}`; } if (!hasVideo) { LogMessage("No camera available, continuing without video"); } } async function applyLocalStreamToPeerConnection() { if (!peerConnection || !localStream) return; const senders = peerConnection.getSenders(); const audioTrack = localStream.getAudioTracks()[0] || null; const videoTrack = localStream.getVideoTracks()[0] || null; const audioSender = senders.find(s => s.track && s.track.kind === "audio"); const videoSender = senders.find(s => s.track && s.track.kind === "video"); if (audioSender) { await audioSender.replaceTrack(audioTrack); LogMessage("Replaced audio track on peer connection"); } else if (audioTrack) { peerConnection.addTrack(audioTrack, localStream); LogMessage("Added audio track to peer connection"); } if (videoSender) { await videoSender.replaceTrack(videoTrack); LogMessage("Replaced video track on peer connection"); } else if (videoTrack) { peerConnection.addTrack(videoTrack, localStream); LogMessage("Added video track to peer connection"); } } async function refreshDevicesAndPreview() { await loadDevices(); await ensureLocalMedia(true); if (peerConnection) { await applyLocalStreamToPeerConnection(); } } async function joinChannelCall() { LogMessage("Current username: " + currentUsername); LogMessage("Current channel: " + currentChannelId); // LogMessage("Joining RTCChannel"); let active = await window.HybridWebView.InvokeDotNet("JoinRtcChannel"); await channelCallJoin(active); // LogMessage("Joined RTCChannel"); // return; // 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); // } } //Combine with channelCallJoin async function ensurePeerConnection2() { if (peerConnection) return; peerConnection = new RTCPeerConnection(configuration); peerConnection.onicegatheringstatechange = () => { console.log(`ICE gathering state changed: ${peerConnection.iceGatheringState}`); }; peerConnection.onconnectionstatechange = () => { console.log(`Connection state change: ${peerConnection.connectionState}`); }; peerConnection.onsignalingstatechange = () => { console.log(`Signaling state change: ${peerConnection.signalingState}`); }; peerConnection.oniceconnectionstatechange = () => { console.log(`ICE connection state change: ${peerConnection.iceConnectionState}`); }; peerConnection.onicecandidate = async (event) => { console.log(`Ice Candidate: ${JSON.stringify(event.candidate)}`); LogMessage(`Ice Candidate: ${JSON.stringify(event.candidate)}`); await window.HybridWebView.InvokeDotNet("WriteIceCandidate", [JSON.stringify(event.candidate)]); await IceCandidateAdded(event.candidate); }; 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}`; } }; } async function channelCallJoin(activeCall) { // LogMessage("Active call: " + activeCall); await ensurePeerConnection2(); await ensureLocalMedia(); await applyLocalStreamToPeerConnection(); if (activeCall) { const rawJson = await window.HybridWebView.InvokeDotNet("GetRtcOffer"); const offer = typeof rawJson === "string" ? JSON.parse(rawJson) : rawJson; await peerConnection.setRemoteDescription(offer); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); // LogMessage("Joining call with media answer: " + JSON.stringify(answer)); // LogMessage("Calling C# WriteRtcAnswer with: " + JSON.stringify(answer)); await window.HybridWebView.InvokeDotNet("WriteRtcAnswer", [JSON.stringify(answer)]); LogMessage("C# WriteRtcAnswer invoked"); //TODO: Update offer in SurrealDB to include answer } else { const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); await window.HybridWebView.InvokeDotNet("WriteRtcOffer", [JSON.stringify(offer)]); LogMessage(`Joining call with media offer: ${JSON.stringify(offer)}`); } } async function AnswerCallbackJS(answer) { answer.sdp = answer.sdp.replaceAll("(rn)", "\r\n"); // LogMessage("Answer: " + JSON.stringify(answer)); // LogMessage("RemoteDescription: " + peerConnection.currentRemoteDescription); if (!peerConnection.currentRemoteDescription && answer) { LogMessage("Current answer: " + JSON.stringify(answer)); const desc = new RTCSessionDescription(answer); await peerConnection.setRemoteDescription(desc); for (const candidate of candidateQueue) { await peerConnection.addIceCandidate(candidate); } } } async function IceCandidateAdded(candidate) { if (peerConnection.currentRemoteDescription) { await peerConnection.addIceCandidate(candidate); LogMessage("ICE CANDIDATE ADDED: " + JSON.stringify(candidate)); } else { LogMessage("RemoteDescription Missing") candidateQueue.push(candidate); } } async function RtcLeaveCall() {} 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); } } //Remove? 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); } } function wireDeviceSelectors() { const cameraSelect = document.getElementById("cameraSelect"); const micSelect = document.getElementById("micSelect"); if (cameraSelect) { cameraSelect.onchange = async () => { LogMessage("Camera changed"); await ensureLocalMedia(true); await applyLocalStreamToPeerConnection(); }; } if (micSelect) { micSelect.onchange = async () => { LogMessage("Microphone changed"); await ensureLocalMedia(true); await applyLocalStreamToPeerConnection(); }; } } 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); }); } //Remove? // 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(); wireDeviceSelectors(); await ensureLocalMedia(true); });