aboutsummaryrefslogtreecommitdiff
path: root/client-web/source
diff options
context:
space:
mode:
Diffstat (limited to 'client-web/source')
-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
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