let peerConnection = null; let peerConnections = {}; let remoteStreams = {}; 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 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 applyLocalStreamToPeerConnections() { if (!localStream) return; const audioTrack = localStream.getAudioTracks()[0] || null; const videoTrack = localStream.getVideoTracks()[0] || null; for (const username of Object.keys(peerConnections)) { const pc = peerConnections[username]; const senders = pc.getSenders(); 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 for ${username}`); } else if (audioTrack) { pc.addTrack(audioTrack, localStream); LogMessage(`Added audio track for ${username}`); } if (videoSender) { await videoSender.replaceTrack(videoTrack); LogMessage(`Replaced video track for ${username}`); } else if (videoTrack) { pc.addTrack(videoTrack, localStream); LogMessage(`Added video track for ${username}`); } } } async function refreshDevicesAndPreview() { await loadDevices(); await ensureLocalMedia(true); if (Object.keys(peerConnections).length > 0) { await applyLocalStreamToPeerConnections(); } } async function joinChannelCall() { LogMessage("Current username: " + currentUsername); LogMessage("Current channel: " + currentChannelId); const isActive = await window.HybridWebView.InvokeDotNet("JoinRtcChannel"); const peerConnection = await ensurePeerConnectionForUser(currentUsername); await ensureLocalMedia(); if (isActive) { 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.setRemoteDescription(answer); await window.HybridWebView.InvokeDotNet("WriteRtcAnswer", [JSON.stringify(answer)]) const rawParticipants = await window.HybridWebView.InvokeDotNet("GetRtcParticipants"); const participants = typeof rawParticipants === "string" ? JSON.parse(rawParticipants) : rawParticipants; LogMessage("Participants: " + JSON.stringify(participants)); // TODO: Remove for (const username of participants) { if (username === currentUsername) continue; const pc = await ensurePeerConnectionForUser(username); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const payload = { type: "rtc_offer", from: currentUsername, to: username, channelId: currentChannelId, sdp: offer.sdp }; await window.HybridWebView.InvokeDotNet("SendRtcSignal", [JSON.stringify(payload)]); LogMessage(`Sent offer to ${username}`); } } else { try { LogMessage(currentUsername + " attempted to join inactive channel. Making new call.") 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)}`); } catch (error) { LogMessage(error) } } } async function channelCallJoin(activeCall) { // LogMessage("Active call: " + activeCall); await ensurePeerConnectionForUser(currentUsername); 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)]); } else { const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); await window.HybridWebView.InvokeDotNet("WriteRtcOffer", [JSON.stringify(offer)]); } } async function ensurePeerConnectionForUser(username) { if (peerConnections[username]) return peerConnections[username]; const pc = new RTCPeerConnection(configuration); peerConnections[username] = pc; pc.onicegatheringstatechange = () => { console.log(`ICE gathering state changed for ${username}: ${pc.iceGatheringState}`); }; pc.onconnectionstatechange = () => { console.log(`Connection state change for ${username}: ${pc.connectionState}`); }; pc.onsignalingstatechange = () => { console.log(`Signaling state change for ${username}: ${pc.signalingState}`); }; pc.oniceconnectionstatechange = () => { console.log(`ICE connection state change for ${username}: ${pc.iceConnectionState}`); }; pc.onicecandidate = async (event) => { if (!event.candidate) return; await window.HybridWebView.InvokeDotNet("WriteIceCandidate", [JSON.stringify(event.candidate)]); }; pc.ontrack = (event) => { LogMessage(`Remote track received from ${username}`); if (!remoteStreams[username]) { remoteStreams[username] = new MediaStream(); } const stream = remoteStreams[username]; event.streams[0].getTracks().forEach(track => { if (!stream.getTracks().some(t => t.id === track.id)) { stream.addTrack(track); } }); const remoteVideo = ensureRemoteTile(username); if (remoteVideo) { remoteVideo.srcObject = stream; } }; if (localStream) { const existingKinds = pc.getSenders() .map(sender => sender.track?.kind) .filter(Boolean); for (const track of localStream.getTracks()) { if (!existingKinds.includes(track.kind)) { pc.addTrack(track, localStream); } } } return pc; } async function RtcLeaveCall() { // TODO: Just a minimal function so it's not empty. for (const username of Object.keys(peerConnections)) { peerConnections[username].close(); removeRemoteTile(username); } peerConnections = {}; remoteStreams = {}; candidateQueue = []; LogMessage("RTC call cleaned up"); } function removeParticipant(username) { const pc = peerConnections[username]; if (pc) { pc.close(); delete peerConnections[username]; } delete remoteStreams[username]; removeRemoteTile(username); LogMessage(`Removed participant ${username}`); } 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); if (!msg.from || msg.from === currentUsername) return; const pc = await ensurePeerConnectionForUser(msg.from); if (msg.type === "rtc_offer") { await ensureLocalMedia(); await pc.setRemoteDescription({ type: "offer", sdp: msg.sdp }); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); const payload = { type: "rtc_answer", from: currentUsername, to: msg.from, channelId: msg.channelId, sdp: answer.sdp }; await window.HybridWebView.InvokeDotNet("SendRtcSignal", [JSON.stringify(payload)]); LogMessage(`Sent answer to ${msg.from}`); return; } if (msg.type === "rtc_answer") { await pc.setRemoteDescription({ type: "answer", sdp: msg.sdp }); LogMessage(`Remote answer applied for ${msg.from}`); return; } if (msg.type === "rtc_ice_candidate") { await pc.addIceCandidate({ candidate: msg.candidate, sdpMid: msg.sdpMid, sdpMLineIndex: msg.sdpMLineIndex }); LogMessage(`Remote ICE candidate applied for ${msg.from}`); 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); } } function wireDeviceSelectors() { const cameraSelect = document.getElementById("cameraSelect"); const micSelect = document.getElementById("micSelect"); if (cameraSelect) { cameraSelect.onchange = async () => { LogMessage("Camera changed"); await ensureLocalMedia(true); await applyLocalStreamToPeerConnections(); }; } if (micSelect) { micSelect.onchange = async () => { LogMessage("Microphone changed"); await ensureLocalMedia(true); await applyLocalStreamToPeerConnections(); }; } } 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? function ensureRemoteTile(username) { let tile = document.getElementById(`remote-tile-${username}`); if (tile) { return tile.querySelector("video"); } const container = document.getElementById("remoteMediaContainer"); if (!container) return null; tile = document.createElement("div"); tile.id = `remote-tile-${username}`; tile.className = "remote-tile"; const video = document.createElement("video"); video.id = `remote-video-${username}`; video.autoplay = true; video.playsInline = true; const label = document.createElement("div"); label.className = "remote-label"; label.textContent = username; tile.appendChild(video); tile.appendChild(label); container.appendChild(tile); return video; } function removeRemoteTile(username) { const tile = document.getElementById(`remote-tile-${username}`); if (tile) { tile.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); });