// @ts-check
"use strict";
import * as Types from './types.js';
///// Status message /////
/** @typedef {"error" | "success" | "progress"} StatusType */
/** @type {HTMLElement | null} */
let statusElementVal = null;
/** @returns {HTMLElement} */
function getStatusElement() {
if (statusElementVal === null) {
const r = document.getElementById("statusMessage");
if (!(r instanceof HTMLElement)) {
throw new Error("Can't find status message area");
}
statusElementVal = r;
statusElementVal.innerText = "Скрипт загружен впервые";
}
return statusElementVal;
}
/**
* @param {string} msg
* @param {StatusType} type
* @returns {null}
*/
function logStatus(msg, type) {
console.log(msg);
const e = getStatusElement();
e.innerText = msg;
switch (type) {
case "error":
e.style.backgroundColor = "#ffaaaa";
e.style.borderColor = "#7f5555";
return null;
case "success":
e.style.backgroundColor = "#aaffaa";
e.style.borderColor = "#557f55";
return null;
case "progress":
e.style.backgroundColor = "#ffffaa";
e.style.borderColor = "#7f7f55";
return null;
}
}
/**
* @param {string} s
* @returns {never}
*/
function panic(s) {
logStatus(s, "error");
throw new Error("Panic: " + s);
}
/**
* @template A
* @param {A | null} x
* @param {string} s - error message
* @returns {A}
*/
function expect(x, s) {
if (x == null) {
panic("unwrap: " + s);
}
return x;
}
///// Main logic /////
/** Open a connection to a websocket server and wait for the connection to
* succeed
*
* @param {string} url
* @returns {Promise<WebSocket>}
*/
function connectWs(url) {
return new Promise(resolve => {
const ws = new WebSocket(url);
/** @param {Event} _ev */
function onOpen(_ev) {
ws.removeEventListener("open", onOpen);
resolve(ws);
}
ws.addEventListener("open", onOpen);
});
}
/** Receive one message from the websocket
* @param {WebSocket} ws
* @returns {Promise<MessageEvent>}
*/
function recv(ws) {
return new Promise(resolve => {
/** @param {MessageEvent} msg */
function onMsg(msg) {
ws.removeEventListener("message", onMsg);
resolve(msg);
}
ws.addEventListener("message", onMsg);
});
}
/**
* @param {string} url
* @returns {Promise<{ws: WebSocket, roomId: string, myId: string}>}
*/
async function connectToServer(url) {
logStatus("Подключение к серверу..", "progress");
const ws = await connectWs(url);
logStatus("Подключение установлено, ожидание инициализации..", "progress");
const msgEvent = await recv(ws);
const firstMsg = Types.decodeServerMessage(msgEvent.data);
if (firstMsg === null || firstMsg.body.t != "identity") {
panic("Ошибка инициализации: некорректное сообщение от сервера");
}
logStatus("Соединение с сервером установлено", "success");
return {
ws: ws,
roomId: firstMsg.body.roomId,
myId: firstMsg.senderId,
};
}
/**
* @param {MediaStream} stream
* @param {string} watcherId
* @param {WebSocket} serverConnection
* @returns {Promise<RTCPeerConnection>}
*/
async function createRtc(stream, watcherId, serverConnection) {
logStatus(`Создаётся новое подключение к ${watcherId}`, "progress");
// Create webrtc connection
const configuration = {"iceServers": [{"urls": "stun:stun.l.google.com:19302"}]};
const peerConnection = new RTCPeerConnection(configuration);
// Set to send local stream
const constraint = qualityConstraint(activeQuality);
for (const track of stream.getTracks()) {
try {
await track.applyConstraints(constraint);
} catch (e) {
logStatus("err", e.toString());
}
peerConnection.addTrack(track, stream);
}
// Initialize webrtc
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
peerConnection.onicecandidate = ev => {
if (ev.candidate != null) {
serverConnection.send(Types.encodeServerMessage({
targetId: watcherId,
body: {
t: "ice",
ice: ev.candidate,
},
}));
}
};
peerConnection.onconnectionstatechange = _ev => {
if (peerConnection.connectionState === "connected") {
logStatus(`Подключение к ${watcherId} успешно`, "success");
}
};
// Send offer through signaling
serverConnection.send(Types.encodeServerMessage({
targetId: watcherId,
body: {
t: "sdp",
sdp: expect(peerConnection.localDescription, "Local description not set"),
}
}));
return peerConnection;
}
/** @typedef {"max" | "1440p" | "1080p60" | "1080p30" | "720p" | "360p"} Quality */
/** Get constraint corresponding to human-redable quality
* @param {Quality} q
* @returns {MediaTrackConstraints}
*/
function qualityConstraint(q) {
switch (q) {
case "max":
return {};
case "1440p":
return {
height: {max: 1440, ideal: 1440},
frameRate: {ideal: 60},
}
case "1080p60":
return {
height: {max: 1080, ideal: 1080},
frameRate: {ideal: 60},
}
case "1080p30":
return {
height: {max: 1080, ideal: 1080},
frameRate: {max: 30},
}
case "720p":
return {
height: {max: 720, ideal: 720},
frameRate: {ideal: 30},
}
case "360p":
return {
height: {max: 360, ideal: 360},
frameRate: {ideal: 30},
}
}
}
/** @type {WebSocket | null} */
let serverConnection = null;
/** @type {string[]}
* Watchers who joined before RTC started */
let oldWatchers = [];
/** @type {MediaStream | null} */
let activeStream = null;
/** @type {Quality} */
let activeQuality = "max";
/** @param {Quality} quality */
async function changeQuality(quality) {
// a bit of ts crime because I don't want to double parse
/** @type {MediaTrackConstraints | undefined} */
const constraint = qualityConstraint(quality);
if (!constraint) {
panic("Wrong quality");
}
activeQuality = quality;
if (activeStream) {
for (const track of activeStream.getTracks()) {
try {
await track.applyConstraints(constraint);
} catch (e) {
logStatus("err", e.toString());
}
}
}
}
/** @ts-ignore */
window.changeQuality = changeQuality;
async function makeCall() {
if (serverConnection === null) {
panic("Звонок невозможен: нет соединения с сервером");
}
// Initialize stream to send
const video = document.getElementById("video");
if (!(video instanceof HTMLVideoElement)) {
panic("Ошибка при поиске видеоэлемента на странице");
}
const stream = await navigator
.mediaDevices
.getDisplayMedia({ video: true, audio: true });
activeStream = stream;
video.srcObject = stream;
video.play();
/** Ids of watchers in the room
* @type {{[id: string]: RTCPeerConnection}} */
let watchers = {};
for (const watcher of oldWatchers) {
const peerConnection = await createRtc(stream, watcher, serverConnection);
watchers[watcher] = peerConnection;
}
serverConnection.onmessage = async message => {
const signal = Types.decodeServerMessage(message.data);
if (!signal) {
console.error("Malformed message");
return;
}
if (signal.body.t === "sdp") {
console.debug("responder sdp signal");
const peerConnection = watchers[signal.senderId];
if (!peerConnection) {
console.warn("Sdp signal references unknown sender: " + signal.senderId);
return;
}
await peerConnection.setRemoteDescription(signal.body.sdp);
if (signal.body.sdp.type === "offer") {
console.warn("Got an offer from the responder");
// Create answer to offer
if (peerConnection.localDescription === null) {
console.warn("Can't respond as there is no local description");
} else {
const desc = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(desc);
const conn = expect(serverConnection, "соединение неожиданно потеряно");
conn.send(Types.encodeServerMessage({
targetId: signal.senderId,
body: {
t: "sdp",
sdp: peerConnection.localDescription,
},
}));
}
}
} else if (signal.body.t === "ice") {
console.debug(`responder ${signal.senderId} ice signal`);
const watcher = watchers[signal.senderId];
if (!watcher) {
console.warn("Ice signal references unknown sender: " + signal.senderId);
return;
}
watcher.addIceCandidate(new RTCIceCandidate(signal.body.ice));
} else if (signal.body.t === "partyJoins") {
logStatus(`Новый зритель: ${signal.senderId}`, "success");
const conn = expect(serverConnection, "соединение неожиданно потеряно");
const peerConnection = await createRtc(stream, signal.senderId, conn);
watchers[signal.senderId] = peerConnection;
const viewersElement = document.getElementById("viewerList");
if (viewersElement instanceof HTMLElement) {
const p = document.createElement("p");
p.innerText = signal.senderId;
viewersElement.appendChild(p);
} else {
console.error("Error getting viewer list");
}
} else if (signal.body.t === "leaveRoom") {
logStatus(`Зритель ушёл: ${signal.senderId}`, "success");
// Filter the viewer list widget for the participant that left
const viewersElement = document.getElementById("viewerList");
if (viewersElement instanceof HTMLElement) {
viewersElement.childNodes.forEach(viewer => {
if (viewer instanceof HTMLElement && viewer.innerText === signal.senderId) {
viewersElement.removeChild(viewer);
}
});
} else {
console.error("Error getting viewer list");
}
}
}
}
/** @ts-ignore */
window.makeCall = makeCall;
async function copyRoom() {
const roomElement = document.getElementById("roomName");
if (!(roomElement instanceof HTMLElement)) {
panic("Ошибка при поиске подписи комнаты на странице");
}
const roomCopyStatusElement = document.getElementById("roomNameCopyStatus");
if (!(roomCopyStatusElement instanceof HTMLElement)) {
panic("Ошибка при поиске подписи комнаты на странице");
}
const blob = new Blob([roomElement.innerText], {type: "text/plain"});
const data = [new ClipboardItem({ ["text/plain"]: blob })];
await navigator.clipboard.write(data);
roomCopyStatusElement.innerText = "[скопировано]";
await new Promise(r => setTimeout(r, 2000));
roomCopyStatusElement.innerText = "[копировать]";
}
/** @ts-ignore */
window.copyRoom = copyRoom;
window.onload = async () => {
// Create connection to signaling server
const urlPort = window.location.port === "" ? "" : ":" + window.location.port;
const initMessage = await connectToServer("wss://" + window.location.hostname + urlPort);
serverConnection = initMessage.ws;
const myId = initMessage.myId;
const roomId = initMessage.roomId;
const roomElement = document.getElementById("roomName");
if (!(roomElement instanceof HTMLElement)) {
panic("Ошибка при поиске подписи комнаты на странице");
}
roomElement.innerText = `${window.location}watch?room=${roomId}`
console.log(`myId = ${myId}, my roomId = ${roomId}`);
serverConnection.onmessage = async message => {
const signal = Types.decodeServerMessage(message.data);
if (!signal) {
console.error("Malformed message");
return;
}
if (signal.body.t === "partyJoins") {
logStatus(`Новый зритель: ${signal.senderId}`, "success");
oldWatchers.push(signal.senderId);
const viewersElement = document.getElementById("viewerList");
if (viewersElement instanceof HTMLElement) {
const p = document.createElement("p");
p.innerText = signal.senderId;
viewersElement.appendChild(p);
} else {
console.error("Error getting viewer list");
}
}
}
}