diff --git a/RelayClient/MainPage.xaml.cs b/RelayClient/MainPage.xaml.cs index 94530a4..0d96f92 100644 --- a/RelayClient/MainPage.xaml.cs +++ b/RelayClient/MainPage.xaml.cs @@ -115,11 +115,9 @@ public partial class MainPage : ContentPage using var doc = JsonDocument.Parse(e.Data); var root = doc.RootElement; - if (!root.TryGetProperty("Type", out var typeElement)) + if (!TryReadSignalType(root, out var type)) return; - var type = (SignalType) typeElement.GetInt32(); - if (type == SignalType.ChannelList) { var channelList = JsonSerializer.Deserialize(e.Data); @@ -218,25 +216,46 @@ public partial class MainPage : ContentPage { case SignalType.OfferUpdated : { - var offer = await GetRtcOffer(); - await SendRtcSignalToJsAsync(offer); + if (!string.Equals(rtcNotification.TargetUsername, _username, StringComparison.OrdinalIgnoreCase)) + break; + + if (string.IsNullOrWhiteSpace(_currentChannelId) || string.IsNullOrWhiteSpace(rtcNotification.Username)) + break; + + var offer = await ServerAPI.GetOfferForChannelAsync(_currentChannelId, rtcNotification.Username, _username); + if (offer is not null) + { + await SendRtcOfferToJsAsync(rtcNotification.Username, offer); + } break; } case SignalType.AnswerUpdated: { - var answer = await ServerAPI.GetAnswerForChannelAsync(_currentChannelId); + if (!string.Equals(rtcNotification.TargetUsername, _username, StringComparison.OrdinalIgnoreCase)) + break; + + if (string.IsNullOrWhiteSpace(_currentChannelId) || string.IsNullOrWhiteSpace(rtcNotification.Username)) + break; + + var answer = await ServerAPI.GetAnswerForChannelAsync(_currentChannelId, rtcNotification.Username, _username); if (answer is not null) { - await AnswerCallback(answer); + await SendRtcAnswerToJsAsync(rtcNotification.Username, answer); } break; } case SignalType.CandidateAdded: { + if (!string.Equals(rtcNotification.TargetUsername, _username, StringComparison.OrdinalIgnoreCase)) + break; + try { IceCandidate? iceCandidate = JsonSerializer.Deserialize(rtcNotification.Direction); - IceCandidateCallback(iceCandidate); + if (iceCandidate is not null && !string.IsNullOrWhiteSpace(rtcNotification.Username)) + { + await SendRtcCandidateToJsAsync(rtcNotification.Username, iceCandidate); + } } catch (Exception ex) { @@ -248,7 +267,10 @@ public partial class MainPage : ContentPage case SignalType.CallLeft: { SafeSendRawToWebView("RTC call left notification received."); - RtcLeaveCallback(); + if (!string.IsNullOrWhiteSpace(rtcNotification.Username)) + { + RtcLeaveCallback(rtcNotification.Username); + } break; } } @@ -264,7 +286,7 @@ public partial class MainPage : ContentPage if (pyload is null) return; - if (pyload.RecipientUsername == _username) + if (!string.Equals(pyload.RecipientUsername, _username, StringComparison.OrdinalIgnoreCase)) return; Console.WriteLine($"[{_username}] received encrypted payload for {pyload.RecipientUsername}"); @@ -417,20 +439,24 @@ public partial class MainPage : ContentPage } #region RTC Functions - public async Task JoinRtcChannel() + public async Task JoinRtcChannel() { if (string.IsNullOrWhiteSpace(_currentChannelId)) - return false; + return "[]"; _wsc.Send($"RTC_JOIN_CHANNEL|{_username}|{_currentChannelId}"); SafeSendRawToWebView($"Attempting to join RTC Channel {_currentChannelName} | {_currentChannelId} "); - bool active = await ServerAPI.GetIsChannelActiveAsync(_currentChannelId); + var participants = await ServerAPI.GetParticipantsForChannelAsync(_currentChannelId); + var otherParticipants = participants + .Where(x => !string.Equals(x, _username, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); - SafeSendRawToWebView($"Rtc Channel {_currentChannelName} | {_currentChannelId} is active: {active}"); + SafeSendRawToWebView($"RTC participants in {_currentChannelName}: {string.Join(", ", otherParticipants)}"); - return active; + return JsonSerializer.Serialize(otherParticipants); } public void LeaveRtcChannel() @@ -441,17 +467,14 @@ public partial class MainPage : ContentPage _wsc.Send($"RTC_LEAVE_CHANNEL|{_username}|{_currentChannelId}"); } - public async void WriteRtcOffer(string json) + public async Task WriteRtcOffer(string json) { try { - RtcDescription? description = JsonSerializer.Deserialize(json); - DBOffer offer = new DBOffer - { - ChannelId = _currentChannelId, - Username = _username, - SessionDescription = description - }; + RtcOffer? offer = JsonSerializer.Deserialize(json); + if (offer is null) + return; + await ServerAPI.PostOfferAsync(offer); } catch (Exception ex) @@ -460,25 +483,15 @@ public partial class MainPage : ContentPage } } - public async Task GetRtcOffer() - { - RtcDescription? offer = await ServerAPI.GetOffersForChannelAsync(_currentChannelId); - return JsonSerializer.Serialize(offer); - } - public async void WriteRtcAnswer(string json) + public async Task WriteRtcAnswer(string json) { - // SafeSendRawToWebView("WriteRtcAnswer entered with: " + json); - try { - RtcDescription? description = JsonSerializer.Deserialize(json); - DBOffer answer = new DBOffer - { - ChannelId = _currentChannelId, - Username = _username, - SessionDescription = description - }; + RtcAnswer? answer = JsonSerializer.Deserialize(json); + if (answer is null) + return; + await ServerAPI.PostAnswerAsync(answer); SafeSendRawToWebView("WriteRtcAnswer posted successfully"); } @@ -488,19 +501,15 @@ public partial class MainPage : ContentPage } } - public async void WriteIceCandidate(string json) + public async Task WriteIceCandidate(string json) { try { - IceCandidate? candidate = JsonSerializer.Deserialize(json); - DBIceCandidate DBCandidate = new DBIceCandidate - { - ChannelId = _currentChannelId, - Username = _username, - Candidate = candidate - }; - if (candidate == null) return; - await ServerAPI.PostIceCandidateAsync(DBCandidate); + DBIceCandidate? dbCandidate = JsonSerializer.Deserialize(json); + if (dbCandidate is null) + return; + + await ServerAPI.PostIceCandidateAsync(dbCandidate); } catch (Exception ex) { @@ -508,47 +517,45 @@ public partial class MainPage : ContentPage } } - public async void IceCandidateCallback(IceCandidate candidate) + private async Task SendRtcOfferToJsAsync(string remoteUsername, RtcSessionDescription offer) { - try - { - await hybridWebView.InvokeJavaScriptAsync("IceCandidateAdded", [candidate], [HybridJSType.Default.IceCandidate]); - } - catch (Exception ex) - { - SafeSendRawToWebView("WriteIceCandidate failed: " + ex.Message); - } - } - public async Task AnswerCallback(RtcDescription answer) - { - answer.sdp = answer.sdp.Replace("\r\n", "(rn)"); - try - { - await hybridWebView.InvokeJavaScriptAsync("AnswerCallbackJS", [answer], [HybridJSType.Default.RtcDescription]); - } - catch (Exception ex) - { - SafeSendRawToWebView("AnswerCallback failed: " + ex.Message); - } + var remoteUsernameJson = JsonSerializer.Serialize(remoteUsername); + var offerJson = JsonSerializer.Serialize(offer); + await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcOffer({remoteUsernameJson}, {offerJson})"); } - public async void RtcLeaveCallback() + private async Task SendRtcAnswerToJsAsync(string remoteUsername, RtcSessionDescription answer) + { + var remoteUsernameJson = JsonSerializer.Serialize(remoteUsername); + var answerJson = JsonSerializer.Serialize(answer); + await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcAnswer({remoteUsernameJson}, {answerJson})"); + } + + private async Task SendRtcCandidateToJsAsync(string remoteUsername, IceCandidate candidate) + { + var remoteUsernameJson = JsonSerializer.Serialize(remoteUsername); + var candidateJson = JsonSerializer.Serialize(candidate); + await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcCandidate({remoteUsernameJson}, {candidateJson})"); + } + + public async void RtcLeaveCallback(string username) { try { - await hybridWebView.InvokeJavaScriptAsync("RtcLeaveCall", [], []); + var usernameJson = JsonSerializer.Serialize(username); + await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcParticipantLeft({usernameJson})"); } catch (Exception ex) { SafeSendRawToWebView("RtcLeaveCallback failed: " + ex.Message); } } - + private async Task SendRtcSignalToJsAsync(string rawJson) { var jsArg = JsonSerializer.Serialize(rawJson); - await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcSignal({jsArg})"); - } //Remove? + await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcSignal?.({jsArg})"); + } private async Task PushRtcContextToJsAsync() { @@ -634,15 +641,50 @@ public partial class MainPage : ContentPage } [JsonSourceGenerationOptions(WriteIndented = false)] - [JsonSerializable(typeof(RtcDescription))] - [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(RtcSessionDescription))] [JsonSerializable(typeof(IceCandidate))] - [JsonSerializable(typeof(List))] [JsonSerializable(typeof(string))] internal partial class HybridJSType : JsonSerializerContext { // This type's attributes specify JSON serialization info to preserve type structure // for trimmed builds. } - -} \ No newline at end of file + + private static bool TryReadSignalType(JsonElement root, out SignalType type) + { + if (TryGetProperty(root, "type", out var typeElement)) + { + if (typeElement.ValueKind == JsonValueKind.String && + Enum.TryParse(typeElement.GetString(), true, out SignalType parsedType)) + { + type = parsedType; + return true; + } + + if (typeElement.ValueKind == JsonValueKind.Number && + typeElement.TryGetInt32(out var rawValue)) + { + type = (SignalType)rawValue; + return true; + } + } + + type = default; + return false; + } + + private static bool TryGetProperty(JsonElement root, string propertyName, out JsonElement value) + { + foreach (var property in root.EnumerateObject()) + { + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + + value = default; + return false; + } +} diff --git a/RelayClient/Resources/Raw/wwwroot/index.html b/RelayClient/Resources/Raw/wwwroot/index.html index af1cf57..9d71298 100644 --- a/RelayClient/Resources/Raw/wwwroot/index.html +++ b/RelayClient/Resources/Raw/wwwroot/index.html @@ -35,9 +35,7 @@
Waiting for local media...
-
- -
Remote video: waiting...
+
Remote media: waiting...
@@ -46,4 +44,4 @@ - \ No newline at end of file + diff --git a/RelayClient/Resources/Raw/wwwroot/index.js b/RelayClient/Resources/Raw/wwwroot/index.js index 8933478..d2bb1d0 100644 --- a/RelayClient/Resources/Raw/wwwroot/index.js +++ b/RelayClient/Resources/Raw/wwwroot/index.js @@ -1,102 +1,137 @@ -let peerConnection = null; -let localStream = null; let currentUsername = null; let currentChannelId = null; +let localStream = 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 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) { + +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.value += "\r\n" + msg; messageLog.scrollTop = messageLog.scrollHeight; } -function hasVideoTrack() { - return !!localStream && localStream.getVideoTracks().length > 0; -} +async function ensurePeerConnection(remoteUsername) { + if (peerConnections.has(remoteUsername)) { + return peerConnections.get(remoteUsername); + } -function hasAudioTrack() { - return !!localStream && localStream.getAudioTracks().length > 0; -} + const peer = new RTCPeerConnection(configuration); + peerConnections.set(remoteUsername, peer); + candidateQueues.set(remoteUsername, []); -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"); + 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)]); }; - peerConnection.ontrack = (event) => { - LogMessage("Remote track received"); - - const remoteVideo = document.getElementById("remoteVideo"); - const remoteVideoStatus = document.getElementById("remoteVideoStatus"); - const remoteMediaStatus = document.getElementById("remoteMediaStatus"); - + peer.ontrack = (event) => { const stream = event.streams[0]; - const hasVideo = stream.getVideoTracks().length > 0; - const hasAudio = stream.getAudioTracks().length > 0; + attachRemoteStream(remoteUsername, stream); + }; - 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}`; + peer.onconnectionstatechange = () => { + LogMessage(remoteUsername + " connection state: " + peer.connectionState); + if (peer.connectionState === "failed" || peer.connectionState === "closed" || peer.connectionState === "disconnected") { + closePeerConnection(remoteUsername); + removeRemoteTile(remoteUsername); } }; - peerConnection.onconnectionstatechange = () => { - LogMessage("Connection state: " + peerConnection.connectionState); - const remoteMediaStatus = document.getElementById("remoteMediaStatus"); - if (remoteMediaStatus && peerConnection.connectionState === "connected") { - remoteMediaStatus.textContent += " | connected"; + if (localStream) { + for (const track of localStream.getTracks()) { + peer.addTrack(track, localStream); } - }; + } - peerConnection.oniceconnectionstatechange = () => { - LogMessage("ICE connection state: " + peerConnection.iceConnectionState); - }; + 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)); + } +} - peerConnection.onicegatheringstatechange = () => { - LogMessage("ICE gathering state: " + peerConnection.iceGatheringState); - }; -} //Remove? async function ensureLocalMedia(forceReload = false) { const localMediaStatus = document.getElementById("localMediaStatus"); const localVideoStatus = document.getElementById("localVideoStatus"); @@ -113,353 +148,141 @@ async function ensureLocalMedia(forceReload = false) { localStream = null; } - let selectedCameraId = cameraSelect ? cameraSelect.value : ""; - let selectedMicId = micSelect ? micSelect.value : ""; + const selectedCameraId = cameraSelect ? cameraSelect.value : ""; + const selectedMicId = micSelect ? micSelect.value : ""; - const videoConstraint = selectedCameraId - ? { deviceId: { exact: selectedCameraId } } - : false; - - const audioConstraint = selectedMicId - ? { deviceId: { exact: selectedMicId } } - : true; + 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; - } + 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}`; - 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"); + 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); } } -async function applyLocalStreamToPeerConnection() { - if (!peerConnection || !localStream) return; +function attachRemoteStream(remoteUsername, stream) { + const remoteVideos = document.getElementById("remoteVideos"); + let tile = document.getElementById(`remote-${remoteUsername}`); - const senders = peerConnection.getSenders(); + if (!tile) { + tile = document.createElement("div"); + tile.id = `remote-${remoteUsername}`; + tile.style.display = "inline-block"; + tile.style.marginRight = "20px"; + tile.style.verticalAlign = "top"; - const audioTrack = localStream.getAudioTracks()[0] || null; - const videoTrack = localStream.getVideoTracks()[0] || null; + const title = document.createElement("div"); + title.textContent = remoteUsername; + title.style.marginBottom = "6px"; - const audioSender = senders.find(s => s.track && s.track.kind === "audio"); - const videoSender = senders.find(s => s.track && s.track.kind === "video"); + 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}`; - 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 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); } - 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"); + 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(); } } -async function refreshDevicesAndPreview() { - await loadDevices(); - await ensureLocalMedia(true); - - if (peerConnection) { - await applyLocalStreamToPeerConnection(); +function closePeerConnection(remoteUsername) { + const peer = peerConnections.get(remoteUsername); + if (peer) { + peer.close(); } + + peerConnections.delete(remoteUsername); + candidateQueues.delete(remoteUsername); } 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 + if (!currentUsername || !currentChannelId) { + LogMessage("RTC context is not ready yet."); + return; + } -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)]); - } - 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("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 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 = ""; @@ -483,7 +306,7 @@ async function loadDevices() { for (const mic of availableMics) { const option = document.createElement("option"); option.value = mic.deviceId; - option.text = mic.label || `Microphone ${micSelect.options.length + 1}`; + option.text = mic.label || `Microphone ${micSelect.options.length}`; micSelect.appendChild(option); } @@ -499,37 +322,21 @@ function wireDeviceSelectors() { 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; +async function refreshDevicesAndPreview() { + await loadDevices(); + await ensureLocalMedia(true); +} window.addEventListener("HybridWebViewMessageReceived", function (e) { LogMessage("Raw message: " + e.detail.message); @@ -541,4 +348,4 @@ window.addEventListener("load", async () => { await loadDevices(); wireDeviceSelectors(); await ensureLocalMedia(true); -}); \ No newline at end of file +}); diff --git a/RelayClient/ServerAPI.cs b/RelayClient/ServerAPI.cs index 41bd167..75e0a76 100644 --- a/RelayClient/ServerAPI.cs +++ b/RelayClient/ServerAPI.cs @@ -1,148 +1,74 @@ -using System.Net.Http.Headers; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; +using RelayShared.Rtc; namespace RelayClient; public class ServerAPI { - static HttpClient client = new HttpClient { BaseAddress = new Uri("http://localhost:5000/") }; - + private static readonly HttpClient client = new() + { + BaseAddress = new Uri("http://localhost:5000/") + }; + public static void setupClient() { client.DefaultRequestHeaders.Accept.Clear(); client.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/json")); } - - public static async Task PostOfferAsync(DBOffer offer) + + public static async Task PostOfferAsync(RtcOffer offer) { - HttpResponseMessage response = await client.PostAsJsonAsync( - "api/rtc/offer", offer); + var response = await client.PostAsJsonAsync("api/rtc/offer", offer); response.EnsureSuccessStatusCode(); - return response.Headers.Location; } - public static async Task GetAllOffersAsync() + public static async Task> GetParticipantsForChannelAsync(string channelId) { - HttpResponseMessage response = await client.GetAsync("api/rtc/offers"); + var response = await client.GetAsync($"api/rtc/participants/{channelId}"); response.EnsureSuccessStatusCode(); - return response.Headers.Location; + + var json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize>(json) ?? []; } - public static async Task GetIsChannelActiveAsync(string channelId) + public static async Task GetOfferForChannelAsync(string channelId, string fromUsername, string targetUsername) { - HttpResponseMessage response = await client.GetAsync($"api/rtc/active/{channelId}"); - response.EnsureSuccessStatusCode(); - return bool.Parse(response.Content.ReadAsStringAsync().Result); - } - - public static async Task GetOffersForChannelAsync(string channelId) - { - HttpResponseMessage response = await client.GetAsync($"api/rtc/offers/{channelId}"); - response.EnsureSuccessStatusCode(); - RtcDescription? offer = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); - return offer; - } - - public static async Task PostAnswerAsync(DBOffer answer) - { - HttpResponseMessage response = await client.PostAsJsonAsync("api/rtc/answer", answer); - var body = await response.Content.ReadAsStringAsync(); - - Console.WriteLine("PostAnswerAsync status: " + response.StatusCode); - Console.WriteLine("PostAnswerAsync body: " + body); - - response.EnsureSuccessStatusCode(); - return response.Headers.Location; - } - - public static async Task GetAnswersForChannelAsync(string channelId) - { - HttpResponseMessage response = await client.GetAsync($"api/rtc/answers/{channelId}"); - response.EnsureSuccessStatusCode(); - return response.Headers.Location; - } - - public static async Task GetLatestAnswerForChannelAsync(string channelId) - { - HttpResponseMessage response = await client.GetAsync($"api/rtc/latest/{channelId}"); - response.EnsureSuccessStatusCode(); - return response.Headers.Location; - } - - public static async Task PostIceCandidateAsync(DBIceCandidate candidate) - { - HttpResponseMessage response = await client.PostAsJsonAsync("api/rtc/candidate", candidate); - response.EnsureSuccessStatusCode(); - return response.Headers.Location; - } - - public static async Task GetIceCandidatesForChannelAsync(string channelId) - { - HttpResponseMessage response = await client.GetAsync($"api/rtc/candidates/{channelId}"); - response.EnsureSuccessStatusCode(); - return response.Headers.Location; - } - - public static async Task GetIceCandidatesForChannelByUserAsync(string channelId, string userId, string directions) - { - HttpResponseMessage response = await client.GetAsync($"api/rtc/candidates/{channelId}/{userId}/{directions}"); - response.EnsureSuccessStatusCode(); - return response.Headers.Location; - } - - public static async Task PostLeave(RtcLeave leave) - { - HttpResponseMessage response = await client.PostAsJsonAsync("api/rtc/leave", leave); - response.EnsureSuccessStatusCode(); - return response.Headers.Location; - } - - public static async Task GetAnswerForChannelAsync(string? channelId) - { - if (string.IsNullOrWhiteSpace(channelId)) - return null; - - HttpResponseMessage response = await client.GetAsync($"api/rtc/answer/{channelId}"); + var response = await client.GetAsync($"api/rtc/offer/{channelId}/{fromUsername}/{targetUsername}"); if (!response.IsSuccessStatusCode) return null; var json = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json); + } + + public static async Task PostAnswerAsync(RtcAnswer answer) + { + var response = await client.PostAsJsonAsync("api/rtc/answer", answer); + response.EnsureSuccessStatusCode(); + } + + public static async Task GetAnswerForChannelAsync(string channelId, string fromUsername, string targetUsername) + { + var response = await client.GetAsync($"api/rtc/answer/{channelId}/{fromUsername}/{targetUsername}"); + if (!response.IsSuccessStatusCode) + return null; + + var json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json); + } + + public static async Task PostIceCandidateAsync(DBIceCandidate candidate) + { + var response = await client.PostAsJsonAsync("api/rtc/candidate", candidate); + response.EnsureSuccessStatusCode(); + } + + public static async Task PostLeaveAsync(RtcLeaveRequest leave) + { + var response = await client.PostAsJsonAsync("api/rtc/leave", leave); + response.EnsureSuccessStatusCode(); } } - -public class RtcDescription -{ - public string type { get; set; } - public string sdp { get; set; } -} - -public class DBOffer -{ - public required string ChannelId { get; set; } - public required string Username { get; set; } - public required RtcDescription SessionDescription { get; set; } -} -public class IceCandidate -{ - public required string candidate { get; set; } - public required string sdpMid { get; set; } - public required int sdpMLineIndex { get; set; } - public required string usernameFragment { get; set; } - -} - -public class DBIceCandidate -{ - public required string ChannelId { get; set; } - public required string Username { get; set; } - public required IceCandidate Candidate { get; set; } -} - -public class RtcLeave -{ - public string ChannelId { get; set; } - public string Username { get; set; } -} \ No newline at end of file diff --git a/RelayServer/Endpoints/RtcEndpoints.cs b/RelayServer/Endpoints/RtcEndpoints.cs index 04a1673..cfc0228 100644 --- a/RelayServer/Endpoints/RtcEndpoints.cs +++ b/RelayServer/Endpoints/RtcEndpoints.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using RelayShared.Rtc; using RelayServer.Services.Rtc; @@ -6,118 +6,108 @@ namespace RelayServer.Endpoints; public static class RtcEndpoints { - /// - /// Maps all RTC-related HTTP endpoints used for storing offers and answers, - /// writing ICE candidates, checking active calls, and leaving active calls. - /// - /// The web application to map endpoints onto. public static void MapRtcEndpoints(this WebApplication app) { - // Store or update the current SDP offer for a channel call. app.MapPost("/api/rtc/offer", async (RtcOffer request, RtcCallService rtcCallService) => { - await rtcCallService.WriteOfferAsync(request.ChannelId, request.Username, request.SessionDescription); + await rtcCallService.WriteOfferAsync( + request.ChannelId, + request.Username, + request.TargetUsername, + request.SessionDescription); RtcNotificationService.BroadcastToChannel(new RtcNotificationMessage { Type = SignalType.OfferUpdated, ChannelId = request.ChannelId, - Username = request.Username + Username = request.Username, + TargetUsername = request.TargetUsername }); return Results.Ok(); }); - // List all offers. app.MapGet("/api/rtc/offers", async (RtcCallService rtcCallService) => { return Results.Ok(await rtcCallService.GetOffersAsync()); }); - // Return whether the specified channel currently has an active call. app.MapGet("/api/rtc/active/{channelId}", async (string channelId, RtcCallService rtcCallService) => { return Results.Ok(await rtcCallService.HasActiveCallAsync(channelId)); }); - // Return the latest stored SDP offer for the specified channel. - app.MapGet("/api/rtc/offers/{channelId}", async (string channelId, RtcCallService rtcCallService) => + app.MapGet("/api/rtc/participants/{channelId}", async (string channelId, RtcCallService rtcCallService) => { - var offer = await rtcCallService.GetOfferAsync(channelId); + return Results.Ok(await rtcCallService.GetParticipantsAsync(channelId)); + }); + + app.MapGet("/api/rtc/offer/{channelId}/{fromUsername}/{targetUsername}", async ( + string channelId, + string fromUsername, + string targetUsername, + RtcCallService rtcCallService) => + { + var offer = await rtcCallService.GetOfferAsync(channelId, fromUsername, targetUsername); return offer is null ? Results.NotFound() : Results.Ok(offer); }); - // Store a new SDP answer for the specified channel call. - app.MapPost("/api/rtc/answer", async (RtcOffer request, RtcCallService rtcCallService) => + app.MapPost("/api/rtc/answer", async (RtcAnswer request, RtcCallService rtcCallService) => { - Console.WriteLine($"RTC answer received for channel {request.ChannelId} from {request.Username}"); - - await rtcCallService.WriteAnswerAsync(request.ChannelId, request.SessionDescription); - - Console.WriteLine($"Broadcasting rtc_answer_updated for {request.ChannelId}"); + await rtcCallService.WriteAnswerAsync( + request.ChannelId, + request.Username, + request.TargetUsername, + request.SessionDescription); RtcNotificationService.BroadcastToChannel(new RtcNotificationMessage { Type = SignalType.AnswerUpdated, - ChannelId = request.ChannelId + ChannelId = request.ChannelId, + Username = request.Username, + TargetUsername = request.TargetUsername }); return Results.Ok(); }); - // Return all answers stored for the specified channel. - app.MapGet("/api/rtc/answers/{channelId}", async (string channelId, RtcCallService rtcCallService) => + app.MapGet("/api/rtc/answer/{channelId}/{fromUsername}/{targetUsername}", async ( + string channelId, + string fromUsername, + string targetUsername, + RtcCallService rtcCallService) => { - return Results.Ok(await rtcCallService.GetAnswersAsync(channelId)); - }); - - // Return the latest answer stored for the specified channel. - app.MapGet("/api/rtc/answer/{channelId}", async (string channelId, RtcCallService rtcCallService) => - { - var answer = await rtcCallService.GetLatestAnswerAsync(channelId); + var answer = await rtcCallService.GetAnswerAsync(channelId, fromUsername, targetUsername); return answer is null ? Results.NotFound() : Results.Ok(answer); }); - // Store a new ICE candidate for the specified channel call. app.MapPost("/api/rtc/candidate", async (DBIceCandidate request, RtcCallService rtcCallService) => { await rtcCallService.WriteIceCandidateAsync( request.ChannelId, request.Username, + request.TargetUsername, request.Candidate.candidate, request.Candidate.sdpMid, - request.Candidate.sdpMLineIndex - ); + request.Candidate.sdpMLineIndex); RtcNotificationService.BroadcastToChannel(new RtcNotificationMessage { Type = SignalType.CandidateAdded, ChannelId = request.ChannelId, Username = request.Username, + TargetUsername = request.TargetUsername, Direction = JsonSerializer.Serialize(request.Candidate) }); return Results.Ok(); }); - // Return all ICE candidates stored for the specified channel. app.MapGet("/api/rtc/candidates/{channelId}", async (string channelId, RtcCallService rtcCallService) => { return Results.Ok(await rtcCallService.GetIceCandidatesAsync(channelId)); }); - // Return ICE candidates for the specified channel that belong to other users - // and match the requested direction. - app.MapGet("/api/rtc/candidates/{channelId}/{username}/{direction}", async ( - string channelId, - string username, - string direction, - RtcCallService rtcCallService) => - { - return Results.Ok(await rtcCallService.GetIceCandidatesForOthersAsync(channelId, username, direction)); - }); - - // Leave the active call for the specified channel. app.MapPost("/api/rtc/leave", async (RtcLeaveRequest request, RtcCallService rtcCallService) => { await rtcCallService.LeaveCallAsync(request.ChannelId, request.Username); @@ -132,4 +122,4 @@ public static class RtcEndpoints return Results.Ok(); }); } -} \ No newline at end of file +} diff --git a/RelayServer/Services/Rtc/RtcCallService.cs b/RelayServer/Services/Rtc/RtcCallService.cs index ba36048..946c2e1 100644 --- a/RelayServer/Services/Rtc/RtcCallService.cs +++ b/RelayServer/Services/Rtc/RtcCallService.cs @@ -1,4 +1,4 @@ -using RelayShared.Rtc; +using RelayShared.Rtc; using SurrealDb.Net; namespace RelayServer.Services.Rtc; @@ -12,19 +12,12 @@ public sealed class RtcCallService _db = db; } - /// - /// Checks whether the specified channel currently has an active RTC call. - /// - /// The channel to inspect. - /// - /// True if the channel has an active call; otherwise, false. - /// public async Task HasActiveCallAsync(string channelId) { - var activeCalls = await _db.Select("rtc_active_calls"); - return activeCalls.Any(x => x.ChannelId == channelId && x.IsActive); + var activeCall = await GetActiveCallAsync(channelId); + return activeCall is not null && activeCall.IsActive; } - + public async Task GetActiveCallAsync(string channelId) { var activeCalls = await _db.Select("rtc_active_calls"); @@ -34,157 +27,70 @@ public sealed class RtcCallService .FirstOrDefault(); } - /// - /// Creates or updates the current SDP offer for a user in the specified channel. - /// If no active call exists for the channel, a new active call is created. - /// Otherwise, the existing active call timestamp is refreshed. - /// - /// The channel the offer belongs to. - /// The user creating the offer. - /// The RtcSession Type. - /// The SDP offer payload. - public async Task WriteOfferAsync(string channelId, string username, RtcSessionDescription sessionDescription) + public async Task> GetParticipantsAsync(string channelId) { - var activeCalls = await _db.Select("rtc_active_calls"); - var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive); + return RtcChannelPresenceService.GetUsersInChannel(channelId).ToList(); + } - if (activeCall is null) - { - await _db.Create("rtc_active_calls", new RtcActiveCall - { - ChannelId = channelId, - OfferUser = username, - Offer = new RtcSessionDescription - { - Type = sessionDescription.Type, - Sdp = sessionDescription.Sdp - }, - Answer = null, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - IsActive = true - }); + public async Task WriteOfferAsync(string channelId, string username, string targetUsername, RtcSessionDescription sessionDescription) + { + var activeCall = await EnsureActiveCallAsync(channelId); + var participant = GetOrCreateParticipant(activeCall, username); - return; - } - - activeCall.OfferUser = username; - activeCall.Offer = new RtcSessionDescription - { - Type = sessionDescription.Type, - Sdp = sessionDescription.Sdp - }; + participant.Offer = CloneSessionDescription(sessionDescription); + participant.Answer = null; activeCall.UpdatedAt = DateTime.UtcNow; - await _db.Merge(activeCall); + await SaveActiveCallAsync(activeCall); } - /// - /// Gets the current offer stored on the active call for the specified channel. - /// - /// The channel whose offer should be retrieved. - /// - /// The current offer for the active call, or null if no active call or offer exists. - /// - public async Task GetOfferAsync(string channelId) + public async Task GetOfferAsync(string channelId, string fromUsername, string targetUsername) { var activeCall = await GetActiveCallAsync(channelId); - return activeCall?.Offer; + return activeCall?.Participants + .FirstOrDefault(x => x.Username.Equals(fromUsername, StringComparison.OrdinalIgnoreCase)) + ?.Offer; } - /// - /// Writes a new SDP answer for the specified channel and refreshes the active call timestamp - /// when a matching active call exists. - /// - /// The channel the answer belongs to. - /// The original offer owner. - /// The SDP and type answer payload. - public async Task WriteAnswerAsync(string channelId, RtcSessionDescription sessionDescription) + public async Task WriteAnswerAsync(string channelId, string username, string targetUsername, RtcSessionDescription sessionDescription) { - var activeCalls = await _db.Select("rtc_active_calls"); - var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive); + var activeCall = await EnsureActiveCallAsync(channelId); + var participant = GetOrCreateParticipant(activeCall, username); - if (activeCall is null) - return; - - activeCall.Answer = new RtcSessionDescription - { - Type = sessionDescription.Type, - Sdp = sessionDescription.Sdp - }; + participant.Answer = CloneSessionDescription(sessionDescription); activeCall.UpdatedAt = DateTime.UtcNow; - await _db.Merge(activeCall); + await SaveActiveCallAsync(activeCall); } - /// - /// Gets all answers stored for the specified channel in creation order. - /// - /// The channel whose answers should be retrieved. - /// - /// A list of answers for the channel ordered from oldest to newest. - /// - public async Task> GetAnswersAsync(string channelId) + public async Task GetAnswerAsync(string channelId, string fromUsername, string targetUsername) { var activeCall = await GetActiveCallAsync(channelId); - - if (activeCall?.Answer is null) - return []; - - return [activeCall.Answer]; + return activeCall?.Participants + .FirstOrDefault(x => x.Username.Equals(fromUsername, StringComparison.OrdinalIgnoreCase)) + ?.Answer; } - /// - /// Gets the most recent answer stored for the specified channel. - /// - /// The channel whose latest answer should be retrieved. - /// - /// The newest answer for the channel, or null if no answer exists. - /// - public async Task GetLatestAnswerAsync(string channelId) - { - var activeCall = await GetActiveCallAsync(channelId); - return activeCall?.Answer; - } - - /// - /// Writes a new ICE candidate entry for the specified channel and user. - /// - /// The channel the ICE candidate belongs to. - /// The user who produced the ICE candidate. - /// The ICE candidate string. - /// The SDP media identifier for the candidate, if any. - /// The SDP media line index for the candidate, if any. - /// - /// The signaling direction the candidate belongs to, such as offer or answer. - /// public async Task WriteIceCandidateAsync( string channelId, string username, + string targetUsername, string candidate, string? sdpMid, - int? sdpMLineIndex/*, - string direction*/) + int? sdpMLineIndex) { await _db.Create("rtc_ice_candidates", new RtcIceCandidate { ChannelId = channelId, Username = username, + TargetUsername = targetUsername, Candidate = candidate, SdpMid = sdpMid, SdpMLineIndex = sdpMLineIndex, - // Direction = direction, CreatedAt = DateTime.UtcNow }); } - /// - /// Gets all ICE candidates stored for the specified channel in creation order. - /// - /// The channel whose ICE candidates should be retrieved. - /// - /// A list of ICE candidates for the channel ordered from oldest to newest. - /// public async Task> GetIceCandidatesAsync(string channelId) { var candidates = await _db.Select("rtc_ice_candidates"); @@ -194,59 +100,78 @@ public sealed class RtcCallService .ToList(); } - /// - /// Gets ICE candidates for the specified channel that were created by other users - /// and match the requested signaling direction. - /// - /// The channel whose ICE candidates should be retrieved. - /// The user to exclude from the results. - /// The signaling direction to match. - /// - /// A list of matching ICE candidates ordered from oldest to newest. - /// - public async Task> GetIceCandidatesForOthersAsync(string channelId, string username, string direction) - { - var candidates = await _db.Select("rtc_ice_candidates"); - return candidates - .Where(x => x.ChannelId == channelId && x.Username != username /*&& x.Direction == direction*/) - .OrderBy(x => x.CreatedAt) - .ToList(); - } - - /// - /// Leaves the active call for the specified channel. In the current implementation, - /// the call is only marked inactive when the offer user leaves. - /// - /// The channel whose call should be left. - /// The user leaving the call. public async Task LeaveCallAsync(string channelId, string username) { - var activeCalls = await _db.Select("rtc_active_calls"); - var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive); - + var activeCall = await GetActiveCallAsync(channelId); if (activeCall is null) return; - if (activeCall.OfferUser == username) - { - activeCall.IsActive = false; - activeCall.UpdatedAt = DateTime.UtcNow; - await _db.Merge(activeCall); - } + activeCall.Participants.RemoveAll(x => x.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); + activeCall.IsActive = activeCall.Participants.Count > 0; + activeCall.UpdatedAt = DateTime.UtcNow; + + await SaveActiveCallAsync(activeCall); } - /// - /// Gets all active call records that currently contain an offer. - /// - /// - /// A list of active calls with offers, ordered from newest to oldest. - /// public async Task> GetOffersAsync() { var activeCalls = await _db.Select("rtc_active_calls"); return activeCalls - .Where(x => x.Offer is not null) + .Where(x => x.Participants.Any(p => p.Offer is not null)) .OrderByDescending(x => x.UpdatedAt) .ToList(); } -} \ No newline at end of file + + private async Task EnsureActiveCallAsync(string channelId) + { + var activeCall = await GetActiveCallAsync(channelId); + if (activeCall is not null) + return activeCall; + + return await _db.Create("rtc_active_calls", new RtcActiveCall + { + ChannelId = channelId, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + IsActive = true, + Participants = [] + }); + } + + private async Task SaveActiveCallAsync(RtcActiveCall activeCall) + { + if (activeCall.Id is null) + { + await _db.Create("rtc_active_calls", activeCall); + return; + } + + await _db.Merge(activeCall); + } + + private static RtcParticipantState GetOrCreateParticipant(RtcActiveCall activeCall, string username) + { + var participant = activeCall.Participants + .FirstOrDefault(x => x.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); + + if (participant is not null) + return participant; + + participant = new RtcParticipantState + { + Username = username + }; + + activeCall.Participants.Add(participant); + return participant; + } + + private static RtcSessionDescription CloneSessionDescription(RtcSessionDescription sessionDescription) + { + return new RtcSessionDescription + { + Type = sessionDescription.Type, + Sdp = sessionDescription.Sdp + }; + } +} diff --git a/RelayShared/Rtc/RtcModels.cs b/RelayShared/Rtc/RtcModels.cs index a3ad885..73ba3d7 100644 --- a/RelayShared/Rtc/RtcModels.cs +++ b/RelayShared/Rtc/RtcModels.cs @@ -1,7 +1,9 @@ -using SurrealDb.Net.Models; +using System.Text.Json.Serialization; +using SurrealDb.Net.Models; namespace RelayShared.Rtc; +[JsonConverter(typeof(JsonStringEnumConverter))] public enum SignalType { Offer, @@ -20,141 +22,276 @@ public enum SignalType public sealed class RtcSignalMessage { + [JsonPropertyName("type")] public SignalType Type { get; set; } + + [JsonPropertyName("from")] public string From { 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; } } public sealed class RtcNotificationMessage { + [JsonPropertyName("type")] public SignalType? Type { get; set; } + + [JsonPropertyName("channelId")] public string? ChannelId { get; set; } + + [JsonPropertyName("username")] public string? Username { get; set; } + + [JsonPropertyName("targetUsername")] + public string? TargetUsername { get; set; } + + [JsonPropertyName("direction")] public string? Direction { get; set; } } public sealed class ServerPublicKeyMessage { + [JsonPropertyName("type")] public SignalType Type { get; set; } = SignalType.ServerPublicKey; + + [JsonPropertyName("publicKey")] public string PublicKey { get; set; } = string.Empty; } public sealed class SocketRtcSignalMessage { + [JsonPropertyName("type")] public SignalType Type { get; set; } + + [JsonPropertyName("senderUsername")] public string SenderUsername { get; set; } = string.Empty; + + [JsonPropertyName("channelId")] public string ChannelId { get; set; } = string.Empty; + + [JsonPropertyName("cipherText")] public string CipherText { get; set; } = string.Empty; + + [JsonPropertyName("nonce")] public string Nonce { get; set; } = string.Empty; + + [JsonPropertyName("tag")] public string Tag { get; set; } = string.Empty; + + [JsonPropertyName("encryptedKey")] public string EncryptedKey { get; set; } = string.Empty; } public sealed class SocketEncryptedMessage { + [JsonPropertyName("type")] public SignalType Type { get; set; } = SignalType.EncryptedChat; + + [JsonPropertyName("senderUsername")] public string SenderUsername { get; set; } = string.Empty; + + [JsonPropertyName("recipientUsername")] public string RecipientUsername { get; set; } = string.Empty; + + [JsonPropertyName("channelId")] public string ChannelId { get; set; } = string.Empty; + + [JsonPropertyName("cipherText")] public string CipherText { get; set; } = string.Empty; + + [JsonPropertyName("nonce")] public string Nonce { get; set; } = string.Empty; + + [JsonPropertyName("tag")] public string Tag { get; set; } = string.Empty; + + [JsonPropertyName("encryptedKey")] public string EncryptedKey { get; set; } = string.Empty; } public sealed class ChannelItem { + [JsonPropertyName("channelId")] public string ChannelId { get; set; } = string.Empty; + + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + + [JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; } } public sealed class SocketChannelList { + [JsonPropertyName("type")] public SignalType Type { get; set; } = SignalType.ChannelList; + + [JsonPropertyName("channels")] public List Channels { get; set; } = []; } public sealed class RtcJoinRequest { + [JsonPropertyName("channelId")] public string ChannelId { get; set; } = string.Empty; + + [JsonPropertyName("username")] public string Username { get; set; } = string.Empty; } public sealed class RtcJoinResponse { + [JsonPropertyName("channelId")] public string ChannelId { get; set; } = string.Empty; - public bool HasActiveCall { get; set; } - public bool IsOfferer { get; set; } - public string? OfferUser { get; set; } - public RtcSessionDescription? OfferSdp { get; set; } + + [JsonPropertyName("participants")] + public List Participants { get; set; } = []; } public sealed class RtcLeaveRequest { + [JsonPropertyName("channelId")] public string ChannelId { get; set; } = string.Empty; + + [JsonPropertyName("username")] public string Username { get; set; } = string.Empty; } public sealed class RtcSessionDescription { + [JsonPropertyName("type")] public SignalType Type { get; set; } + + [JsonPropertyName("sdp")] public string Sdp { get; set; } = string.Empty; } public sealed class RtcOffer { + [JsonPropertyName("channelId")] public string ChannelId { get; set; } = string.Empty; + + [JsonPropertyName("username")] public string Username { get; set; } = string.Empty; + + [JsonPropertyName("targetUsername")] + public string TargetUsername { get; set; } = string.Empty; + + [JsonPropertyName("sessionDescription")] public RtcSessionDescription SessionDescription { get; set; } = new(); } public sealed class RtcAnswer { + [JsonPropertyName("channelId")] public string ChannelId { get; set; } = string.Empty; + + [JsonPropertyName("username")] public string Username { get; set; } = string.Empty; + + [JsonPropertyName("targetUsername")] + public string TargetUsername { get; set; } = string.Empty; + + [JsonPropertyName("sessionDescription")] public RtcSessionDescription SessionDescription { get; set; } = new(); } +public sealed class RtcParticipantState +{ + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("offer")] + public RtcSessionDescription? Offer { get; set; } + + [JsonPropertyName("answer")] + public RtcSessionDescription? Answer { get; set; } +} + public class RtcIceCandidate : Record { + [JsonPropertyName("channelId")] public required string ChannelId { get; set; } + + [JsonPropertyName("username")] public required string Username { get; set; } + + [JsonPropertyName("targetUsername")] + public required string TargetUsername { get; set; } + + [JsonPropertyName("candidate")] public required string Candidate { get; set; } + + [JsonPropertyName("sdpMid")] public string? SdpMid { get; set; } + + [JsonPropertyName("sdpMLineIndex")] public int? SdpMLineIndex { get; set; } - // public required string Direction { get; set; } // "offer" or "answer" + + [JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; } } public class DBIceCandidate { + [JsonPropertyName("channelId")] public required string ChannelId { get; set; } + + [JsonPropertyName("username")] public required string Username { get; set; } + + [JsonPropertyName("targetUsername")] + public required string TargetUsername { get; set; } + + [JsonPropertyName("candidate")] public required IceCandidate Candidate { get; set; } } public class IceCandidate { + [JsonPropertyName("candidate")] public required string candidate { get; set; } + + [JsonPropertyName("sdpMid")] public required string sdpMid { get; set; } + + [JsonPropertyName("sdpMLineIndex")] public required int sdpMLineIndex { get; set; } + + [JsonPropertyName("usernameFragment")] public required string usernameFragment { get; set; } - } public sealed class RtcActiveCall : Record { + [JsonPropertyName("channelId")] public string ChannelId { get; set; } = string.Empty; - public string? OfferUser { get; set; } - public RtcSessionDescription? Offer { get; set; } - public RtcSessionDescription? Answer { get; set; } + + [JsonPropertyName("participants")] + public List Participants { get; set; } = []; + + [JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] public DateTime UpdatedAt { get; set; } + + [JsonPropertyName("isActive")] public bool IsActive { get; set; } -} \ No newline at end of file +}