diff options
Diffstat (limited to 'client-web')
-rw-r--r-- | client-web/.gitignore | 1 | ||||
-rw-r--r-- | client-web/makefile | 4 | ||||
-rw-r--r-- | client-web/public/app.html | 20 | ||||
-rw-r--r-- | client-web/public/assets/rnnoise/LICENSE | 21 | ||||
-rw-r--r-- | client-web/public/assets/rnnoise/rnnoise-processor.js | 41 | ||||
-rw-r--r-- | client-web/public/assets/rnnoise/rnnoise-processor.wasm | bin | 0 -> 124830 bytes | |||
-rw-r--r-- | client-web/public/assets/rnnoise/rnnoise-runtime.js | 76 | ||||
-rw-r--r-- | client-web/public/assets/style/logger.css | 42 | ||||
-rw-r--r-- | client-web/public/assets/style/master.css | 115 | ||||
-rw-r--r-- | client-web/public/start.html | 52 | ||||
-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 |
20 files changed, 961 insertions, 0 deletions
diff --git a/client-web/.gitignore b/client-web/.gitignore new file mode 100644 index 0000000..90fbc35 --- /dev/null +++ b/client-web/.gitignore @@ -0,0 +1 @@ +/public/assets/bundle.js diff --git a/client-web/makefile b/client-web/makefile new file mode 100644 index 0000000..cc36a8e --- /dev/null +++ b/client-web/makefile @@ -0,0 +1,4 @@ + +public/bundle.js: source/* + deno bundle --no-check --unstable source/index.ts > $@ + diff --git a/client-web/public/app.html b/client-web/public/app.html new file mode 100644 index 0000000..d88abbd --- /dev/null +++ b/client-web/public/app.html @@ -0,0 +1,20 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + + <script defer async type="module" src="/bundle.js"></script> + <link rel="stylesheet" href="/style/master.css" /> + + <title>keks-meet</title> + </head> + + <body> + <p> + keks-meet needs evil javascript to be enabled. Don't be afraid though, all + the code is free (AGPL-3.0-only)! Look at it on + <a href="https://codeberg.org/metamuffin/keks-meet">codeberg</a> + </p> + </body> +</html> diff --git a/client-web/public/assets/rnnoise/LICENSE b/client-web/public/assets/rnnoise/LICENSE new file mode 100644 index 0000000..4824556 --- /dev/null +++ b/client-web/public/assets/rnnoise/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 WONG Tin Chi Timothy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/client-web/public/assets/rnnoise/rnnoise-processor.js b/client-web/public/assets/rnnoise/rnnoise-processor.js new file mode 100644 index 0000000..5b594a4 --- /dev/null +++ b/client-web/public/assets/rnnoise/rnnoise-processor.js @@ -0,0 +1,41 @@ +"use strict"; +{ + let b, d; + registerProcessor( + "rnnoise", + class extends AudioWorkletProcessor { + constructor(a) { + super({ + ...a, + numberOfInputs: 1, + numberOfOutputs: 1, + outputChannelCount: [1], + }); + b || + (d = new Float32Array( + (b = new WebAssembly.Instance(a.processorOptions.module) + .exports).memory.buffer + )); + this.state = b.newState(); + this.alive = !0; + this.port.onmessage = ({ data: a }) => { + this.alive && + (a + ? this.port.postMessage({ vadProb: b.getVadProb(this.state) }) + : ((this.alive = !1), b.deleteState(this.state))); + }; + } + process(a, c, e) { + if (!a[0][0]) return 1 + if (this.alive) + return ( + d.set(a[0][0], b.getInput(this.state) / 4), + (a = c[0][0]), + (c = b.pipe(this.state, a.length) / 4) && + a.set(d.subarray(c, c + a.length)), + !0 + ); + } + } + ); +} diff --git a/client-web/public/assets/rnnoise/rnnoise-processor.wasm b/client-web/public/assets/rnnoise/rnnoise-processor.wasm Binary files differnew file mode 100644 index 0000000..86fea35 --- /dev/null +++ b/client-web/public/assets/rnnoise/rnnoise-processor.wasm diff --git a/client-web/public/assets/rnnoise/rnnoise-runtime.js b/client-web/public/assets/rnnoise/rnnoise-runtime.js new file mode 100644 index 0000000..f69c568 --- /dev/null +++ b/client-web/public/assets/rnnoise/rnnoise-runtime.js @@ -0,0 +1,76 @@ +"use strict"; +{ + const g = document.currentScript.src.match(/(.*\/)?/)[0], + h = ( + WebAssembly.compileStreaming || + (async (a) => await WebAssembly.compile(await (await a).arrayBuffer())) + )(fetch(g + "rnnoise-processor.wasm")); + let k, c, e; + window.RNNoiseNode = + ((window.AudioWorkletNode || + (window.AudioWorkletNode = window.webkitAudioWorkletNode)) && + class extends AudioWorkletNode { + static async register(a) { + k = await h; + await a.audioWorklet.addModule(g + "rnnoise-processor.js"); + } + constructor(a) { + super(a, "rnnoise", { + channelCountMode: "explicit", + channelCount: 1, + channelInterpretation: "speakers", + numberOfInputs: 1, + numberOfOutputs: 1, + outputChannelCount: [1], + processorOptions: { module: k }, + }); + this.port.onmessage = ({ data: b }) => { + b = Object.assign(new Event("status"), b); + this.dispatchEvent(b); + if (this.onstatus) this.onstatus(b); + }; + } + update(a) { + this.port.postMessage(a); + } + }) || + ((window.ScriptProcessorNode || + (window.ScriptProcessorNode = window.webkitScriptProcessorNode)) && + Object.assign( + function (a) { + const b = a.createScriptProcessor(512, 1, 1), + d = c.newState(); + let f = !0; + b.onaudioprocess = ({ inputBuffer: b, outputBuffer: a }) => { + f && + (e.set(b.getChannelData(0), c.getInput(d) / 4), + (b = a.getChannelData(0)), + (a = c.pipe(d, b.length) / 4) && + b.set(e.subarray(a, a + b.length))); + }; + b.update = (a) => { + if (f) + if (a) { + if ( + ((a = Object.assign(new Event("status"), { + vadProb: c.getVadProb(d), + })), + b.dispatchEvent(a), + b.onstatus) + ) + b.onstatus(a); + } else (f = !1), c.deleteState(d); + }; + return b; + }, + { + register: async () => { + c || + (e = new Float32Array( + (c = (await WebAssembly.instantiate(await h)) + .exports).memory.buffer + )); + }, + } + )); +} diff --git a/client-web/public/assets/style/logger.css b/client-web/public/assets/style/logger.css new file mode 100644 index 0000000..cb76586 --- /dev/null +++ b/client-web/public/assets/style/logger.css @@ -0,0 +1,42 @@ +.logger-container { + position: absolute; + top: 0px; + right: 0px; + transition: width 1s; + + background-color: rgba(0, 0, 0, 0.376); + border-radius: 0.2em; + border: 0px solid transparent; + padding: 0.2em; +} + +.logger-line { + font-size: 1em; + height: 1.2em; + + animation-name: appear, disappear; + animation-timing-function: linear, linear; + animation-delay: 0s, 3s; + animation-duration: 0.3s, 1s; + animation-fill-mode: forwards, forwards; +} + +@keyframes appear { + from { + margin-top: -1.2em; + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes disappear { + from { + opacity: 1; + } + to { + margin-top: -1.2em; + opacity: 0; + } +} diff --git a/client-web/public/assets/style/master.css b/client-web/public/assets/style/master.css new file mode 100644 index 0000000..0b9e205 --- /dev/null +++ b/client-web/public/assets/style/master.css @@ -0,0 +1,115 @@ +@import url("https://s.metamuffin.org/static/font-ubuntu/include.css"); +@import url("./logger.css"); + +* { + font-family: "Ubuntu", sans-serif; + font-weight: 300; + color: white; + margin: 0px; + padding: 0px; +} + +:root { + --bg: #263238; + --bg-dark: #000a12; + --bg-light: #354b58; + --bg-lighter: #4f5b62; + --bg-disabled: #720000; + --bg-enabled: #097200; + --ac: #4a148c; + --ac-light: #7c43bd; + --ac-dark: #12005e; +} + +body { + background-color: var(--bg-dark); +} + +h2 { + font-weight: 700; + margin: 1em; +} + +input[type="button"], +button { + padding: 0.5em; + margin: 0.25em; + background-color: var(--bg-light); + border: 0px solid transparent; + border-radius: 3px; +} +input[type="button"]:hover, +button:hover { + background-color: var(--bg-lighter); +} +input[type="button"].enabled, +button.enabled { + background-color: var(--bg-enabled); +} +input[type="text"] { + background-color: var(--bg-dark); + border: 1px solid var(--ac-light); +} + +.local-controls { + background-color: var(--bg); + padding: 0.5em; + position: absolute; + bottom: 0.5em; + border: 0px solid transparent; + border-radius: 5px; + left: 50%; + transform: translateX(-50%); + z-index: 100; +} + +.room { + width: 100%; + height: 100%; +} + +.user { + background-color: var(--bg); + border: 0px soly transparent; + border-radius: 5px; + padding: 1em; + vertical-align: baseline; + min-width: 10em; + margin: 0.5em; +} + +.user .info .name { + font-weight: 400; +} +.user.local .info .name { + text-decoration: underline; +} + +.media { + max-height: 30vh; + border: 0px solid transparent; + border-radius: 5px; +} + +.start-box { + position: absolute; + top: 50vh; + left: 50vw; + transform: translate(-50%, -50%); +} +.start-box p { + margin-bottom: 0.5em; +} +.start-box input[type="text"] { + margin: 0.5em; + font-size: 32px; +} + +.menu-overlay { + position: absolute; + bottom: 0px; + right: 0px; + display: block; + text-align: right; +} + diff --git a/client-web/public/start.html b/client-web/public/start.html new file mode 100644 index 0000000..0852f8b --- /dev/null +++ b/client-web/public/start.html @@ -0,0 +1,52 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + + <script defer async type="module" src="/bundle.js"></script> + <link rel="stylesheet" href="/style/master.css" /> + + <title>keks-meet</title> + </head> + + <body> + <h2>keks-meet</h2> + <p>A web conferencing application using webrtc</p> + <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> + <p> + To get started, just enter a unique idenfier, click 'Join', then + share the URL with your partner. + </p> + <noscript> + keks-meet needs evil javascript to be enabled. Don't be afraid + though, all the code is free (AGPL-3.0-only)! Look at it on + <a href="https://codeberg.org/metamuffin/keks-meet">codeberg</a> + </noscript> + <script> + const room_input = document.createElement("input"); + room_input.type = "text"; + room_input.id = "room-id-input"; + room_input.placeholder = "Room ID (leave blank for random id)"; + + const submit = document.createElement("input"); + submit.type = "button"; + submit.addEventListener("click", () => { + if (room_input.value.length == 0) + room_input.value = Math.floor(Math.random() * 10000) + .toString(16) + .padStart(5, "0"); + window.location.pathname = `/${encodeURIComponent( + room_input.value + )}`; + }); + submit.value = "Join room!"; + + el.classList.add("start-box"); + el.append(room_input, document.createElement("br"), submit); + </script> + </body> +</html> 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 |