aboutsummaryrefslogtreecommitdiff
path: root/client-web
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2022-09-07 11:14:42 +0200
committermetamuffin <metamuffin@disroot.org>2022-09-07 11:14:42 +0200
commit61950198e3bf06555f48e8f51c882a4c3cce5128 (patch)
treea7701a44804d4a2a634f3410d400545ea82d1c45 /client-web
parent832f48f29098cc6f840ade90db3b94efa67c6833 (diff)
downloadkeks-meet-61950198e3bf06555f48e8f51c882a4c3cce5128.tar
keks-meet-61950198e3bf06555f48e8f51c882a4c3cce5128.tar.bz2
keks-meet-61950198e3bf06555f48e8f51c882a4c3cce5128.tar.zst
REFACTOR! pt.1
Diffstat (limited to 'client-web')
-rw-r--r--client-web/.gitignore1
-rw-r--r--client-web/makefile4
-rw-r--r--client-web/public/app.html20
-rw-r--r--client-web/public/assets/rnnoise/LICENSE21
-rw-r--r--client-web/public/assets/rnnoise/rnnoise-processor.js41
-rw-r--r--client-web/public/assets/rnnoise/rnnoise-processor.wasmbin0 -> 124830 bytes
-rw-r--r--client-web/public/assets/rnnoise/rnnoise-runtime.js76
-rw-r--r--client-web/public/assets/style/logger.css42
-rw-r--r--client-web/public/assets/style/master.css115
-rw-r--r--client-web/public/start.html52
-rw-r--r--client-web/source/helper.ts43
-rw-r--r--client-web/source/index.ts55
-rw-r--r--client-web/source/local_user.ts125
-rw-r--r--client-web/source/logger.ts50
-rw-r--r--client-web/source/menu.ts26
-rw-r--r--client-web/source/remote_user.ts74
-rw-r--r--client-web/source/rnnoise.ts38
-rw-r--r--client-web/source/room.ts72
-rw-r--r--client-web/source/track_handle.ts26
-rw-r--r--client-web/source/user.ts80
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
new file mode 100644
index 0000000..86fea35
--- /dev/null
+++ b/client-web/public/assets/rnnoise/rnnoise-processor.wasm
Binary files differ
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