352 lines
11 KiB
JavaScript
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);
|
|
});
|