let currentUsername = null; let currentChannelId = null; let localStream = null; let availableCameras = []; let availableMics = []; const peerConnections = new Map(); const candidateQueues = new Map(); 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); }; window.handleRtcOffer = async function (remoteUsername, offer) { await ensureLocalMedia(); const peer = await ensurePeerConnection(remoteUsername); LogMessage("Incoming offer from " + remoteUsername); await peer.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await peer.createAnswer(); await peer.setLocalDescription(answer); const payload = { channelId: currentChannelId, username: currentUsername, targetUsername: remoteUsername, sessionDescription: answer }; await window.HybridWebView.InvokeDotNet("WriteRtcAnswer", [JSON.stringify(payload)]); }; window.handleRtcAnswer = async function (remoteUsername, answer) { const peer = await ensurePeerConnection(remoteUsername); LogMessage("Incoming answer from " + remoteUsername); await peer.setRemoteDescription(new RTCSessionDescription(answer)); await flushCandidateQueue(remoteUsername); }; window.handleRtcCandidate = async function (remoteUsername, candidate) { const peer = await ensurePeerConnection(remoteUsername); if (peer.remoteDescription) { await peer.addIceCandidate(new RTCIceCandidate(candidate)); } else { const queue = candidateQueues.get(remoteUsername) || []; queue.push(candidate); candidateQueues.set(remoteUsername, queue); } }; window.handleRtcParticipantLeft = function (remoteUsername) { LogMessage(remoteUsername + " left the call"); closePeerConnection(remoteUsername); removeRemoteTile(remoteUsername); }; function LogMessage(msg) { const messageLog = document.getElementById("messageLog"); messageLog.value += "\r\n" + msg; messageLog.scrollTop = messageLog.scrollHeight; } async function ensurePeerConnection(remoteUsername) { if (peerConnections.has(remoteUsername)) { return peerConnections.get(remoteUsername); } const peer = new RTCPeerConnection(configuration); peerConnections.set(remoteUsername, peer); candidateQueues.set(remoteUsername, []); peer.onicecandidate = async (event) => { if (!event.candidate) { return; } const payload = { channelId: currentChannelId, username: currentUsername, targetUsername: remoteUsername, candidate: event.candidate }; await window.HybridWebView.InvokeDotNet("WriteIceCandidate", [JSON.stringify(payload)]); }; peer.ontrack = (event) => { const stream = event.streams[0]; attachRemoteStream(remoteUsername, stream); }; peer.onconnectionstatechange = () => { LogMessage(remoteUsername + " connection state: " + peer.connectionState); if (peer.connectionState === "failed" || peer.connectionState === "closed" || peer.connectionState === "disconnected") { closePeerConnection(remoteUsername); removeRemoteTile(remoteUsername); } }; if (localStream) { for (const track of localStream.getTracks()) { peer.addTrack(track, localStream); } } return peer; } async function flushCandidateQueue(remoteUsername) { const peer = peerConnections.get(remoteUsername); const queue = candidateQueues.get(remoteUsername) || []; while (peer && queue.length > 0) { const candidate = queue.shift(); await peer.addIceCandidate(new RTCIceCandidate(candidate)); } } 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; } const selectedCameraId = cameraSelect ? cameraSelect.value : ""; const 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 }); } catch (err) { LogMessage("Selected media failed: " + err); localStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: audioConstraint }); } const hasVideo = localStream.getVideoTracks().length > 0; const hasAudio = localStream.getAudioTracks().length > 0; localVideo.srcObject = hasVideo ? localStream : null; localVideoStatus.textContent = hasVideo ? "Local video: active" : "Local video: unavailable"; localMediaStatus.textContent = `Local media: audio=${hasAudio} video=${hasVideo}`; for (const [remoteUsername, peer] of peerConnections) { const senders = peer.getSenders(); for (const track of localStream.getTracks()) { const existingSender = senders.find(sender => sender.track && sender.track.kind === track.kind); if (existingSender) { await existingSender.replaceTrack(track); } else { peer.addTrack(track, localStream); } } LogMessage("Updated local media for " + remoteUsername); } } function attachRemoteStream(remoteUsername, stream) { const remoteVideos = document.getElementById("remoteVideos"); let tile = document.getElementById(`remote-${remoteUsername}`); if (!tile) { tile = document.createElement("div"); tile.id = `remote-${remoteUsername}`; tile.style.display = "inline-block"; tile.style.marginRight = "20px"; tile.style.verticalAlign = "top"; const title = document.createElement("div"); title.textContent = remoteUsername; title.style.marginBottom = "6px"; const video = document.createElement("video"); video.autoplay = true; video.playsInline = true; video.style.width = "320px"; video.style.height = "240px"; video.style.background = "#111"; video.id = `remote-video-${remoteUsername}`; const status = document.createElement("div"); status.id = `remote-status-${remoteUsername}`; status.textContent = "Remote media: active"; tile.appendChild(title); tile.appendChild(video); tile.appendChild(status); remoteVideos.appendChild(tile); } const video = document.getElementById(`remote-video-${remoteUsername}`); const status = document.getElementById(`remote-status-${remoteUsername}`); video.srcObject = stream; status.textContent = `Remote media: audio=${stream.getAudioTracks().length > 0} video=${stream.getVideoTracks().length > 0}`; } function removeRemoteTile(remoteUsername) { const tile = document.getElementById(`remote-${remoteUsername}`); if (tile) { tile.remove(); } } function closePeerConnection(remoteUsername) { const peer = peerConnections.get(remoteUsername); if (peer) { peer.close(); } peerConnections.delete(remoteUsername); candidateQueues.delete(remoteUsername); } async function joinChannelCall() { if (!currentUsername || !currentChannelId) { LogMessage("RTC context is not ready yet."); return; } await ensureLocalMedia(); const rawParticipants = await window.HybridWebView.InvokeDotNet("JoinRtcChannel"); const participants = typeof rawParticipants === "string" ? JSON.parse(rawParticipants) : rawParticipants; LogMessage("Joining call with participants: " + (participants.length ? participants.join(", ") : "none")); for (const remoteUsername of participants) { const peer = await ensurePeerConnection(remoteUsername); const offer = await peer.createOffer(); await peer.setLocalDescription(offer); const payload = { channelId: currentChannelId, username: currentUsername, targetUsername: remoteUsername, sessionDescription: offer }; await window.HybridWebView.InvokeDotNet("WriteRtcOffer", [JSON.stringify(payload)]); LogMessage("Created offer for " + remoteUsername); } } 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"); 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}`; 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 () => { await ensureLocalMedia(true); }; } if (micSelect) { micSelect.onchange = async () => { await ensureLocalMedia(true); }; } } async function refreshDevicesAndPreview() { await loadDevices(); await ensureLocalMedia(true); } 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); });