Messaging works, let's start there.

This commit is contained in:
2026-04-13 16:12:02 -04:00
parent 085507519a
commit 0375693970
7 changed files with 645 additions and 820 deletions

View File

@@ -115,11 +115,9 @@ public partial class MainPage : ContentPage
using var doc = JsonDocument.Parse(e.Data); using var doc = JsonDocument.Parse(e.Data);
var root = doc.RootElement; var root = doc.RootElement;
if (!root.TryGetProperty("Type", out var typeElement)) if (!TryReadSignalType(root, out var type))
return; return;
var type = (SignalType) typeElement.GetInt32();
if (type == SignalType.ChannelList) if (type == SignalType.ChannelList)
{ {
var channelList = JsonSerializer.Deserialize<SocketChannelList>(e.Data); var channelList = JsonSerializer.Deserialize<SocketChannelList>(e.Data);
@@ -218,25 +216,46 @@ public partial class MainPage : ContentPage
{ {
case SignalType.OfferUpdated : case SignalType.OfferUpdated :
{ {
var offer = await GetRtcOffer(); if (!string.Equals(rtcNotification.TargetUsername, _username, StringComparison.OrdinalIgnoreCase))
await SendRtcSignalToJsAsync(offer); 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; break;
} }
case SignalType.AnswerUpdated: 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) if (answer is not null)
{ {
await AnswerCallback(answer); await SendRtcAnswerToJsAsync(rtcNotification.Username, answer);
} }
break; break;
} }
case SignalType.CandidateAdded: case SignalType.CandidateAdded:
{ {
if (!string.Equals(rtcNotification.TargetUsername, _username, StringComparison.OrdinalIgnoreCase))
break;
try try
{ {
IceCandidate? iceCandidate = JsonSerializer.Deserialize<IceCandidate>(rtcNotification.Direction); IceCandidate? iceCandidate = JsonSerializer.Deserialize<IceCandidate>(rtcNotification.Direction);
IceCandidateCallback(iceCandidate); if (iceCandidate is not null && !string.IsNullOrWhiteSpace(rtcNotification.Username))
{
await SendRtcCandidateToJsAsync(rtcNotification.Username, iceCandidate);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -248,7 +267,10 @@ public partial class MainPage : ContentPage
case SignalType.CallLeft: case SignalType.CallLeft:
{ {
SafeSendRawToWebView("RTC call left notification received."); SafeSendRawToWebView("RTC call left notification received.");
RtcLeaveCallback(); if (!string.IsNullOrWhiteSpace(rtcNotification.Username))
{
RtcLeaveCallback(rtcNotification.Username);
}
break; break;
} }
} }
@@ -264,7 +286,7 @@ public partial class MainPage : ContentPage
if (pyload is null) if (pyload is null)
return; return;
if (pyload.RecipientUsername == _username) if (!string.Equals(pyload.RecipientUsername, _username, StringComparison.OrdinalIgnoreCase))
return; return;
Console.WriteLine($"[{_username}] received encrypted payload for {pyload.RecipientUsername}"); Console.WriteLine($"[{_username}] received encrypted payload for {pyload.RecipientUsername}");
@@ -417,20 +439,24 @@ public partial class MainPage : ContentPage
} }
#region RTC Functions #region RTC Functions
public async Task<bool> JoinRtcChannel() public async Task<string> JoinRtcChannel()
{ {
if (string.IsNullOrWhiteSpace(_currentChannelId)) if (string.IsNullOrWhiteSpace(_currentChannelId))
return false; return "[]";
_wsc.Send($"RTC_JOIN_CHANNEL|{_username}|{_currentChannelId}"); _wsc.Send($"RTC_JOIN_CHANNEL|{_username}|{_currentChannelId}");
SafeSendRawToWebView($"Attempting to join RTC Channel {_currentChannelName} | {_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() public void LeaveRtcChannel()
@@ -441,17 +467,14 @@ public partial class MainPage : ContentPage
_wsc.Send($"RTC_LEAVE_CHANNEL|{_username}|{_currentChannelId}"); _wsc.Send($"RTC_LEAVE_CHANNEL|{_username}|{_currentChannelId}");
} }
public async void WriteRtcOffer(string json) public async Task WriteRtcOffer(string json)
{ {
try try
{ {
RtcDescription? description = JsonSerializer.Deserialize<RtcDescription>(json); RtcOffer? offer = JsonSerializer.Deserialize<RtcOffer>(json);
DBOffer offer = new DBOffer if (offer is null)
{ return;
ChannelId = _currentChannelId,
Username = _username,
SessionDescription = description
};
await ServerAPI.PostOfferAsync(offer); await ServerAPI.PostOfferAsync(offer);
} }
catch (Exception ex) catch (Exception ex)
@@ -460,25 +483,15 @@ public partial class MainPage : ContentPage
} }
} }
public async Task<string> 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 try
{ {
RtcDescription? description = JsonSerializer.Deserialize<RtcDescription>(json); RtcAnswer? answer = JsonSerializer.Deserialize<RtcAnswer>(json);
DBOffer answer = new DBOffer if (answer is null)
{ return;
ChannelId = _currentChannelId,
Username = _username,
SessionDescription = description
};
await ServerAPI.PostAnswerAsync(answer); await ServerAPI.PostAnswerAsync(answer);
SafeSendRawToWebView("WriteRtcAnswer posted successfully"); 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 try
{ {
IceCandidate? candidate = JsonSerializer.Deserialize<IceCandidate>(json); DBIceCandidate? dbCandidate = JsonSerializer.Deserialize<DBIceCandidate>(json);
DBIceCandidate DBCandidate = new DBIceCandidate if (dbCandidate is null)
{ return;
ChannelId = _currentChannelId,
Username = _username, await ServerAPI.PostIceCandidateAsync(dbCandidate);
Candidate = candidate
};
if (candidate == null) return;
await ServerAPI.PostIceCandidateAsync(DBCandidate);
} }
catch (Exception ex) 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 var remoteUsernameJson = JsonSerializer.Serialize(remoteUsername);
{ var offerJson = JsonSerializer.Serialize(offer);
await hybridWebView.InvokeJavaScriptAsync("IceCandidateAdded", [candidate], [HybridJSType.Default.IceCandidate]); await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcOffer({remoteUsernameJson}, {offerJson})");
}
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);
}
} }
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 try
{ {
await hybridWebView.InvokeJavaScriptAsync("RtcLeaveCall", [], []); var usernameJson = JsonSerializer.Serialize(username);
await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcParticipantLeft({usernameJson})");
} }
catch (Exception ex) catch (Exception ex)
{ {
SafeSendRawToWebView("RtcLeaveCallback failed: " + ex.Message); SafeSendRawToWebView("RtcLeaveCallback failed: " + ex.Message);
} }
} }
private async Task SendRtcSignalToJsAsync(string rawJson) private async Task SendRtcSignalToJsAsync(string rawJson)
{ {
var jsArg = JsonSerializer.Serialize(rawJson); var jsArg = JsonSerializer.Serialize(rawJson);
await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcSignal({jsArg})"); await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcSignal?.({jsArg})");
} //Remove? }
private async Task PushRtcContextToJsAsync() private async Task PushRtcContextToJsAsync()
{ {
@@ -634,15 +641,50 @@ public partial class MainPage : ContentPage
} }
[JsonSourceGenerationOptions(WriteIndented = false)] [JsonSourceGenerationOptions(WriteIndented = false)]
[JsonSerializable(typeof(RtcDescription))] [JsonSerializable(typeof(RtcSessionDescription))]
[JsonSerializable(typeof(List<RtcSignalMessage>))]
[JsonSerializable(typeof(IceCandidate))] [JsonSerializable(typeof(IceCandidate))]
[JsonSerializable(typeof(List<IceCandidate>))]
[JsonSerializable(typeof(string))] [JsonSerializable(typeof(string))]
internal partial class HybridJSType : JsonSerializerContext internal partial class HybridJSType : JsonSerializerContext
{ {
// This type's attributes specify JSON serialization info to preserve type structure // This type's attributes specify JSON serialization info to preserve type structure
// for trimmed builds. // for trimmed builds.
} }
} 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;
}
}

View File

@@ -35,9 +35,7 @@
<div id="localMediaStatus">Waiting for local media...</div> <div id="localMediaStatus">Waiting for local media...</div>
</div> </div>
<div style="display: inline-block; vertical-align: top;"> <div id="remoteVideos" style="display: inline-block; vertical-align: top;">
<video id="remoteVideo" autoplay playsinline style="width: 320px; height: 240px; background: #111;"></video>
<div id="remoteVideoStatus">Remote video: waiting...</div>
<div id="remoteMediaStatus">Remote media: waiting...</div> <div id="remoteMediaStatus">Remote media: waiting...</div>
</div> </div>
</div> </div>
@@ -46,4 +44,4 @@
<textarea readonly id="messageLog" style="width: 90%; height: 12em;"></textarea> <textarea readonly id="messageLog" style="width: 90%; height: 12em;"></textarea>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,102 +1,137 @@
let peerConnection = null;
let localStream = null;
let currentUsername = null; let currentUsername = null;
let currentChannelId = null; let currentChannelId = null;
let localStream = null;
let availableCameras = []; let availableCameras = [];
let availableMics = []; 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; currentUsername = name;
LogMessage("Username set to: " + currentUsername); LogMessage("Username set to: " + currentUsername);
}; };
window.setChannelId = function(channelId) {
window.setChannelId = function (channelId) {
currentChannelId = channelId; currentChannelId = channelId;
LogMessage("Channel set to: " + currentChannelId); 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) { function LogMessage(msg) {
const messageLog = document.getElementById("messageLog"); const messageLog = document.getElementById("messageLog");
messageLog.value += '\r\n' + msg; messageLog.value += "\r\n" + msg;
messageLog.scrollTop = messageLog.scrollHeight; messageLog.scrollTop = messageLog.scrollHeight;
} }
function hasVideoTrack() { async function ensurePeerConnection(remoteUsername) {
return !!localStream && localStream.getVideoTracks().length > 0; if (peerConnections.has(remoteUsername)) {
} return peerConnections.get(remoteUsername);
}
function hasAudioTrack() { const peer = new RTCPeerConnection(configuration);
return !!localStream && localStream.getAudioTracks().length > 0; peerConnections.set(remoteUsername, peer);
} candidateQueues.set(remoteUsername, []);
async function ensurePeerConnection() { peer.onicecandidate = async (event) => {
if (peerConnection) return; if (!event.candidate) {
return;
peerConnection = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
});
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
LogMessage("ICE candidate gathered");
} }
const payload = {
channelId: currentChannelId,
username: currentUsername,
targetUsername: remoteUsername,
candidate: event.candidate
};
await window.HybridWebView.InvokeDotNet("WriteIceCandidate", [JSON.stringify(payload)]);
}; };
peerConnection.ontrack = (event) => { peer.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 stream = event.streams[0];
const hasVideo = stream.getVideoTracks().length > 0; attachRemoteStream(remoteUsername, stream);
const hasAudio = stream.getAudioTracks().length > 0; };
if (hasVideo) { peer.onconnectionstatechange = () => {
remoteVideo.srcObject = stream; LogMessage(remoteUsername + " connection state: " + peer.connectionState);
} else { if (peer.connectionState === "failed" || peer.connectionState === "closed" || peer.connectionState === "disconnected") {
remoteVideo.srcObject = null; closePeerConnection(remoteUsername);
} removeRemoteTile(remoteUsername);
if (remoteVideoStatus) {
remoteVideoStatus.textContent = hasVideo
? "Remote video: active"
: "Remote video: unavailable";
}
if (remoteMediaStatus) {
remoteMediaStatus.textContent = `Remote media: audio=${hasAudio} video=${hasVideo}`;
} }
}; };
peerConnection.onconnectionstatechange = () => { if (localStream) {
LogMessage("Connection state: " + peerConnection.connectionState); for (const track of localStream.getTracks()) {
const remoteMediaStatus = document.getElementById("remoteMediaStatus"); peer.addTrack(track, localStream);
if (remoteMediaStatus && peerConnection.connectionState === "connected") {
remoteMediaStatus.textContent += " | connected";
} }
}; }
peerConnection.oniceconnectionstatechange = () => { return peer;
LogMessage("ICE connection state: " + peerConnection.iceConnectionState); }
};
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) { async function ensureLocalMedia(forceReload = false) {
const localMediaStatus = document.getElementById("localMediaStatus"); const localMediaStatus = document.getElementById("localMediaStatus");
const localVideoStatus = document.getElementById("localVideoStatus"); const localVideoStatus = document.getElementById("localVideoStatus");
@@ -113,353 +148,141 @@ async function ensureLocalMedia(forceReload = false) {
localStream = null; localStream = null;
} }
let selectedCameraId = cameraSelect ? cameraSelect.value : ""; const selectedCameraId = cameraSelect ? cameraSelect.value : "";
let selectedMicId = micSelect ? micSelect.value : ""; const selectedMicId = micSelect ? micSelect.value : "";
const videoConstraint = selectedCameraId const videoConstraint = selectedCameraId ? { deviceId: { exact: selectedCameraId } } : false;
? { deviceId: { exact: selectedCameraId } } const audioConstraint = selectedMicId ? { deviceId: { exact: selectedMicId } } : true;
: false;
const audioConstraint = selectedMicId
? { deviceId: { exact: selectedMicId } }
: true;
try { try {
localStream = await navigator.mediaDevices.getUserMedia({ localStream = await navigator.mediaDevices.getUserMedia({
video: videoConstraint, video: videoConstraint,
audio: audioConstraint audio: audioConstraint
}); });
LogMessage("Local media initialized");
} catch (err) { } catch (err) {
LogMessage("selected media failed: " + err); LogMessage("Selected media failed: " + err);
localStream = await navigator.mediaDevices.getUserMedia({
try { video: false,
localStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraint
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 hasVideo = localStream.getVideoTracks().length > 0;
const hasAudio = localStream.getAudioTracks().length > 0; const hasAudio = localStream.getAudioTracks().length > 0;
localVideo.srcObject = hasVideo ? localStream : null; localVideo.srcObject = hasVideo ? localStream : null;
localVideoStatus.textContent = hasVideo ? "Local video: active" : "Local video: unavailable";
localMediaStatus.textContent = `Local media: audio=${hasAudio} video=${hasVideo}`;
if (localVideoStatus) { for (const [remoteUsername, peer] of peerConnections) {
localVideoStatus.textContent = hasVideo const senders = peer.getSenders();
? "Local video: active" for (const track of localStream.getTracks()) {
: "Local video: unavailable"; const existingSender = senders.find(sender => sender.track && sender.track.kind === track.kind);
} if (existingSender) {
await existingSender.replaceTrack(track);
if (localMediaStatus) { } else {
localMediaStatus.textContent = `Local media: audio=${hasAudio} video=${hasVideo}`; peer.addTrack(track, localStream);
} }
}
if (!hasVideo) { LogMessage("Updated local media for " + remoteUsername);
LogMessage("No camera available, continuing without video");
} }
} }
async function applyLocalStreamToPeerConnection() { function attachRemoteStream(remoteUsername, stream) {
if (!peerConnection || !localStream) return; 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 title = document.createElement("div");
const videoTrack = localStream.getVideoTracks()[0] || null; title.textContent = remoteUsername;
title.style.marginBottom = "6px";
const audioSender = senders.find(s => s.track && s.track.kind === "audio"); const video = document.createElement("video");
const videoSender = senders.find(s => s.track && s.track.kind === "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) { const status = document.createElement("div");
await audioSender.replaceTrack(audioTrack); status.id = `remote-status-${remoteUsername}`;
LogMessage("Replaced audio track on peer connection"); status.textContent = "Remote media: active";
} else if (audioTrack) {
peerConnection.addTrack(audioTrack, localStream); tile.appendChild(title);
LogMessage("Added audio track to peer connection"); tile.appendChild(video);
tile.appendChild(status);
remoteVideos.appendChild(tile);
} }
if (videoSender) { const video = document.getElementById(`remote-video-${remoteUsername}`);
await videoSender.replaceTrack(videoTrack); const status = document.getElementById(`remote-status-${remoteUsername}`);
LogMessage("Replaced video track on peer connection"); video.srcObject = stream;
} else if (videoTrack) { status.textContent = `Remote media: audio=${stream.getAudioTracks().length > 0} video=${stream.getVideoTracks().length > 0}`;
peerConnection.addTrack(videoTrack, localStream); }
LogMessage("Added video track to peer connection");
function removeRemoteTile(remoteUsername) {
const tile = document.getElementById(`remote-${remoteUsername}`);
if (tile) {
tile.remove();
} }
} }
async function refreshDevicesAndPreview() { function closePeerConnection(remoteUsername) {
await loadDevices(); const peer = peerConnections.get(remoteUsername);
await ensureLocalMedia(true); if (peer) {
peer.close();
if (peerConnection) {
await applyLocalStreamToPeerConnection();
} }
peerConnections.delete(remoteUsername);
candidateQueues.delete(remoteUsername);
} }
async function joinChannelCall() { async function joinChannelCall() {
LogMessage("Current username: " + currentUsername); if (!currentUsername || !currentChannelId) {
LogMessage("Current channel: " + currentChannelId); LogMessage("RTC context is not ready yet.");
// LogMessage("Joining RTCChannel"); return;
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 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 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)); const rawParticipants = await window.HybridWebView.InvokeDotNet("JoinRtcChannel");
// LogMessage("Calling C# WriteRtcAnswer with: " + JSON.stringify(answer)); const participants = typeof rawParticipants === "string" ? JSON.parse(rawParticipants) : rawParticipants;
await window.HybridWebView.InvokeDotNet("WriteRtcAnswer", [JSON.stringify(answer)]);
} LogMessage("Joining call with participants: " + (participants.length ? participants.join(", ") : "none"));
else
{ for (const remoteUsername of participants) {
const offer = await peerConnection.createOffer(); const peer = await ensurePeerConnection(remoteUsername);
await peerConnection.setLocalDescription(offer); const offer = await peer.createOffer();
await peer.setLocalDescription(offer);
await window.HybridWebView.InvokeDotNet("WriteRtcOffer", [JSON.stringify(offer)]);
LogMessage(`Joining call with media offer: ${JSON.stringify(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() { async function loadDevices() {
try { try {
const devices = await navigator.mediaDevices.enumerateDevices(); const devices = await navigator.mediaDevices.enumerateDevices();
availableCameras = devices.filter(d => d.kind === "videoinput"); availableCameras = devices.filter(d => d.kind === "videoinput");
availableMics = devices.filter(d => d.kind === "audioinput"); availableMics = devices.filter(d => d.kind === "audioinput");
const cameraSelect = document.getElementById("cameraSelect"); const cameraSelect = document.getElementById("cameraSelect");
const micSelect = document.getElementById("micSelect"); const micSelect = document.getElementById("micSelect");
if (!cameraSelect || !micSelect) {
LogMessage("Device dropdowns not found.");
return;
}
cameraSelect.innerHTML = ""; cameraSelect.innerHTML = "";
micSelect.innerHTML = ""; micSelect.innerHTML = "";
@@ -483,7 +306,7 @@ async function loadDevices() {
for (const mic of availableMics) { for (const mic of availableMics) {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = mic.deviceId; option.value = mic.deviceId;
option.text = mic.label || `Microphone ${micSelect.options.length + 1}`; option.text = mic.label || `Microphone ${micSelect.options.length}`;
micSelect.appendChild(option); micSelect.appendChild(option);
} }
@@ -499,37 +322,21 @@ function wireDeviceSelectors() {
if (cameraSelect) { if (cameraSelect) {
cameraSelect.onchange = async () => { cameraSelect.onchange = async () => {
LogMessage("Camera changed");
await ensureLocalMedia(true); await ensureLocalMedia(true);
await applyLocalStreamToPeerConnection();
}; };
} }
if (micSelect) { if (micSelect) {
micSelect.onchange = async () => { micSelect.onchange = async () => {
LogMessage("Microphone changed");
await ensureLocalMedia(true); await ensureLocalMedia(true);
await applyLocalStreamToPeerConnection();
}; };
} }
} }
async function waitForIceGatheringComplete(pc) { async function refreshDevicesAndPreview() {
if (pc.iceGatheringState === "complete") return; await loadDevices();
await ensureLocalMedia(true);
await new Promise(resolve => { }
function checkState() {
if (pc.iceGatheringState === "complete") {
pc.removeEventListener("icegatheringstatechange", checkState);
resolve();
}
}
pc.addEventListener("icegatheringstatechange", checkState);
});
} //Remove?
// window.handleRtcSignal = handleRtcSignal;
window.addEventListener("HybridWebViewMessageReceived", function (e) { window.addEventListener("HybridWebViewMessageReceived", function (e) {
LogMessage("Raw message: " + e.detail.message); LogMessage("Raw message: " + e.detail.message);
@@ -541,4 +348,4 @@ window.addEventListener("load", async () => {
await loadDevices(); await loadDevices();
wireDeviceSelectors(); wireDeviceSelectors();
await ensureLocalMedia(true); await ensureLocalMedia(true);
}); });

View File

@@ -1,148 +1,74 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using RelayShared.Rtc;
namespace RelayClient; namespace RelayClient;
public class ServerAPI 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() public static void setupClient()
{ {
client.DefaultRequestHeaders.Accept.Clear(); client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add( client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json")); new MediaTypeWithQualityHeaderValue("application/json"));
} }
public static async Task<Uri> PostOfferAsync(DBOffer offer) public static async Task PostOfferAsync(RtcOffer offer)
{ {
HttpResponseMessage response = await client.PostAsJsonAsync( var response = await client.PostAsJsonAsync("api/rtc/offer", offer);
"api/rtc/offer", offer);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
return response.Headers.Location;
} }
public static async Task<Uri> GetAllOffersAsync() public static async Task<List<string>> GetParticipantsForChannelAsync(string channelId)
{ {
HttpResponseMessage response = await client.GetAsync("api/rtc/offers"); var response = await client.GetAsync($"api/rtc/participants/{channelId}");
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
return response.Headers.Location;
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
} }
public static async Task<bool> GetIsChannelActiveAsync(string channelId) public static async Task<RtcSessionDescription?> GetOfferForChannelAsync(string channelId, string fromUsername, string targetUsername)
{ {
HttpResponseMessage response = await client.GetAsync($"api/rtc/active/{channelId}"); var response = await client.GetAsync($"api/rtc/offer/{channelId}/{fromUsername}/{targetUsername}");
response.EnsureSuccessStatusCode();
return bool.Parse(response.Content.ReadAsStringAsync().Result);
}
public static async Task<RtcDescription> GetOffersForChannelAsync(string channelId)
{
HttpResponseMessage response = await client.GetAsync($"api/rtc/offers/{channelId}");
response.EnsureSuccessStatusCode();
RtcDescription? offer = JsonSerializer.Deserialize<RtcDescription>(await response.Content.ReadAsStringAsync());
return offer;
}
public static async Task<Uri?> 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<Uri> GetAnswersForChannelAsync(string channelId)
{
HttpResponseMessage response = await client.GetAsync($"api/rtc/answers/{channelId}");
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<Uri> GetLatestAnswerForChannelAsync(string channelId)
{
HttpResponseMessage response = await client.GetAsync($"api/rtc/latest/{channelId}");
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<Uri> PostIceCandidateAsync(DBIceCandidate candidate)
{
HttpResponseMessage response = await client.PostAsJsonAsync("api/rtc/candidate", candidate);
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<Uri> GetIceCandidatesForChannelAsync(string channelId)
{
HttpResponseMessage response = await client.GetAsync($"api/rtc/candidates/{channelId}");
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<Uri> 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<Uri> PostLeave(RtcLeave leave)
{
HttpResponseMessage response = await client.PostAsJsonAsync("api/rtc/leave", leave);
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<RtcDescription?> GetAnswerForChannelAsync(string? channelId)
{
if (string.IsNullOrWhiteSpace(channelId))
return null;
HttpResponseMessage response = await client.GetAsync($"api/rtc/answer/{channelId}");
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return null; return null;
var json = await response.Content.ReadAsStringAsync(); var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<RtcDescription>(json); return JsonSerializer.Deserialize<RtcSessionDescription>(json);
}
public static async Task PostAnswerAsync(RtcAnswer answer)
{
var response = await client.PostAsJsonAsync("api/rtc/answer", answer);
response.EnsureSuccessStatusCode();
}
public static async Task<RtcSessionDescription?> 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<RtcSessionDescription>(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; }
}

View File

@@ -1,4 +1,4 @@
using System.Text.Json; using System.Text.Json;
using RelayShared.Rtc; using RelayShared.Rtc;
using RelayServer.Services.Rtc; using RelayServer.Services.Rtc;
@@ -6,118 +6,108 @@ namespace RelayServer.Endpoints;
public static class RtcEndpoints public static class RtcEndpoints
{ {
/// <summary>
/// Maps all RTC-related HTTP endpoints used for storing offers and answers,
/// writing ICE candidates, checking active calls, and leaving active calls.
/// </summary>
/// <param name="app">The web application to map endpoints onto.</param>
public static void MapRtcEndpoints(this WebApplication app) 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) => 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 RtcNotificationService.BroadcastToChannel(new RtcNotificationMessage
{ {
Type = SignalType.OfferUpdated, Type = SignalType.OfferUpdated,
ChannelId = request.ChannelId, ChannelId = request.ChannelId,
Username = request.Username Username = request.Username,
TargetUsername = request.TargetUsername
}); });
return Results.Ok(); return Results.Ok();
}); });
// List all offers.
app.MapGet("/api/rtc/offers", async (RtcCallService rtcCallService) => app.MapGet("/api/rtc/offers", async (RtcCallService rtcCallService) =>
{ {
return Results.Ok(await rtcCallService.GetOffersAsync()); 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) => app.MapGet("/api/rtc/active/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
{ {
return Results.Ok(await rtcCallService.HasActiveCallAsync(channelId)); return Results.Ok(await rtcCallService.HasActiveCallAsync(channelId));
}); });
// Return the latest stored SDP offer for the specified channel. app.MapGet("/api/rtc/participants/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
app.MapGet("/api/rtc/offers/{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); 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 (RtcAnswer request, RtcCallService rtcCallService) =>
app.MapPost("/api/rtc/answer", async (RtcOffer request, RtcCallService rtcCallService) =>
{ {
Console.WriteLine($"RTC answer received for channel {request.ChannelId} from {request.Username}"); await rtcCallService.WriteAnswerAsync(
request.ChannelId,
await rtcCallService.WriteAnswerAsync(request.ChannelId, request.SessionDescription); request.Username,
request.TargetUsername,
Console.WriteLine($"Broadcasting rtc_answer_updated for {request.ChannelId}"); request.SessionDescription);
RtcNotificationService.BroadcastToChannel(new RtcNotificationMessage RtcNotificationService.BroadcastToChannel(new RtcNotificationMessage
{ {
Type = SignalType.AnswerUpdated, Type = SignalType.AnswerUpdated,
ChannelId = request.ChannelId ChannelId = request.ChannelId,
Username = request.Username,
TargetUsername = request.TargetUsername
}); });
return Results.Ok(); return Results.Ok();
}); });
// Return all answers stored for the specified channel. app.MapGet("/api/rtc/answer/{channelId}/{fromUsername}/{targetUsername}", async (
app.MapGet("/api/rtc/answers/{channelId}", async (string channelId, RtcCallService rtcCallService) => string channelId,
string fromUsername,
string targetUsername,
RtcCallService rtcCallService) =>
{ {
return Results.Ok(await rtcCallService.GetAnswersAsync(channelId)); var answer = await rtcCallService.GetAnswerAsync(channelId, fromUsername, targetUsername);
});
// 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);
return answer is null ? Results.NotFound() : Results.Ok(answer); 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) => app.MapPost("/api/rtc/candidate", async (DBIceCandidate request, RtcCallService rtcCallService) =>
{ {
await rtcCallService.WriteIceCandidateAsync( await rtcCallService.WriteIceCandidateAsync(
request.ChannelId, request.ChannelId,
request.Username, request.Username,
request.TargetUsername,
request.Candidate.candidate, request.Candidate.candidate,
request.Candidate.sdpMid, request.Candidate.sdpMid,
request.Candidate.sdpMLineIndex request.Candidate.sdpMLineIndex);
);
RtcNotificationService.BroadcastToChannel(new RtcNotificationMessage RtcNotificationService.BroadcastToChannel(new RtcNotificationMessage
{ {
Type = SignalType.CandidateAdded, Type = SignalType.CandidateAdded,
ChannelId = request.ChannelId, ChannelId = request.ChannelId,
Username = request.Username, Username = request.Username,
TargetUsername = request.TargetUsername,
Direction = JsonSerializer.Serialize(request.Candidate) Direction = JsonSerializer.Serialize(request.Candidate)
}); });
return Results.Ok(); return Results.Ok();
}); });
// Return all ICE candidates stored for the specified channel.
app.MapGet("/api/rtc/candidates/{channelId}", async (string channelId, RtcCallService rtcCallService) => app.MapGet("/api/rtc/candidates/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
{ {
return Results.Ok(await rtcCallService.GetIceCandidatesAsync(channelId)); 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) => app.MapPost("/api/rtc/leave", async (RtcLeaveRequest request, RtcCallService rtcCallService) =>
{ {
await rtcCallService.LeaveCallAsync(request.ChannelId, request.Username); await rtcCallService.LeaveCallAsync(request.ChannelId, request.Username);
@@ -132,4 +122,4 @@ public static class RtcEndpoints
return Results.Ok(); return Results.Ok();
}); });
} }
} }

View File

@@ -1,4 +1,4 @@
using RelayShared.Rtc; using RelayShared.Rtc;
using SurrealDb.Net; using SurrealDb.Net;
namespace RelayServer.Services.Rtc; namespace RelayServer.Services.Rtc;
@@ -12,19 +12,12 @@ public sealed class RtcCallService
_db = db; _db = db;
} }
/// <summary>
/// Checks whether the specified channel currently has an active RTC call.
/// </summary>
/// <param name="channelId">The channel to inspect.</param>
/// <returns>
/// True if the channel has an active call; otherwise, false.
/// </returns>
public async Task<bool> HasActiveCallAsync(string channelId) public async Task<bool> HasActiveCallAsync(string channelId)
{ {
var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls"); var activeCall = await GetActiveCallAsync(channelId);
return activeCalls.Any(x => x.ChannelId == channelId && x.IsActive); return activeCall is not null && activeCall.IsActive;
} }
public async Task<RtcActiveCall?> GetActiveCallAsync(string channelId) public async Task<RtcActiveCall?> GetActiveCallAsync(string channelId)
{ {
var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls"); var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls");
@@ -34,157 +27,70 @@ public sealed class RtcCallService
.FirstOrDefault(); .FirstOrDefault();
} }
/// <summary> public async Task<List<string>> GetParticipantsAsync(string channelId)
/// 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.
/// </summary>
/// <param name="channelId">The channel the offer belongs to.</param>
/// <param name="username">The user creating the offer.</param>
/// <param name="type">The RtcSession Type. </param>
/// <param name="sdp">The SDP offer payload.</param>
public async Task WriteOfferAsync(string channelId, string username, RtcSessionDescription sessionDescription)
{ {
var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls"); return RtcChannelPresenceService.GetUsersInChannel(channelId).ToList();
var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive); }
if (activeCall is null) public async Task WriteOfferAsync(string channelId, string username, string targetUsername, RtcSessionDescription sessionDescription)
{ {
await _db.Create("rtc_active_calls", new RtcActiveCall var activeCall = await EnsureActiveCallAsync(channelId);
{ var participant = GetOrCreateParticipant(activeCall, username);
ChannelId = channelId,
OfferUser = username,
Offer = new RtcSessionDescription
{
Type = sessionDescription.Type,
Sdp = sessionDescription.Sdp
},
Answer = null,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
IsActive = true
});
return; participant.Offer = CloneSessionDescription(sessionDescription);
} participant.Answer = null;
activeCall.OfferUser = username;
activeCall.Offer = new RtcSessionDescription
{
Type = sessionDescription.Type,
Sdp = sessionDescription.Sdp
};
activeCall.UpdatedAt = DateTime.UtcNow; activeCall.UpdatedAt = DateTime.UtcNow;
await _db.Merge<RtcActiveCall, RtcActiveCall>(activeCall); await SaveActiveCallAsync(activeCall);
} }
/// <summary> public async Task<RtcSessionDescription?> GetOfferAsync(string channelId, string fromUsername, string targetUsername)
/// Gets the current offer stored on the active call for the specified channel.
/// </summary>
/// <param name="channelId">The channel whose offer should be retrieved.</param>
/// <returns>
/// The current offer for the active call, or null if no active call or offer exists.
/// </returns>
public async Task<RtcSessionDescription?> GetOfferAsync(string channelId)
{ {
var activeCall = await GetActiveCallAsync(channelId); var activeCall = await GetActiveCallAsync(channelId);
return activeCall?.Offer; return activeCall?.Participants
.FirstOrDefault(x => x.Username.Equals(fromUsername, StringComparison.OrdinalIgnoreCase))
?.Offer;
} }
/// <summary> public async Task WriteAnswerAsync(string channelId, string username, string targetUsername, RtcSessionDescription sessionDescription)
/// Writes a new SDP answer for the specified channel and refreshes the active call timestamp
/// when a matching active call exists.
/// </summary>
/// <param name="channelId">The channel the answer belongs to.</param>
/// <param name="offerUser">The original offer owner.</param>
/// <param name="sessionDescription">The SDP and type answer payload.</param>
public async Task WriteAnswerAsync(string channelId, RtcSessionDescription sessionDescription)
{ {
var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls"); var activeCall = await EnsureActiveCallAsync(channelId);
var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive); var participant = GetOrCreateParticipant(activeCall, username);
if (activeCall is null) participant.Answer = CloneSessionDescription(sessionDescription);
return;
activeCall.Answer = new RtcSessionDescription
{
Type = sessionDescription.Type,
Sdp = sessionDescription.Sdp
};
activeCall.UpdatedAt = DateTime.UtcNow; activeCall.UpdatedAt = DateTime.UtcNow;
await _db.Merge<RtcActiveCall, RtcActiveCall>(activeCall); await SaveActiveCallAsync(activeCall);
} }
/// <summary> public async Task<RtcSessionDescription?> GetAnswerAsync(string channelId, string fromUsername, string targetUsername)
/// Gets all answers stored for the specified channel in creation order.
/// </summary>
/// <param name="channelId">The channel whose answers should be retrieved.</param>
/// <returns>
/// A list of answers for the channel ordered from oldest to newest.
/// </returns>
public async Task<List<RtcSessionDescription>> GetAnswersAsync(string channelId)
{ {
var activeCall = await GetActiveCallAsync(channelId); var activeCall = await GetActiveCallAsync(channelId);
return activeCall?.Participants
if (activeCall?.Answer is null) .FirstOrDefault(x => x.Username.Equals(fromUsername, StringComparison.OrdinalIgnoreCase))
return []; ?.Answer;
return [activeCall.Answer];
} }
/// <summary>
/// Gets the most recent answer stored for the specified channel.
/// </summary>
/// <param name="channelId">The channel whose latest answer should be retrieved.</param>
/// <returns>
/// The newest answer for the channel, or null if no answer exists.
/// </returns>
public async Task<RtcSessionDescription?> GetLatestAnswerAsync(string channelId)
{
var activeCall = await GetActiveCallAsync(channelId);
return activeCall?.Answer;
}
/// <summary>
/// Writes a new ICE candidate entry for the specified channel and user.
/// </summary>
/// <param name="channelId">The channel the ICE candidate belongs to.</param>
/// <param name="username">The user who produced the ICE candidate.</param>
/// <param name="candidate">The ICE candidate string.</param>
/// <param name="sdpMid">The SDP media identifier for the candidate, if any.</param>
/// <param name="sdpMLineIndex">The SDP media line index for the candidate, if any.</param>
/// <param name="direction">
/// The signaling direction the candidate belongs to, such as offer or answer.
/// </param>
public async Task WriteIceCandidateAsync( public async Task WriteIceCandidateAsync(
string channelId, string channelId,
string username, string username,
string targetUsername,
string candidate, string candidate,
string? sdpMid, string? sdpMid,
int? sdpMLineIndex/*, int? sdpMLineIndex)
string direction*/)
{ {
await _db.Create("rtc_ice_candidates", new RtcIceCandidate await _db.Create("rtc_ice_candidates", new RtcIceCandidate
{ {
ChannelId = channelId, ChannelId = channelId,
Username = username, Username = username,
TargetUsername = targetUsername,
Candidate = candidate, Candidate = candidate,
SdpMid = sdpMid, SdpMid = sdpMid,
SdpMLineIndex = sdpMLineIndex, SdpMLineIndex = sdpMLineIndex,
// Direction = direction,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}); });
} }
/// <summary>
/// Gets all ICE candidates stored for the specified channel in creation order.
/// </summary>
/// <param name="channelId">The channel whose ICE candidates should be retrieved.</param>
/// <returns>
/// A list of ICE candidates for the channel ordered from oldest to newest.
/// </returns>
public async Task<List<RtcIceCandidate>> GetIceCandidatesAsync(string channelId) public async Task<List<RtcIceCandidate>> GetIceCandidatesAsync(string channelId)
{ {
var candidates = await _db.Select<RtcIceCandidate>("rtc_ice_candidates"); var candidates = await _db.Select<RtcIceCandidate>("rtc_ice_candidates");
@@ -194,59 +100,78 @@ public sealed class RtcCallService
.ToList(); .ToList();
} }
/// <summary>
/// Gets ICE candidates for the specified channel that were created by other users
/// and match the requested signaling direction.
/// </summary>
/// <param name="channelId">The channel whose ICE candidates should be retrieved.</param>
/// <param name="username">The user to exclude from the results.</param>
/// <param name="direction">The signaling direction to match.</param>
/// <returns>
/// A list of matching ICE candidates ordered from oldest to newest.
/// </returns>
public async Task<List<RtcIceCandidate>> GetIceCandidatesForOthersAsync(string channelId, string username, string direction)
{
var candidates = await _db.Select<RtcIceCandidate>("rtc_ice_candidates");
return candidates
.Where(x => x.ChannelId == channelId && x.Username != username /*&& x.Direction == direction*/)
.OrderBy(x => x.CreatedAt)
.ToList();
}
/// <summary>
/// Leaves the active call for the specified channel. In the current implementation,
/// the call is only marked inactive when the offer user leaves.
/// </summary>
/// <param name="channelId">The channel whose call should be left.</param>
/// <param name="username">The user leaving the call.</param>
public async Task LeaveCallAsync(string channelId, string username) public async Task LeaveCallAsync(string channelId, string username)
{ {
var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls"); var activeCall = await GetActiveCallAsync(channelId);
var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive);
if (activeCall is null) if (activeCall is null)
return; return;
if (activeCall.OfferUser == username) activeCall.Participants.RemoveAll(x => x.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
{ activeCall.IsActive = activeCall.Participants.Count > 0;
activeCall.IsActive = false; activeCall.UpdatedAt = DateTime.UtcNow;
activeCall.UpdatedAt = DateTime.UtcNow;
await _db.Merge<RtcActiveCall, RtcActiveCall>(activeCall); await SaveActiveCallAsync(activeCall);
}
} }
/// <summary>
/// Gets all active call records that currently contain an offer.
/// </summary>
/// <returns>
/// A list of active calls with offers, ordered from newest to oldest.
/// </returns>
public async Task<List<RtcActiveCall>> GetOffersAsync() public async Task<List<RtcActiveCall>> GetOffersAsync()
{ {
var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls"); var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls");
return activeCalls return activeCalls
.Where(x => x.Offer is not null) .Where(x => x.Participants.Any(p => p.Offer is not null))
.OrderByDescending(x => x.UpdatedAt) .OrderByDescending(x => x.UpdatedAt)
.ToList(); .ToList();
} }
}
private async Task<RtcActiveCall> 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<RtcActiveCall, RtcActiveCall>(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
};
}
}

View File

@@ -1,7 +1,9 @@
using SurrealDb.Net.Models; using System.Text.Json.Serialization;
using SurrealDb.Net.Models;
namespace RelayShared.Rtc; namespace RelayShared.Rtc;
[JsonConverter(typeof(JsonStringEnumConverter<SignalType>))]
public enum SignalType public enum SignalType
{ {
Offer, Offer,
@@ -20,141 +22,276 @@ public enum SignalType
public sealed class RtcSignalMessage public sealed class RtcSignalMessage
{ {
[JsonPropertyName("type")]
public SignalType Type { get; set; } public SignalType Type { get; set; }
[JsonPropertyName("from")]
public string From { get; set; } = string.Empty; public string From { get; set; } = string.Empty;
[JsonPropertyName("channelId")]
public string ChannelId { get; set; } = string.Empty; public string ChannelId { get; set; } = string.Empty;
[JsonPropertyName("sdp")]
public string? Sdp { get; set; } public string? Sdp { get; set; }
[JsonPropertyName("candidate")]
public string? Candidate { get; set; } public string? Candidate { get; set; }
[JsonPropertyName("sdpMid")]
public string? SdpMid { get; set; } public string? SdpMid { get; set; }
[JsonPropertyName("sdpMLineIndex")]
public int? SdpMLineIndex { get; set; } public int? SdpMLineIndex { get; set; }
[JsonPropertyName("isInitiator")]
public bool IsInitiator { get; set; } public bool IsInitiator { get; set; }
} }
public sealed class RtcNotificationMessage public sealed class RtcNotificationMessage
{ {
[JsonPropertyName("type")]
public SignalType? Type { get; set; } public SignalType? Type { get; set; }
[JsonPropertyName("channelId")]
public string? ChannelId { get; set; } public string? ChannelId { get; set; }
[JsonPropertyName("username")]
public string? Username { get; set; } public string? Username { get; set; }
[JsonPropertyName("targetUsername")]
public string? TargetUsername { get; set; }
[JsonPropertyName("direction")]
public string? Direction { get; set; } public string? Direction { get; set; }
} }
public sealed class ServerPublicKeyMessage public sealed class ServerPublicKeyMessage
{ {
[JsonPropertyName("type")]
public SignalType Type { get; set; } = SignalType.ServerPublicKey; public SignalType Type { get; set; } = SignalType.ServerPublicKey;
[JsonPropertyName("publicKey")]
public string PublicKey { get; set; } = string.Empty; public string PublicKey { get; set; } = string.Empty;
} }
public sealed class SocketRtcSignalMessage public sealed class SocketRtcSignalMessage
{ {
[JsonPropertyName("type")]
public SignalType Type { get; set; } public SignalType Type { get; set; }
[JsonPropertyName("senderUsername")]
public string SenderUsername { get; set; } = string.Empty; public string SenderUsername { get; set; } = string.Empty;
[JsonPropertyName("channelId")]
public string ChannelId { get; set; } = string.Empty; public string ChannelId { get; set; } = string.Empty;
[JsonPropertyName("cipherText")]
public string CipherText { get; set; } = string.Empty; public string CipherText { get; set; } = string.Empty;
[JsonPropertyName("nonce")]
public string Nonce { get; set; } = string.Empty; public string Nonce { get; set; } = string.Empty;
[JsonPropertyName("tag")]
public string Tag { get; set; } = string.Empty; public string Tag { get; set; } = string.Empty;
[JsonPropertyName("encryptedKey")]
public string EncryptedKey { get; set; } = string.Empty; public string EncryptedKey { get; set; } = string.Empty;
} }
public sealed class SocketEncryptedMessage public sealed class SocketEncryptedMessage
{ {
[JsonPropertyName("type")]
public SignalType Type { get; set; } = SignalType.EncryptedChat; public SignalType Type { get; set; } = SignalType.EncryptedChat;
[JsonPropertyName("senderUsername")]
public string SenderUsername { get; set; } = string.Empty; public string SenderUsername { get; set; } = string.Empty;
[JsonPropertyName("recipientUsername")]
public string RecipientUsername { get; set; } = string.Empty; public string RecipientUsername { get; set; } = string.Empty;
[JsonPropertyName("channelId")]
public string ChannelId { get; set; } = string.Empty; public string ChannelId { get; set; } = string.Empty;
[JsonPropertyName("cipherText")]
public string CipherText { get; set; } = string.Empty; public string CipherText { get; set; } = string.Empty;
[JsonPropertyName("nonce")]
public string Nonce { get; set; } = string.Empty; public string Nonce { get; set; } = string.Empty;
[JsonPropertyName("tag")]
public string Tag { get; set; } = string.Empty; public string Tag { get; set; } = string.Empty;
[JsonPropertyName("encryptedKey")]
public string EncryptedKey { get; set; } = string.Empty; public string EncryptedKey { get; set; } = string.Empty;
} }
public sealed class ChannelItem public sealed class ChannelItem
{ {
[JsonPropertyName("channelId")]
public string ChannelId { get; set; } = string.Empty; public string ChannelId { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
} }
public sealed class SocketChannelList public sealed class SocketChannelList
{ {
[JsonPropertyName("type")]
public SignalType Type { get; set; } = SignalType.ChannelList; public SignalType Type { get; set; } = SignalType.ChannelList;
[JsonPropertyName("channels")]
public List<ChannelItem> Channels { get; set; } = []; public List<ChannelItem> Channels { get; set; } = [];
} }
public sealed class RtcJoinRequest public sealed class RtcJoinRequest
{ {
[JsonPropertyName("channelId")]
public string ChannelId { get; set; } = string.Empty; public string ChannelId { get; set; } = string.Empty;
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty; public string Username { get; set; } = string.Empty;
} }
public sealed class RtcJoinResponse public sealed class RtcJoinResponse
{ {
[JsonPropertyName("channelId")]
public string ChannelId { get; set; } = string.Empty; public string ChannelId { get; set; } = string.Empty;
public bool HasActiveCall { get; set; }
public bool IsOfferer { get; set; } [JsonPropertyName("participants")]
public string? OfferUser { get; set; } public List<string> Participants { get; set; } = [];
public RtcSessionDescription? OfferSdp { get; set; }
} }
public sealed class RtcLeaveRequest public sealed class RtcLeaveRequest
{ {
[JsonPropertyName("channelId")]
public string ChannelId { get; set; } = string.Empty; public string ChannelId { get; set; } = string.Empty;
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty; public string Username { get; set; } = string.Empty;
} }
public sealed class RtcSessionDescription public sealed class RtcSessionDescription
{ {
[JsonPropertyName("type")]
public SignalType Type { get; set; } public SignalType Type { get; set; }
[JsonPropertyName("sdp")]
public string Sdp { get; set; } = string.Empty; public string Sdp { get; set; } = string.Empty;
} }
public sealed class RtcOffer public sealed class RtcOffer
{ {
[JsonPropertyName("channelId")]
public string ChannelId { get; set; } = string.Empty; public string ChannelId { get; set; } = string.Empty;
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty; 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 RtcSessionDescription SessionDescription { get; set; } = new();
} }
public sealed class RtcAnswer public sealed class RtcAnswer
{ {
[JsonPropertyName("channelId")]
public string ChannelId { get; set; } = string.Empty; public string ChannelId { get; set; } = string.Empty;
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty; 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 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 public class RtcIceCandidate : Record
{ {
[JsonPropertyName("channelId")]
public required string ChannelId { get; set; } public required string ChannelId { get; set; }
[JsonPropertyName("username")]
public required string Username { get; set; } public required string Username { get; set; }
[JsonPropertyName("targetUsername")]
public required string TargetUsername { get; set; }
[JsonPropertyName("candidate")]
public required string Candidate { get; set; } public required string Candidate { get; set; }
[JsonPropertyName("sdpMid")]
public string? SdpMid { get; set; } public string? SdpMid { get; set; }
[JsonPropertyName("sdpMLineIndex")]
public int? SdpMLineIndex { get; set; } public int? SdpMLineIndex { get; set; }
// public required string Direction { get; set; } // "offer" or "answer"
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
} }
public class DBIceCandidate public class DBIceCandidate
{ {
[JsonPropertyName("channelId")]
public required string ChannelId { get; set; } public required string ChannelId { get; set; }
[JsonPropertyName("username")]
public required string Username { get; set; } public required string Username { get; set; }
[JsonPropertyName("targetUsername")]
public required string TargetUsername { get; set; }
[JsonPropertyName("candidate")]
public required IceCandidate Candidate { get; set; } public required IceCandidate Candidate { get; set; }
} }
public class IceCandidate public class IceCandidate
{ {
[JsonPropertyName("candidate")]
public required string candidate { get; set; } public required string candidate { get; set; }
[JsonPropertyName("sdpMid")]
public required string sdpMid { get; set; } public required string sdpMid { get; set; }
[JsonPropertyName("sdpMLineIndex")]
public required int sdpMLineIndex { get; set; } public required int sdpMLineIndex { get; set; }
[JsonPropertyName("usernameFragment")]
public required string usernameFragment { get; set; } public required string usernameFragment { get; set; }
} }
public sealed class RtcActiveCall : Record public sealed class RtcActiveCall : Record
{ {
[JsonPropertyName("channelId")]
public string ChannelId { get; set; } = string.Empty; public string ChannelId { get; set; } = string.Empty;
public string? OfferUser { get; set; }
public RtcSessionDescription? Offer { get; set; } [JsonPropertyName("participants")]
public RtcSessionDescription? Answer { get; set; } public List<RtcParticipantState> Participants { get; set; } = [];
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
[JsonPropertyName("updatedAt")]
public DateTime UpdatedAt { get; set; } public DateTime UpdatedAt { get; set; }
[JsonPropertyName("isActive")]
public bool IsActive { get; set; } public bool IsActive { get; set; }
} }