From a2608ffab99cce1b58b7a32a6fa37b0b6201bb3f Mon Sep 17 00:00:00 2001 From: RuKira Date: Sat, 18 Apr 2026 18:05:22 -0400 Subject: [PATCH] This isn't FULLY functional, but it's what I've made thus far... I'm still working on a Ice Disconnect somewhere at least for me - welcome to test. --- RelayClient/MainPage.xaml.cs | 10 +- RelayClient/Resources/Raw/wwwroot/index.html | 6 +- RelayClient/Resources/Raw/wwwroot/index.js | 444 ++++++++---------- RelayClient/ServerAPI.cs | 13 + RelayServer/Endpoints/RtcEndpoints.cs | 9 +- .../Services/Chat/ChatSocketBehavior.cs | 63 +++ .../Services/Rtc/RtcChannelPresenceService.cs | 5 + RelayShared/Rtc/RtcModels.cs | 22 +- 8 files changed, 300 insertions(+), 272 deletions(-) diff --git a/RelayClient/MainPage.xaml.cs b/RelayClient/MainPage.xaml.cs index cb587e6..ae238b0 100644 --- a/RelayClient/MainPage.xaml.cs +++ b/RelayClient/MainPage.xaml.cs @@ -610,11 +610,11 @@ public partial class MainPage : ContentPage Console.WriteLine($"[{_username}] sent RTC signal: {rtcSignal.Type} -> {rtcSignal.ChannelId}"); } //Remove? - //public async Task GetRtcParticipants() // TODO: UNCOMMENT AND ADD - //{ - // var participants = await ServerAPI.GetRtcParticipantsAsync(_currentChannelId); - // return JsonSerializer.Serialize(participants); - //} + public async Task GetRtcParticipants() + { + var participants = await ServerAPI.GetRtcParticipantsAsync(_currentChannelId); + return JsonSerializer.Serialize(participants); + } #endregion private void OnSendMessageButtonClicked(object sender, EventArgs e) diff --git a/RelayClient/Resources/Raw/wwwroot/index.html b/RelayClient/Resources/Raw/wwwroot/index.html index af1cf57..e6237f7 100644 --- a/RelayClient/Resources/Raw/wwwroot/index.html +++ b/RelayClient/Resources/Raw/wwwroot/index.html @@ -35,11 +35,7 @@
Waiting for local media...
-
- -
Remote video: waiting...
-
Remote media: waiting...
-
+
diff --git a/RelayClient/Resources/Raw/wwwroot/index.js b/RelayClient/Resources/Raw/wwwroot/index.js index bbcc65f..dcdb70d 100644 --- a/RelayClient/Resources/Raw/wwwroot/index.js +++ b/RelayClient/Resources/Raw/wwwroot/index.js @@ -42,63 +42,6 @@ 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"); @@ -174,31 +117,34 @@ async function ensureLocalMedia(forceReload = false) { } } -async function applyLocalStreamToPeerConnection() { - if (!peerConnection || !localStream) return; - - const senders = peerConnection.getSenders(); +async function applyLocalStreamToPeerConnections() { + if (!localStream) return; 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"); + for (const username of Object.keys(peerConnections)) { + const pc = peerConnections[username]; + const senders = pc.getSenders(); - 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"); - } + const audioSender = senders.find(s => s.track && s.track.kind === "audio"); + const videoSender = senders.find(s => s.track && s.track.kind === "video"); - 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"); + 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}`); + } } } @@ -206,238 +152,188 @@ async function refreshDevicesAndPreview() { await loadDevices(); await ensureLocalMedia(true); - if (peerConnection) { - await applyLocalStreamToPeerConnection(); + if (Object.keys(peerConnections).length > 0) { + await applyLocalStreamToPeerConnections(); } } 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 ensurePeerConnectionForUser(username) -{ - 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 ensurePeerConnectionForUser(); + await window.HybridWebView.InvokeDotNet("JoinRtcChannel"); 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)]); - } - 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)}`); + 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}`); } } -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 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); + } } } -} -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); - - } + + 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 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.from || msg.from === currentUsername) return; - 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; - } + const pc = await ensurePeerConnectionForUser(msg.from); 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({ + await pc.setRemoteDescription({ type: "offer", sdp: msg.sdp }); - const answer = await peerConnection.createAnswer(); - await peerConnection.setLocalDescription(answer); - // await waitForIceGatheringComplete(peerConnection); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); const payload = { type: "rtc_answer", from: currentUsername, + to: msg.from, channelId: msg.channelId, - sdp: peerConnection.localDescription.sdp + sdp: answer.sdp }; - LogMessage("Sending answer to channel " + msg.channelId); await window.HybridWebView.InvokeDotNet("SendRtcSignal", [JSON.stringify(payload)]); + LogMessage(`Sent answer to ${msg.from}`); return; } if (msg.type === "rtc_answer") { - LogMessage("Applying remote answer"); - - await peerConnection.setRemoteDescription({ + await pc.setRemoteDescription({ type: "answer", sdp: msg.sdp }); - LogMessage("Remote answer applied"); + LogMessage(`Remote answer applied for ${msg.from}`); return; } if (msg.type === "rtc_ice_candidate") { - LogMessage("Applying remote ICE candidate"); - - await peerConnection.addIceCandidate({ + await pc.addIceCandidate({ candidate: msg.candidate, sdpMid: msg.sdpMid, sdpMLineIndex: msg.sdpMLineIndex }); - LogMessage("Remote ICE candidate applied"); + LogMessage(`Remote ICE candidate applied for ${msg.from}`); return; } @@ -445,7 +341,7 @@ async function handleRtcSignal(rawJson) { } catch (err) { LogMessage("handleRtcSignal failed: " + err); } -} //Remove? +} async function loadDevices() { try { @@ -503,7 +399,7 @@ function wireDeviceSelectors() { cameraSelect.onchange = async () => { LogMessage("Camera changed"); await ensureLocalMedia(true); - await applyLocalStreamToPeerConnection(); + await applyLocalStreamToPeerConnections(); }; } @@ -511,7 +407,7 @@ function wireDeviceSelectors() { micSelect.onchange = async () => { LogMessage("Microphone changed"); await ensureLocalMedia(true); - await applyLocalStreamToPeerConnection(); + await applyLocalStreamToPeerConnections(); }; } } @@ -531,6 +427,42 @@ async function waitForIceGatheringComplete(pc) { }); } //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) { diff --git a/RelayClient/ServerAPI.cs b/RelayClient/ServerAPI.cs index 41bd167..3396c4e 100644 --- a/RelayClient/ServerAPI.cs +++ b/RelayClient/ServerAPI.cs @@ -111,6 +111,19 @@ public class ServerAPI var json = await response.Content.ReadAsStringAsync(); return JsonSerializer.Deserialize(json); } + + public static async Task> GetRtcParticipantsAsync(string? channelId) + { + if (string.IsNullOrWhiteSpace(channelId)) + return new List(); + + HttpResponseMessage response = await client.GetAsync($"api/rtc/participants/{channelId}"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + + return JsonSerializer.Deserialize>(json) ?? new List(); + } } public class RtcDescription diff --git a/RelayServer/Endpoints/RtcEndpoints.cs b/RelayServer/Endpoints/RtcEndpoints.cs index ce01327..cebccac 100644 --- a/RelayServer/Endpoints/RtcEndpoints.cs +++ b/RelayServer/Endpoints/RtcEndpoints.cs @@ -71,11 +71,10 @@ public static class RtcEndpoints return Results.Ok(await rtcCallService.GetAnswersAsync(channelId)); }); - //app.MapGet("/api/rtc/participants/{channelId}", (string channelId) => // TODO: UNCOMMENT AND ADD - //{ - // var participants = RtcChannelPresenceService.GetUsernamesInChannel(channelId); - // return Results.Ok(participants); - //}); + app.MapGet("/api/rtc/participants/{channelId}", (string channelId) => + { + return Results.Ok(RtcChannelPresenceService.GetUsersInChannel(channelId)); + }); // Return the latest answer stored for the specified channel. app.MapGet("/api/rtc/answer/{channelId}", async (string channelId, RtcCallService rtcCallService) => diff --git a/RelayServer/Services/Chat/ChatSocketBehavior.cs b/RelayServer/Services/Chat/ChatSocketBehavior.cs index c29821b..becc9d2 100644 --- a/RelayServer/Services/Chat/ChatSocketBehavior.cs +++ b/RelayServer/Services/Chat/ChatSocketBehavior.cs @@ -69,9 +69,72 @@ public class ChatSocketBehavior : WebSocketBehavior return; } + if (IsEncryptedRtcSignal(msg)) + { + HandleEncryptedRtcSignal(msg); + return; + } + HandleEncryptedChatMessage(msg); } + private static bool IsEncryptedRtcSignal(string msg) + { + try + { + using var doc = JsonDocument.Parse(msg); + var root = doc.RootElement; + + if (!root.TryGetProperty("Type", out var typeProp)) + return false; + + var type = (SignalType)typeProp.GetInt32(); + + return type == SignalType.EncryptedSignal; + } + catch + { + return false; + } + } + + private void HandleEncryptedRtcSignal(string msg) + { + Console.WriteLine("RTC SIGNAL HIT"); + SocketRtcSignalMessage? clientPayload; + + try + { + clientPayload = JsonSerializer.Deserialize(msg); + } + catch + { + Console.WriteLine("Failed to parse encrypted RTC signal payload."); + return; + } + + if (clientPayload is null || clientPayload.Type != SignalType.EncryptedSignal) + return; + + if (string.IsNullOrWhiteSpace(clientPayload.ChannelId)) + { + Console.WriteLine("Encrypted RTC signal missing channel id."); + return; + } + + var sessionIds = RtcChannelPresenceService.GetSessionsInChannel(clientPayload.ChannelId); + + foreach (var sessionId in sessionIds) + { + if (sessionId == ID) + continue; + + Sessions.SendTo(JsonSerializer.Serialize(clientPayload), sessionId); + } + + Console.WriteLine($"Forwarded encrypted RTC signal from {clientPayload.SenderUsername} to channel {clientPayload.ChannelId}"); + } + /// /// /// diff --git a/RelayServer/Services/Rtc/RtcChannelPresenceService.cs b/RelayServer/Services/Rtc/RtcChannelPresenceService.cs index 581f5b7..1786e17 100644 --- a/RelayServer/Services/Rtc/RtcChannelPresenceService.cs +++ b/RelayServer/Services/Rtc/RtcChannelPresenceService.cs @@ -35,6 +35,11 @@ public static class RtcChannelPresenceService .Select(x => x.Key) .ToList(); } + + public static List GetUsernamesInChannel(string channelId) + { + return GetUsersInChannel(channelId).ToList(); + } public static IReadOnlyList GetUsersInChannel(string channelId) { diff --git a/RelayShared/Rtc/RtcModels.cs b/RelayShared/Rtc/RtcModels.cs index 63f96eb..eb52cc4 100644 --- a/RelayShared/Rtc/RtcModels.cs +++ b/RelayShared/Rtc/RtcModels.cs @@ -1,4 +1,6 @@ -using SurrealDb.Net.Models; +using System.Text.Json.Serialization; +using RelayShared.Rtc; +using SurrealDb.Net.Models; #region Resharper Stuff // ReSharper disable ClassNeverInstantiated.Global @@ -26,13 +28,31 @@ public enum SignalType public sealed class RtcSignalMessage { + [JsonPropertyName("type")] public SignalType Type { get; set; } + + [JsonPropertyName("from")] public string From { get; set; } = string.Empty; + + [JsonPropertyName("to")] + public string To { get; set; } = string.Empty; + + [JsonPropertyName("channelId")] public string ChannelId { get; set; } = string.Empty; + + [JsonPropertyName("sdp")] public string? Sdp { get; set; } + + [JsonPropertyName("candidate")] public string? Candidate { get; set; } + + [JsonPropertyName("sdpMid")] public string? SdpMid { get; set; } + + [JsonPropertyName("sdpMLineIndex")] public int? SdpMLineIndex { get; set; } + + [JsonPropertyName("isInitiator")] public bool IsInitiator { get; set; } }