diff options
author | metamuffin <metamuffin@disroot.org> | 2022-09-07 11:14:42 +0200 |
---|---|---|
committer | metamuffin <metamuffin@disroot.org> | 2022-09-07 11:14:42 +0200 |
commit | 61950198e3bf06555f48e8f51c882a4c3cce5128 (patch) | |
tree | a7701a44804d4a2a634f3410d400545ea82d1c45 /client-web/source | |
parent | 832f48f29098cc6f840ade90db3b94efa67c6833 (diff) | |
download | keks-meet-61950198e3bf06555f48e8f51c882a4c3cce5128.tar keks-meet-61950198e3bf06555f48e8f51c882a4c3cce5128.tar.bz2 keks-meet-61950198e3bf06555f48e8f51c882a4c3cce5128.tar.zst |
REFACTOR! pt.1
Diffstat (limited to 'client-web/source')
-rw-r--r-- | client-web/source/helper.ts | 43 | ||||
-rw-r--r-- | client-web/source/index.ts | 55 | ||||
-rw-r--r-- | client-web/source/local_user.ts | 125 | ||||
-rw-r--r-- | client-web/source/logger.ts | 50 | ||||
-rw-r--r-- | client-web/source/menu.ts | 26 | ||||
-rw-r--r-- | client-web/source/remote_user.ts | 74 | ||||
-rw-r--r-- | client-web/source/rnnoise.ts | 38 | ||||
-rw-r--r-- | client-web/source/room.ts | 72 | ||||
-rw-r--r-- | client-web/source/track_handle.ts | 26 | ||||
-rw-r--r-- | client-web/source/user.ts | 80 |
10 files changed, 589 insertions, 0 deletions
diff --git a/client-web/source/helper.ts b/client-web/source/helper.ts new file mode 100644 index 0000000..31e500a --- /dev/null +++ b/client-web/source/helper.ts @@ -0,0 +1,43 @@ +/// <reference lib="dom" /> + +import { parameters } from "./index.ts" + +export function get_query_params(): { [key: string]: string } { + const q: { [key: string]: string } = {} + for (const kv of window.location.hash.substring(1).split("&")) { + const [key, value] = kv.split("=") + q[decodeURIComponent(key)] = decodeURIComponent(value) + } + return q +} + +export function hex_id(len = 8): string { + if (len > 8) return hex_id() + hex_id(len - 8) + return Math.floor(Math.random() * 16 ** len).toString(16).padStart(len, "0") +} + +export function parameter_bool(name: string, def: boolean): boolean { + const v = parameters[name] + if (!v) return def + if (v == "0" || v == "false" || v == "no") return false + if (v == "1" || v == "true" || v == "yes") return true + alert(`parameter ${name} is invalid`) + return def +} + +export function parameter_number(name: string, def: number): number { + const v = parameters[name] + if (!v) return def + const n = parseFloat(v) + if (Number.isNaN(n)) { + alert(`parameter ${name} is invalid`) + return def + } + return n +} + +export function parameter_string(name: string, def: string): string { + const v = parameters[name] + if (!v) return def + return v +} diff --git a/client-web/source/index.ts b/client-web/source/index.ts new file mode 100644 index 0000000..fbb77d4 --- /dev/null +++ b/client-web/source/index.ts @@ -0,0 +1,55 @@ +/// <reference lib="dom" /> + +import { get_query_params } from "./helper.ts" +import { log } from "./logger.ts" +import { create_menu } from "./menu.ts"; +import { Room } from "./room.ts" + +export const servers: RTCConfiguration = { + iceServers: [{ urls: ["stun:stun1.l.google.com:19302", "stun:stun2.l.google.com:19302"] }], + iceCandidatePoolSize: 10, +} + +export interface User { + peer: RTCPeerConnection + stream: MediaStream, +} + +export const parameters = get_query_params() + +window.onload = () => main() + +export function main() { + document.body.querySelector("p")?.remove() + log("*", "starting up") + if (window.location.pathname.startsWith("/room/")) { + const room_name = window.location.pathname.substring("/room/".length) + const room = new Room(room_name) + create_menu(room) + document.body.append(room.el) + } else { + create_menu() + document.body.append(create_start_screen()) + } +} + +function create_start_screen() { + const with_text_content = (a: string) => (b: string) => { + const e = document.createElement(a) + e.textContent = b + return e + } + const p = with_text_content("p") + const h2 = with_text_content("h2") + + const el = document.createElement("div") + el.append( + h2("keks-meet"), + p("A web conferencing application using webrtc"), + p("keks-meet is free software! It is licenced under the terms of the third version of the GNU Affero General Public Licence only."), + p("To get started, just enter a unique idenfier, click 'Join', then share the URL with your partner.") + ) + + + return el +} diff --git a/client-web/source/local_user.ts b/client-web/source/local_user.ts new file mode 100644 index 0000000..ca91a19 --- /dev/null +++ b/client-web/source/local_user.ts @@ -0,0 +1,125 @@ +/// <reference lib="dom" /> + +import { parameter_bool, parameter_number } from "./helper.ts"; +import { log } from "./logger.ts"; +import { RemoteUser } from "./remote_user.ts"; +import { get_rnnoise_node } from "./rnnoise.ts"; +import { Room } from "./room.ts"; +import { TrackHandle } from "./track_handle.ts"; +import { User } from "./user.ts"; + + +export class LocalUser extends User { + mic_gain?: GainNode + default_gain: number = parameter_number("mic_gain", 1) + + constructor(room: Room, name: string) { + super(room, name) + this.el.classList.add("local") + this.local = true + this.create_controls() + this.add_initial_tracks() + log("usermodel", `added local user: ${this.name}`) + } + + async add_initial_tracks() { + if (parameter_bool("mic_enabled", false)) this.publish_track(await this.create_mic_track()) + if (parameter_bool("camera_enabled", false)) this.publish_track(await this.create_camera_track()) + if (parameter_bool("screen_enabled", false)) this.publish_track(await this.create_screen_track()) + } + + publish_track(t: TrackHandle) { + this.room.remote_users.forEach(u => u.peer.addTrack(t.track)) + this.add_track(t) + t.addEventListener("ended", () => { + this.room.remote_users.forEach(u => { + u.peer.getSenders().forEach(s => { + if (s.track == t.track) u.peer.removeTrack(s) + }) + }) + }) + } + + add_initial_to_remote(u: RemoteUser) { + this.tracks.forEach(t => u.peer.addTrack(t.track)) + } + + create_controls() { + const mic_toggle = document.createElement("input") + const camera_toggle = document.createElement("input") + const screen_toggle = document.createElement("input") + mic_toggle.type = camera_toggle.type = screen_toggle.type = "button" + mic_toggle.value = "Microphone" + camera_toggle.value = "Camera" + screen_toggle.value = "Screen" + + const create = async (_e: HTMLElement, tp: Promise<TrackHandle>) => { + log("media", "awaiting track") + const t = await tp + log("media", "got track") + this.publish_track(t) + } + + mic_toggle.addEventListener("click", () => create(mic_toggle, this.create_mic_track())) + camera_toggle.addEventListener("click", () => create(camera_toggle, this.create_camera_track())) + screen_toggle.addEventListener("click", () => create(screen_toggle, this.create_screen_track())) + + const el = document.createElement("div") + el.classList.add("local-controls") + el.append(mic_toggle, camera_toggle, screen_toggle) + document.body.append(el) + } + + + async create_camera_track() { + log("media", "requesting user media (camera)") + const user_media = await window.navigator.mediaDevices.getUserMedia({ video: true }) + return new TrackHandle(user_media.getVideoTracks()[0], true) + } + async create_screen_track() { + log("media", "requesting user media (screen)") + const user_media = await window.navigator.mediaDevices.getDisplayMedia({ video: true }) + return new TrackHandle(user_media.getVideoTracks()[0], true) + } + async create_mic_track() { + log("media", "requesting user media (audio)") + const use_rnnoise = parameter_bool("rnnoise", true) + const audio_contraints = use_rnnoise ? { + channelCount: { ideal: 1 }, + noiseSuppression: { ideal: false }, + echoCancellation: { ideal: true }, + autoGainControl: { ideal: false }, + } : true; + + const user_media = await window.navigator.mediaDevices.getUserMedia({ audio: audio_contraints }) + const context = new AudioContext() + const source = context.createMediaStreamSource(user_media) + const destination = context.createMediaStreamDestination() + const gain = context.createGain() + gain.gain.value = this.default_gain + this.mic_gain = gain + + let rnnoise: RNNoiseNode; + if (use_rnnoise) { + rnnoise = await get_rnnoise_node(context) + source.connect(rnnoise) + rnnoise.connect(gain) + } else { + source.connect(gain) + } + gain.connect(destination) + + const t = new TrackHandle(destination.stream.getAudioTracks()[0], true) + + t.addEventListener("ended", () => { + user_media.getTracks().forEach(t => t.stop()) + source.disconnect() + if (rnnoise) rnnoise.disconnect() + gain.disconnect() + destination.disconnect() + this.mic_gain = undefined + }) + + return t + } +} diff --git a/client-web/source/logger.ts b/client-web/source/logger.ts new file mode 100644 index 0000000..e00b1d0 --- /dev/null +++ b/client-web/source/logger.ts @@ -0,0 +1,50 @@ +/// <reference lib="dom" /> + +const log_tag_color = { + "*": "#FF4444", + webrtc: "#FF44FF", + media: "#FFFF44", + ws: "#44FFFF", + rnnoise: "#2222FF", + usermodel: "#44FF44", + error: "#FF0000", +} +export type LogTag = keyof typeof log_tag_color + +let logger_container: HTMLDivElement + +// TODO maybe log time aswell +// deno-lint-ignore no-explicit-any +export function log(tag: LogTag, message: string, ...data: any[]) { + for (let i = 0; i < data.length; i++) { + const e = data[i]; + if (e instanceof MediaStreamTrack) data[i] = `(${e.kind}) ${e.id}` + } + console.log(`%c[${tag}] ${message}`, "color:" + log_tag_color[tag], ...data); + + if (logger_container) { + const e = document.createElement("p") + e.classList.add("logger-line") + e.textContent = `[${tag}] ${message}` + e.style.color = log_tag_color[tag] + logger_container.append(e) + setTimeout(() => { + e.remove() + }, tag == "error" ? 60000 : 6000) + } +} + +globalThis.addEventListener("load", () => { + const d = document.createElement("div") + d.classList.add("logger-container") + document.body.append(d) + logger_container = d + + // clear the console every hour so logs dont accumulate + setInterval(() => console.clear(), 1000 * 60 * 60) +}) + +globalThis.onerror = (_ev, source, line, col, err) => { + log("error", `${err?.name} ${err?.message}`, err) + log("error", `on ${source}:${line}:${col}`, err) +}
\ No newline at end of file diff --git a/client-web/source/menu.ts b/client-web/source/menu.ts new file mode 100644 index 0000000..1401b42 --- /dev/null +++ b/client-web/source/menu.ts @@ -0,0 +1,26 @@ +import { Room } from "./room.ts"; + +export function create_menu(room?: Room) { + const menu = document.createElement("div") + menu.classList.add("menu-overlay") + document.body.append(menu) + + const item = (name: string, cb: (() => void) | string) => { + const p = document.createElement("p") + const a = document.createElement("a") + a.classList.add("menu-item") + a.textContent = name + if (typeof cb == "string") a.href = cb + else a.addEventListener("click", cb), a.href = "#" + p.append(a) + return p + } + + if (room) menu.append( + item("Settings", () => alert("todo, refer to the url parameters in the docs for now")) + ) + menu.append( + item("Licence", "/licence"), + item("Sources / Documentation", "https://codeberg.org/metamuffin/keks-meet"), + ) +}
\ No newline at end of file diff --git a/client-web/source/remote_user.ts b/client-web/source/remote_user.ts new file mode 100644 index 0000000..a0fdeaf --- /dev/null +++ b/client-web/source/remote_user.ts @@ -0,0 +1,74 @@ +/// <reference lib="dom" /> + +import { servers } from "./index.ts" +import { log } from "./logger.ts" +import { Room } from "./room.ts" +import { TrackHandle } from "./track_handle.ts"; +import { User } from "./user.ts" + +export class RemoteUser extends User { + peer: RTCPeerConnection + negotiation_busy = false + + constructor(room: Room, name: string) { + super(room, name) + log("usermodel", `added remote user: ${name}`) + this.peer = new RTCPeerConnection(servers) + this.peer.onicecandidate = ev => { + if (!ev.candidate) return + room.websocket_send({ ice_candiate: ev.candidate.toJSON(), receiver: this.name }) + } + this.peer.ontrack = ev => { + const t = ev.track + log("media", `remote track: ${this.name}`, t) + this.add_track(new TrackHandle(t)) + } + this.peer.onnegotiationneeded = async () => { + log("webrtc", `negotiation needed: ${this.name}`) + while (this.negotiation_busy) { + await new Promise<void>(r => setTimeout(() => r(), 100)) + } + this.offer() + } + } + + async offer() { + this.negotiation_busy = true + const offer_description = await this.peer.createOffer() + await this.peer.setLocalDescription(offer_description) + const offer = { type: offer_description.type, sdp: offer_description.sdp } + log("webrtc", `sent offer: ${this.name}`, { a: offer }) + this.room.websocket_send({ receiver: this.name, offer }) + } + async on_offer(offer: RTCSessionDescriptionInit) { + this.negotiation_busy = true + log("webrtc", `got offer: ${this.name}`, { a: offer }) + const offer_description = new RTCSessionDescription(offer) + await this.peer.setRemoteDescription(offer_description) + this.answer() + } + async answer() { + const answer_description = await this.peer.createAnswer() + await this.peer.setLocalDescription(answer_description) + const answer = { type: answer_description.type, sdp: answer_description.sdp } + log("webrtc", `sent answer: ${this.name}`, { a: answer }) + this.room.websocket_send({ receiver: this.name, answer }) + this.negotiation_busy = false + } + async on_answer(answer: RTCSessionDescriptionInit) { + log("webrtc", `got answer: ${this.name}`, { a: answer }) + const answer_description = new RTCSessionDescription(answer) + await this.peer.setRemoteDescription(answer_description) + this.negotiation_busy = false + } + + add_ice_candidate(candidate: RTCIceCandidateInit) { + this.peer.addIceCandidate(new RTCIceCandidate(candidate)) + } + + leave() { + log("usermodel", `remove remote user: ${this.name}`) + this.peer.close() + this.room.el.removeChild(this.el) + } +}
\ No newline at end of file diff --git a/client-web/source/rnnoise.ts b/client-web/source/rnnoise.ts new file mode 100644 index 0000000..7867682 --- /dev/null +++ b/client-web/source/rnnoise.ts @@ -0,0 +1,38 @@ +/// <reference lib="dom" /> + +import { log } from "./logger.ts" + +declare global { + class RNNoiseNode extends AudioWorkletNode { + static register(context: AudioContext): Promise<void> + constructor(context: AudioContext) + // deno-lint-ignore no-explicit-any + onstatus: (data: any) => void + update(something: boolean): void + } +} + + +// TODO fix leak +export async function get_rnnoise_node(context: AudioContext): Promise<RNNoiseNode> { + log("rnnoise", "enabled") + //@ts-ignore asfdasfd + let RNNoiseNode: typeof RNNoiseNode = window.RNNoiseNode; + + let script: HTMLScriptElement; + if (!RNNoiseNode) { + log("rnnoise", "loading wasm...") + script = document.createElement("script") + script.src = "/_rnnoise/rnnoise-runtime.js" + script.defer = true + document.head.appendChild(script) + //@ts-ignore asdfsfad + while (!window.RNNoiseNode) await new Promise<void>(r => setTimeout(() => r(), 100)) + //@ts-ignore asfdsadfsafd + RNNoiseNode = window.RNNoiseNode; + log("rnnoise", "loaded") + } + + await RNNoiseNode.register(context) + return new RNNoiseNode(context) +}
\ No newline at end of file diff --git a/client-web/source/room.ts b/client-web/source/room.ts new file mode 100644 index 0000000..f22cff1 --- /dev/null +++ b/client-web/source/room.ts @@ -0,0 +1,72 @@ +/// <reference lib="dom" /> + +import { log } from "./logger.ts"; +import { RemoteUser } from "./remote_user.ts"; +import { User } from "./user.ts"; +import { LocalUser } from "./local_user.ts"; +import { hex_id, parameter_string } from "./helper.ts"; +import { PacketS, PacketC } from "../../common/packets.d.ts"; + + +export class Room { + el: HTMLElement + name: string + users: Map<string, User> = new Map() + remote_users: Map<string, RemoteUser> = new Map() + local_user: LocalUser + websocket: WebSocket + + constructor(name: string) { + this.name = name + this.el = document.createElement("div") + this.el.classList.add("room") + this.websocket = new WebSocket(`${window.location.protocol.endsWith("s:") ? "wss" : "ws"}://${window.location.host}/signaling/${encodeURIComponent(name)}`) + this.websocket.onclose = () => this.websocket_close() + this.websocket.onopen = () => this.websocket_open() + this.websocket.onmessage = (ev) => { + this.websocket_message(JSON.parse(ev.data)) + } + this.local_user = new LocalUser(this, parameter_string("username", `guest-${hex_id()}`)) + } + + websocket_send(data: PacketS) { + log("ws", `-> ${data.receiver ?? "*"}`, data) + this.websocket.send(JSON.stringify(data)) + } + websocket_message(packet: PacketC) { + if (packet.join) { + log("*", `${this.name} ${packet.sender} joined`); + const ru = new RemoteUser(this, packet.sender) + this.local_user.add_initial_to_remote(ru) + if (!packet.stable) ru.offer() + this.users.set(packet.sender, ru) + this.remote_users.set(packet.sender, ru) + return + } + const sender = this.remote_users.get(packet.sender) + if (!sender) return console.warn(`unknown sender ${packet.sender}`) + if (packet.leave) { + log("*", `${this.name} ${packet.sender} left`); + sender.leave() + this.users.delete(packet.sender) + this.remote_users.delete(packet.sender) + return + } + if (!packet.data) return console.warn("dataless packet") + log("ws", `<- ${packet.sender}: `, packet.data); + if (packet.data.ice_candiate) sender.add_ice_candidate(packet.data.ice_candiate) + if (packet.data.offer) sender.on_offer(packet.data.offer) + if (packet.data.answer) sender.on_answer(packet.data.answer) + } + websocket_close() { + log("ws", "websocket closed"); + setTimeout(() => { + window.location.reload() + }, 1000) + } + websocket_open() { + log("ws", "websocket opened"); + this.websocket.send(this.local_user.name) + setInterval(() => this.websocket_send({}), 30000) // stupid workaround for nginx disconnection inactive connections + } +}
\ No newline at end of file diff --git a/client-web/source/track_handle.ts b/client-web/source/track_handle.ts new file mode 100644 index 0000000..98b2b2f --- /dev/null +++ b/client-web/source/track_handle.ts @@ -0,0 +1,26 @@ +/// <reference lib="dom" /> + +export class TrackHandle extends EventTarget { + constructor( + public track: MediaStreamTrack, + public local = false + ) { + super() + track.onended = () => this.dispatchEvent(new CustomEvent("ended")) + // TODO research how onmute and onunmute behave + track.onmute = () => this.dispatchEvent(new CustomEvent("ended")) // onmute seems to be called when the remote ends the track + track.onunmute = () => this.dispatchEvent(new CustomEvent("started")) + + this.addEventListener("ended", () => { + // drop all references to help gc + track.onunmute = track.onmute = track.onended = null + }) + } + + get kind() { return this.track.kind } + get label() { return this.track.label } + get muted() { return this.track.muted } + get id() { return this.track.id } + + end() { this.track.stop(); this.dispatchEvent(new CustomEvent("ended")) } +} diff --git a/client-web/source/user.ts b/client-web/source/user.ts new file mode 100644 index 0000000..bda875f --- /dev/null +++ b/client-web/source/user.ts @@ -0,0 +1,80 @@ +/// <reference lib="dom" /> + +import { log } from "./logger.ts" +import { Room } from "./room.ts" +import { TrackHandle } from "./track_handle.ts"; + + +export abstract class User { + name: string + room: Room + + el: HTMLElement + + local = false + + protected tracks: Set<TrackHandle> = new Set() + + constructor(room: Room, name: string) { + this.name = name + this.room = room + this.el = document.createElement("div") + this.el.classList.add("user") + this.room.el.append(this.el) + this.setup_view() + } + + add_track(t: TrackHandle) { + this.tracks.add(t) + this.create_track_element(t) + t.addEventListener("ended", () => { + log("media", "track ended", t) + this.tracks.delete(t) + }) + t.addEventListener("mute", () => { + log("media", "track muted", t) + }) + t.addEventListener("unmute", () => { + log("media", "track unmuted", t) + }) + } + + setup_view() { + const info_el = document.createElement("div") + info_el.classList.add("info") + const name_el = document.createElement("span") + name_el.textContent = this.name + name_el.classList.add("name") + info_el.append(name_el) + this.el.append(info_el) + } + + create_track_element(t: TrackHandle) { + const is_video = t.kind == "video" + const media_el = is_video ? document.createElement("video") : document.createElement("audio") + const stream = new MediaStream([t.track]) + media_el.srcObject = stream + media_el.classList.add("media") + media_el.autoplay = true + media_el.controls = true + + if (this.local) media_el.muted = true + + + const el = document.createElement("div") + if (t.local) { + const end_button = document.createElement("button") + end_button.textContent = "End" + end_button.addEventListener("click", () => { + t.end() + }) + el.append(end_button) + } + el.append(media_el) + this.el.append(el) + t.addEventListener("ended", () => { + media_el.srcObject = null + el.remove() + }) + } +}
\ No newline at end of file |