Files
Relay/RelayClient/Resources/Raw/wwwroot/index.js

352 lines
11 KiB
JavaScript

let currentUsername = null;
let currentChannelId = null;
let localStream = null;
let availableCameras = [];
let availableMics = [];
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) {
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.scrollTop = messageLog.scrollHeight;
}
async function ensurePeerConnection(remoteUsername) {
if (peerConnections.has(remoteUsername)) {
return peerConnections.get(remoteUsername);
}
const peer = new RTCPeerConnection(configuration);
peerConnections.set(remoteUsername, peer);
candidateQueues.set(remoteUsername, []);
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)]);
};
peer.ontrack = (event) => {
const stream = event.streams[0];
attachRemoteStream(remoteUsername, stream);
};
peer.onconnectionstatechange = () => {
LogMessage(remoteUsername + " connection state: " + peer.connectionState);
if (peer.connectionState === "failed" || peer.connectionState === "closed" || peer.connectionState === "disconnected") {
closePeerConnection(remoteUsername);
removeRemoteTile(remoteUsername);
}
};
if (localStream) {
for (const track of localStream.getTracks()) {
peer.addTrack(track, localStream);
}
}
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));
}
}
async function ensureLocalMedia(forceReload = false) {
const localMediaStatus = document.getElementById("localMediaStatus");
const localVideoStatus = document.getElementById("localVideoStatus");
const localVideo = document.getElementById("localVideo");
const cameraSelect = document.getElementById("cameraSelect");
const micSelect = document.getElementById("micSelect");
if (localStream && !forceReload) {
return;
}
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
const selectedCameraId = cameraSelect ? cameraSelect.value : "";
const selectedMicId = micSelect ? micSelect.value : "";
const videoConstraint = selectedCameraId ? { deviceId: { exact: selectedCameraId } } : false;
const audioConstraint = selectedMicId ? { deviceId: { exact: selectedMicId } } : true;
try {
localStream = await navigator.mediaDevices.getUserMedia({
video: videoConstraint,
audio: audioConstraint
});
} catch (err) {
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}`;
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);
}
}
function attachRemoteStream(remoteUsername, stream) {
const remoteVideos = document.getElementById("remoteVideos");
let tile = document.getElementById(`remote-${remoteUsername}`);
if (!tile) {
tile = document.createElement("div");
tile.id = `remote-${remoteUsername}`;
tile.style.display = "inline-block";
tile.style.marginRight = "20px";
tile.style.verticalAlign = "top";
const title = document.createElement("div");
title.textContent = remoteUsername;
title.style.marginBottom = "6px";
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}`;
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);
}
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();
}
}
function closePeerConnection(remoteUsername) {
const peer = peerConnections.get(remoteUsername);
if (peer) {
peer.close();
}
peerConnections.delete(remoteUsername);
candidateQueues.delete(remoteUsername);
}
async function joinChannelCall() {
if (!currentUsername || !currentChannelId) {
LogMessage("RTC context is not ready yet.");
return;
}
await ensureLocalMedia();
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 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");
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}`;
micSelect.appendChild(option);
}
LogMessage(`Loaded devices: ${availableCameras.length} cameras, ${availableMics.length} mics`);
} catch (err) {
LogMessage("loadDevices failed: " + err);
}
}
function wireDeviceSelectors() {
const cameraSelect = document.getElementById("cameraSelect");
const micSelect = document.getElementById("micSelect");
if (cameraSelect) {
cameraSelect.onchange = async () => {
await ensureLocalMedia(true);
};
}
if (micSelect) {
micSelect.onchange = async () => {
await ensureLocalMedia(true);
};
}
}
async function refreshDevicesAndPreview() {
await loadDevices();
await ensureLocalMedia(true);
}
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();
wireDeviceSelectors();
await ensureLocalMedia(true);
});