From 92c432cd4980fa331830c454898cb39b288603ec Mon Sep 17 00:00:00 2001 From: RuKira Date: Sun, 26 Apr 2026 00:06:49 -0400 Subject: [PATCH] Client Code Done - Needs Bug Fixing --- RelayClient/MainPage.xaml.cs | 13 +- RelayClient/Resources/Raw/wwwroot/index.css | 25 + RelayClient/Resources/Raw/wwwroot/index.html | 7 +- RelayClient/Resources/Raw/wwwroot/index.js | 521 +----------------- RelayClient/Resources/Raw/wwwroot/media.js | 220 ++++++++ .../Resources/Raw/wwwroot/relaySocket.js | 42 ++ RelayClient/Resources/Raw/wwwroot/rtc.js | 224 ++++++++ 7 files changed, 548 insertions(+), 504 deletions(-) create mode 100644 RelayClient/Resources/Raw/wwwroot/media.js create mode 100644 RelayClient/Resources/Raw/wwwroot/relaySocket.js create mode 100644 RelayClient/Resources/Raw/wwwroot/rtc.js diff --git a/RelayClient/MainPage.xaml.cs b/RelayClient/MainPage.xaml.cs index 5cef8f1..5dd799e 100644 --- a/RelayClient/MainPage.xaml.cs +++ b/RelayClient/MainPage.xaml.cs @@ -590,16 +590,9 @@ public partial class MainPage : ContentPage var jsArg = JsonSerializer.Serialize(rawJson); - await hybridWebView.EvaluateJavaScriptAsync($@" - window.HybridWebView.SendRawMessage('JS dispatch wrapper hit'); - const fn = window.handleRtcSignal || window.dispatchRtcSignal; - if (!fn) {{ - window.HybridWebView.SendRawMessage('No RTC signal handler found on window'); - }} else {{ - window.HybridWebView.SendRawMessage('Calling RTC signal handler'); - fn({jsArg}); - }} - "); + await hybridWebView.EvaluateJavaScriptAsync( + $"window.RelaySocket.receiveRtcSignal({jsArg})" + ); SafeSendRawToWebView("RTC signal dispatched to JS"); } diff --git a/RelayClient/Resources/Raw/wwwroot/index.css b/RelayClient/Resources/Raw/wwwroot/index.css index 005dc59..a6fff29 100644 --- a/RelayClient/Resources/Raw/wwwroot/index.css +++ b/RelayClient/Resources/Raw/wwwroot/index.css @@ -86,4 +86,29 @@ textarea::-webkit-scrollbar-thumb { border: 1px solid #332940; border-radius: 10px; padding: 12px; +} + +.remote-media-container { + display: flex; + flex-direction: row; + gap: 16px; + align-items: flex-start; + flex-wrap: nowrap; + overflow-x: auto; + padding: 8px 0; +} + +.remote-media-tile, +.remote-tile { + flex: 0 0 auto; + width: 320px; +} + +.remote-media-tile video, +.remote-tile video { + width: 320px; + height: 240px; + background: #111; + border-radius: 8px; + object-fit: cover; } \ No newline at end of file diff --git a/RelayClient/Resources/Raw/wwwroot/index.html b/RelayClient/Resources/Raw/wwwroot/index.html index e6237f7..cd1b890 100644 --- a/RelayClient/Resources/Raw/wwwroot/index.html +++ b/RelayClient/Resources/Raw/wwwroot/index.html @@ -8,6 +8,9 @@ + + + @@ -16,8 +19,8 @@
- - + +
diff --git a/RelayClient/Resources/Raw/wwwroot/index.js b/RelayClient/Resources/Raw/wwwroot/index.js index 4374da7..2d822bb 100644 --- a/RelayClient/Resources/Raw/wwwroot/index.js +++ b/RelayClient/Resources/Raw/wwwroot/index.js @@ -1,506 +1,41 @@ -let peerConnection = null; -let peerConnections = {}; -let remoteStreams = {}; -let localStream = null; -let currentUsername = 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) { +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) { + +window.setChannelId = function (channelId) { currentChannelId = channelId; LogMessage("Channel set to: " + currentChannelId); }; function LogMessage(msg) { const messageLog = document.getElementById("messageLog"); - messageLog.value += '\r\n' + msg; + + if (!messageLog) { + console.log(msg); + return; + } + + 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(); - } -} - -//TODO: Only join call if no active call in progress for client -//TODO: Enable proper leave call functions -//TODO: Leave call and join new call if client clicks a 2nd voice channel -async function joinChannelCall() { - LogMessage("Current username: " + currentUsername); - LogMessage("Current channel: " + currentChannelId); - - await window.HybridWebView.InvokeDotNet("JoinRtcChannel"); - await ensureLocalMedia(); - - const rawParticipants = await window.HybridWebView.InvokeDotNet("GetRtcParticipants"); - const participants = typeof rawParticipants === "string" - ? JSON.parse(rawParticipants) - : rawParticipants; - - LogMessage("Participants: " + JSON.stringify(participants)); - - const otherUsers = participants.filter(username => username !== currentUsername); - - if (otherUsers.length === 0) { - LogMessage("Joined call as first participant. Waiting for others..."); - return; - } - - for (const username of otherUsers) { - 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, - isInitiator: true - }; - - await window.HybridWebView.InvokeDotNet("SendRtcSignal", [JSON.stringify(payload)]); - LogMessage(`Sent offer to ${username}`); - } -} - -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; - - if (msg.to && msg.to !== currentUsername) { - LogMessage(`Ignoring signal meant for ${msg.to}`); - 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); - } -} - -window.handleRtcSignal = handleRtcSignal; - -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.LogMessage = LogMessage; window.addEventListener("HybridWebViewMessageReceived", function (e) { LogMessage("Raw message: " + e.detail.message); @@ -508,8 +43,10 @@ window.addEventListener("HybridWebViewMessageReceived", function (e) { window.addEventListener("load", async () => { LogMessage("RTC page loaded"); + window.HybridWebView.SendRawMessage("rtc_page_ready"); - await loadDevices(); - wireDeviceSelectors(); - await ensureLocalMedia(true); + + Media.wireDeviceSelectors(); + await Media.loadDevices(); + await Media.ensureLocalMedia(); }); \ No newline at end of file diff --git a/RelayClient/Resources/Raw/wwwroot/media.js b/RelayClient/Resources/Raw/wwwroot/media.js new file mode 100644 index 0000000..c7f9719 --- /dev/null +++ b/RelayClient/Resources/Raw/wwwroot/media.js @@ -0,0 +1,220 @@ +let localStream = null; +const remoteStreams = {}; + +const Media = { + async loadDevices() { + const devices = await navigator.mediaDevices.enumerateDevices(); + + const cameras = devices.filter(d => d.kind === "videoinput"); + const mics = devices.filter(d => d.kind === "audioinput"); + + const cameraSelect = document.getElementById("cameraSelect"); + const micSelect = document.getElementById("micSelect"); + + cameraSelect.innerHTML = ""; + micSelect.innerHTML = ""; + + for (const camera of cameras) { + const option = document.createElement("option"); + option.value = camera.deviceId; + option.textContent = camera.label || `Camera ${cameraSelect.length + 1}`; + cameraSelect.appendChild(option); + } + + for (const mic of mics) { + const option = document.createElement("option"); + option.value = mic.deviceId; + option.textContent = mic.label || `Microphone ${micSelect.length + 1}`; + micSelect.appendChild(option); + } + + LogMessage(`Loaded devices: ${cameras.length} cameras, ${mics.length} mics`); + }, + + async ensureLocalMedia() { + const cameraSelect = document.getElementById("cameraSelect"); + const micSelect = document.getElementById("micSelect"); + + if (localStream) { + return localStream; + } + + const audioDeviceId = micSelect?.value; + const videoDeviceId = cameraSelect?.value; + + const constraints = { + audio: audioDeviceId + ? { deviceId: { exact: audioDeviceId } } + : true, + video: videoDeviceId + ? { deviceId: { exact: videoDeviceId } } + : true + }; + + try { + localStream = await navigator.mediaDevices.getUserMedia(constraints); + } catch { + localStream = await navigator.mediaDevices.getUserMedia({ + audio: audioDeviceId + ? { deviceId: { exact: audioDeviceId } } + : true, + video: false + }); + + LogMessage("No camera available, continuing without video"); + } + + this.attachLocalStream(localStream); + LogMessage("Local media initialized"); + + return localStream; + }, + + attachLocalStream(stream) { + const localVideo = document.getElementById("localVideo"); + const localMediaStatus = document.getElementById("localMediaStatus"); + const localVideoStatus = document.getElementById("localVideoStatus"); + + if (localVideo) { + localVideo.srcObject = stream; + } + + const audioTracks = stream.getAudioTracks(); + const videoTracks = stream.getVideoTracks(); + + if (localMediaStatus) { + localMediaStatus.textContent = + audioTracks.length > 0 + ? "Microphone active" + : "No microphone"; + } + + if (localVideoStatus) { + localVideoStatus.textContent = + videoTracks.length > 0 + ? "Local video active" + : "Local video unavailable"; + } + }, + + async refreshDevicesAndPreview() { + if (localStream) { + localStream.getTracks().forEach(track => track.stop()); + localStream = null; + } + + await this.loadDevices(); + await this.ensureLocalMedia(); + + if (window.RelayRtc?.applyLocalStreamToAllPeerConnections) { + await window.RelayRtc.applyLocalStreamToAllPeerConnections(); + } + }, + + async applyLocalStreamToPeerConnection(pc, username) { + const stream = await this.ensureLocalMedia(); + + const existingSenders = pc.getSenders(); + + for (const track of stream.getTracks()) { + const existingSender = existingSenders.find(sender => + sender.track && sender.track.kind === track.kind + ); + + if (existingSender) { + await existingSender.replaceTrack(track); + } else { + pc.addTrack(track, stream); + } + + LogMessage(`Added local ${track.kind} track for ${username}`); + } + }, + + async applyLocalStreamToAllPeerConnections() { + if (!window.RelayRtc?.peerConnections) return; + + for (const [username, pc] of Object.entries(window.RelayRtc.peerConnections)) { + await this.applyLocalStreamToPeerConnection(pc, username); + } + }, + + attachRemoteStream(username, stream) { + remoteStreams[username] = stream; + + const tile = this.ensureRemoteTile(username); + const video = tile.querySelector("video"); + const status = tile.querySelector(".remote-media-status"); + + video.srcObject = stream; + + const audioTracks = stream.getAudioTracks(); + const videoTracks = stream.getVideoTracks(); + + status.textContent = + `${audioTracks.length > 0 ? "Audio" : "No audio"} / ` + + `${videoTracks.length > 0 ? "Video" : "No video"}`; + }, + + ensureRemoteTile(username) { + const container = document.getElementById("remoteMediaContainer"); + + let tile = document.getElementById(`remote-tile-${username}`); + + if (tile) return tile; + + tile = document.createElement("div"); + tile.id = `remote-tile-${username}`; + tile.className = "remote-media-tile"; + + const title = document.createElement("div"); + title.className = "remote-media-title"; + title.textContent = username; + + const video = document.createElement("video"); + video.autoplay = true; + video.playsInline = true; + + const status = document.createElement("div"); + status.className = "remote-media-status"; + status.textContent = "Remote media: waiting..."; + + tile.appendChild(title); + tile.appendChild(video); + tile.appendChild(status); + + container.appendChild(tile); + + return tile; + }, + + removeRemoteStream(username) { + delete remoteStreams[username]; + + const tile = document.getElementById(`remote-tile-${username}`); + if (tile) { + tile.remove(); + } + }, + + wireDeviceSelectors() { + const cameraSelect = document.getElementById("cameraSelect"); + const micSelect = document.getElementById("micSelect"); + + if (cameraSelect) { + cameraSelect.addEventListener("change", async () => { + LogMessage("Camera changed"); + await this.refreshDevicesAndPreview(); + }); + } + + if (micSelect) { + micSelect.addEventListener("change", async () => { + LogMessage("Microphone changed"); + await this.refreshDevicesAndPreview(); + }); + } + } +}; + +window.Media = Media; \ No newline at end of file diff --git a/RelayClient/Resources/Raw/wwwroot/relaySocket.js b/RelayClient/Resources/Raw/wwwroot/relaySocket.js new file mode 100644 index 0000000..2af41d4 --- /dev/null +++ b/RelayClient/Resources/Raw/wwwroot/relaySocket.js @@ -0,0 +1,42 @@ +const RelaySocket = { + async joinRtcChannel() { + await window.HybridWebView.InvokeDotNet("JoinRtcChannel"); + }, + + async leaveRtcChannel() { + await window.HybridWebView.InvokeDotNet("LeaveRtcChannel"); + }, + + async getRtcParticipants() { + const raw = await window.HybridWebView.InvokeDotNet("GetRtcParticipants"); + + if (!raw) return []; + + return typeof raw === "string" + ? JSON.parse(raw) + : raw; + }, + + async sendRtcSignal(signal) { + if (!signal.channelId) signal.channelId = currentChannelId; + if (!signal.from) signal.from = currentUsername; + + await window.HybridWebView.InvokeDotNet("SendRtcSignal", [ + JSON.stringify(signal) + ]); + }, + + receiveRtcSignal(rawJson) { + if (window.RelayRtc?.handleRtcSignal) { + return window.RelayRtc.handleRtcSignal(rawJson); + } + + if (typeof window.handleRtcSignal === "function") { + return window.handleRtcSignal(rawJson); + } + + LogMessage("No RTC signal handler registered."); + } +}; + +window.RelaySocket = RelaySocket; \ No newline at end of file diff --git a/RelayClient/Resources/Raw/wwwroot/rtc.js b/RelayClient/Resources/Raw/wwwroot/rtc.js new file mode 100644 index 0000000..2355872 --- /dev/null +++ b/RelayClient/Resources/Raw/wwwroot/rtc.js @@ -0,0 +1,224 @@ +const peerConnections = {}; + +async function joinChannelCall() { + LogMessage("Current username: " + currentUsername); + LogMessage("Current channel: " + currentChannelId); + + if (!currentUsername || !currentChannelId) { + LogMessage("Cannot join RTC: missing username or channel."); + return; + } + + await RelaySocket.joinRtcChannel(); + await Media.ensureLocalMedia(); + + const participants = await RelaySocket.getRtcParticipants(); + + LogMessage("Participants: " + JSON.stringify(participants)); + + const existingUsers = participants.filter(x => x !== currentUsername); + + if (existingUsers.length === 0) { + LogMessage("Joined call as first participant. Waiting for others..."); + return; + } + + for (const username of existingUsers) { + await sendOffer(username); + } +} + +async function sendOffer(username) { + const pc = await ensurePeerConnectionForUser(username); + + await Media.applyLocalStreamToPeerConnection(pc, username); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + await RelaySocket.sendRtcSignal({ + type: "rtc_offer", + channelId: currentChannelId, + from: currentUsername, + to: username, + sdp: offer.sdp + }); + + LogMessage(`Sent offer to ${username}`); +} + +async function handleRtcSignal(rawJson) { + try { + const msg = typeof rawJson === "string" ? JSON.parse(rawJson) : rawJson; + + if (!msg || !msg.type) return; + if (msg.from === currentUsername) return; + + if (msg.to && msg.to !== currentUsername) { + LogMessage(`Ignoring RTC signal meant for ${msg.to}`); + return; + } + + LogMessage(`Received signal: ${msg.type} from ${msg.from}`); + + if (msg.type === "rtc_offer") { + await handleOffer(msg); + return; + } + + if (msg.type === "rtc_answer") { + await handleAnswer(msg); + return; + } + + if (msg.type === "rtc_ice") { + await handleIce(msg); + return; + } + + if (msg.type === "rtc_leave") { + closePeerConnection(msg.from); + return; + } + + LogMessage("Unhandled RTC signal type: " + msg.type); + } catch (err) { + LogMessage("handleRtcSignal failed: " + err); + } +} + +async function handleOffer(msg) { + const pc = await ensurePeerConnectionForUser(msg.from); + + await Media.ensureLocalMedia(); + await Media.applyLocalStreamToPeerConnection(pc, msg.from); + + await pc.setRemoteDescription({ + type: "offer", + sdp: msg.sdp + }); + + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + await RelaySocket.sendRtcSignal({ + type: "rtc_answer", + channelId: currentChannelId, + from: currentUsername, + to: msg.from, + sdp: answer.sdp + }); + + LogMessage(`Sent answer to ${msg.from}`); +} + +async function handleAnswer(msg) { + const pc = peerConnections[msg.from]; + + if (!pc) { + LogMessage(`No peer connection found for answer from ${msg.from}`); + return; + } + + await pc.setRemoteDescription({ + type: "answer", + sdp: msg.sdp + }); + + LogMessage(`Applied answer from ${msg.from}`); +} + +async function handleIce(msg) { + const pc = peerConnections[msg.from]; + + if (!pc) { + LogMessage(`No peer connection found for ICE from ${msg.from}`); + return; + } + + if (!msg.candidate) return; + + await pc.addIceCandidate(msg.candidate); + + LogMessage(`Applied ICE from ${msg.from}`); +} + +async function ensurePeerConnectionForUser(username) { + if (peerConnections[username]) { + return peerConnections[username]; + } + + const pc = new RTCPeerConnection(configuration); + peerConnections[username] = pc; + + pc.onicecandidate = async event => { + if (!event.candidate) return; + + await RelaySocket.sendRtcSignal({ + type: "rtc_ice", + channelId: currentChannelId, + from: currentUsername, + to: username, + candidate: event.candidate + }); + }; + + pc.ontrack = event => { + LogMessage(`Remote track received from ${username}`); + + const stream = event.streams[0]; + if (!stream) return; + + Media.attachRemoteStream(username, stream); + }; + + pc.onconnectionstatechange = () => { + LogMessage(`Connection ${username}: ${pc.connectionState}`); + + if ( + pc.connectionState === "failed" || + pc.connectionState === "closed" || + pc.connectionState === "disconnected" + ) { + closePeerConnection(username); + } + }; + + return pc; +} + +async function leaveChannelCall() { + await RelaySocket.sendRtcSignal({ + type: "rtc_leave", + channelId: currentChannelId, + from: currentUsername + }); + + for (const username of Object.keys(peerConnections)) { + closePeerConnection(username); + } + + await RelaySocket.leaveRtcChannel(); + + LogMessage("Left RTC channel"); +} + +function closePeerConnection(username) { + const pc = peerConnections[username]; + if (!pc) return; + + pc.close(); + delete peerConnections[username]; + + Media.removeRemoteStream(username); + + LogMessage(`Closed RTC connection with ${username}`); +} + +window.RelayRtc = { + joinChannelCall, + leaveChannelCall, + handleRtcSignal +}; + +window.handleRtcSignal = handleRtcSignal; \ No newline at end of file