diff --git a/RelayClient/Resources/Raw/wwwroot/index.html b/RelayClient/Resources/Raw/wwwroot/index.html index dae6071..b67a8ae 100644 --- a/RelayClient/Resources/Raw/wwwroot/index.html +++ b/RelayClient/Resources/Raw/wwwroot/index.html @@ -8,330 +8,7 @@ - +
diff --git a/RelayClient/Resources/Raw/wwwroot/index.js b/RelayClient/Resources/Raw/wwwroot/index.js new file mode 100644 index 0000000..58fe2fd --- /dev/null +++ b/RelayClient/Resources/Raw/wwwroot/index.js @@ -0,0 +1,324 @@ +let peerConnection = null; +let localStream = null; +let currentUsername = null; +let currentChannelId = null; +let availableCameras = []; +let availableMics = []; + +window.setUsername = function(name) { + currentUsername = name; + LogMessage("Username set to: " + currentUsername); +}; + +window.setChannelId = function(channelId) { + currentChannelId = channelId; + LogMessage("Channel set to: " + currentChannelId); +}; + +function LogMessage(msg) { + const messageLog = document.getElementById("messageLog"); + messageLog.value += '\r\n' + msg; + messageLog.scrollTop = messageLog.scrollHeight; +} + +function hasVideoTrack() { + return !!localStream && localStream.getVideoTracks().length > 0; +} + +function hasAudioTrack() { + return !!localStream && localStream.getAudioTracks().length > 0; +} + +async function ensurePeerConnection() { + if (peerConnection) return; + + peerConnection = new RTCPeerConnection({ + iceServers: [{ urls: "stun:stun.l.google.com:19302" }] + }); + + peerConnection.onicecandidate = async (event) => { + if (!event.candidate || !currentChannelId || !currentUsername) return; + + const payload = { + type: "rtc_ice_candidate", + from: currentUsername, + channelId: currentChannelId, + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex + }; + + LogMessage("Sending ICE candidate"); + await window.HybridWebView.InvokeDotNet("SendRtcSignal", [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"); + + const stream = event.streams[0]; + const hasVideo = stream.getVideoTracks().length > 0; + const hasAudio = stream.getAudioTracks().length > 0; + + if (hasVideo) { + remoteVideo.srcObject = stream; + } else { + remoteVideo.srcObject = null; + } + + if (remoteVideoStatus) { + remoteVideoStatus.textContent = hasVideo + ? "Remote video: active" + : "Remote video: unavailable"; + } + + if (remoteMediaStatus) { + remoteMediaStatus.textContent = `Remote media: audio=${hasAudio} video=${hasVideo}`; + } + }; + + peerConnection.onconnectionstatechange = () => { + LogMessage("Connection state: " + peerConnection.connectionState); + const remoteMediaStatus = document.getElementById("remoteMediaStatus"); + if (remoteMediaStatus && peerConnection.connectionState === "connected") { + remoteMediaStatus.textContent += " | connected"; + } + }; + + peerConnection.oniceconnectionstatechange = () => { + LogMessage("ICE connection state: " + peerConnection.iceConnectionState); + }; + + peerConnection.onicegatheringstatechange = () => { + LogMessage("ICE gathering state: " + peerConnection.iceGatheringState); + }; +} + +async function ensureLocalMedia() { + if (localStream) return; + + const localMediaStatus = document.getElementById("localMediaStatus"); + const localVideoStatus = document.getElementById("localVideoStatus"); + const cameraSelect = document.getElementById("cameraSelect"); + const micSelect = document.getElementById("micSelect"); + + let selectedCameraId = cameraSelect ? cameraSelect.value : ""; + let selectedMicId = micSelect ? micSelect.value : ""; + + let mediaError = null; + + 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) { + mediaError = 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"; + throw mediaError; + } + } + + const localVideo = document.getElementById("localVideo"); + + if (localStream.getVideoTracks().length > 0) { + localVideo.srcObject = localStream; + if (localVideoStatus) localVideoStatus.textContent = "Local video: active"; + if (localMediaStatus) localMediaStatus.textContent = "Local media: audio + video"; + } else { + localVideo.srcObject = null; + if (localVideoStatus) localVideoStatus.textContent = "Local video: unavailable"; + if (localMediaStatus) localMediaStatus.textContent = "Local media: audio only"; + LogMessage("No camera available, continuing without video"); + } + + for (const track of localStream.getTracks()) { + peerConnection.addTrack(track, localStream); + LogMessage(`Added local track: ${track.kind}`); + } +} + +async function joinChannelCall() { + LogMessage("Current username: " + currentUsername); + LogMessage("Current channel: " + currentChannelId); + //TODO: Update Server DB to hold bool if channel has an active call + //TODO: First check if channel already has an active offer, if it does join with an answer, otherwise make a new offer + + try { + if (!currentChannelId) { + LogMessage("No current channel set."); + return; + } + + await ensurePeerConnection(); + await ensureLocalMedia(); + + LogMessage(`Joining call with media: audio=${hasAudioTrack()} video=${hasVideoTrack()}`); + + const offer = await peerConnection.createOffer(); + await peerConnection.setLocalDescription(offer); + + const payload = { + type: "rtc_offer", + from: currentUsername, + channelId: currentChannelId, + sdp: offer.sdp + }; + + LogMessage("Sending offer to channel " + currentChannelId); + await window.HybridWebView.InvokeDotNet("SendRtcSignal", [JSON.stringify(payload)]); + } catch (err) { + LogMessage("joinChannelCall failed: " + err); + } +} + +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_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); + + const payload = { + type: "rtc_answer", + from: currentUsername, + channelId: msg.channelId, + sdp: answer.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); + } +} + +async function loadDevices() { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + + availableCameras = devices.filter(d => d.kind === "videoinput"); + availableMics = devices.filter(d => d.kind === "audioinput"); + + const cameraSelect = document.getElementById("cameraSelect"); + const micSelect = document.getElementById("micSelect"); + + if (!cameraSelect || !micSelect) { + LogMessage("Device dropdowns not found."); + return; + } + + cameraSelect.innerHTML = ""; + micSelect.innerHTML = ""; + + const noCameraOption = document.createElement("option"); + noCameraOption.value = ""; + noCameraOption.text = "No camera / audio-only"; + cameraSelect.appendChild(noCameraOption); + + const noMicOption = document.createElement("option"); + noMicOption.value = ""; + noMicOption.text = "Default microphone"; + micSelect.appendChild(noMicOption); + + for (const cam of availableCameras) { + const option = document.createElement("option"); + option.value = cam.deviceId; + option.text = cam.label || `Camera ${cameraSelect.options.length}`; + cameraSelect.appendChild(option); + } + + for (const mic of availableMics) { + const option = document.createElement("option"); + option.value = mic.deviceId; + option.text = mic.label || `Microphone ${micSelect.options.length + 1}`; + micSelect.appendChild(option); + } + + LogMessage(`Loaded devices: ${availableCameras.length} cameras, ${availableMics.length} mics`); + } catch (err) { + LogMessage("loadDevices failed: " + err); + } +} + +window.handleRtcSignal = handleRtcSignal; + +window.addEventListener("HybridWebViewMessageReceived", function (e) { + LogMessage("Raw message: " + e.detail.message); +}); + +window.addEventListener("load", async () => { + LogMessage("RTC page loaded"); + window.HybridWebView.SendRawMessage("rtc_page_ready"); + await loadDevices(); +}); \ No newline at end of file diff --git a/RelayServer/Program.cs b/RelayServer/Program.cs index 89d08f3..e6a0a31 100644 --- a/RelayServer/Program.cs +++ b/RelayServer/Program.cs @@ -8,7 +8,7 @@ using RelayServer.Models; var surrealService = new SurrealService(); var coreClient = new CoreClientService(); var cryptoService = new ChannelCryptoService(); - +//TODO: Move everything into a MAIN function await using var db = await surrealService.ConnectAsync(); ChatTest.ClientKeyService = new ClientKeyService(db); @@ -54,7 +54,7 @@ var server = await db.Create("servers", new Servers }); Console.WriteLine($"Server created: {ToJsonString(server)}"); - +//TODO: Removed unused vars var keeperMember = await db.Create("server_members", new ServerMembers { UserId = keeper.Id, @@ -77,7 +77,10 @@ var testMember = await db.Create("server_members", new ServerMembers }); Console.WriteLine("Server members created."); - +//TODO: Make channels dynamically addable +//TODO: Add logic for channel types (ENUM) +//TODO: Add a test voice channel +//TODO: Add logic for channel groups for future UI use var channel = await db.Create("channels", new Channels { Name = "general", @@ -128,10 +131,10 @@ Console.WriteLine("Server encryption key created."); await app.StartAsync(); -Console.ReadKey(true); +Console.ReadKey(true); //TODO: Make program stop be a console command rather than just [RETURN] wssv.Stop(); -await app.StopAsync(); +await app.StopAsync(); return; static string ToJsonString(object? obj) @@ -160,6 +163,7 @@ static string GetRecordId(object? id) return $"{table}:{recordId}"; } +//TODO: Cleanup unused code public class WebRtcHub : Hub { public async Task SendOffer(string targetConnectionId, string sdp)