diff options
Diffstat (limited to 'client-web/source/user')
-rw-r--r-- | client-web/source/user/local.ts | 131 | ||||
-rw-r--r-- | client-web/source/user/mod.ts | 75 | ||||
-rw-r--r-- | client-web/source/user/remote.ts | 74 |
3 files changed, 280 insertions, 0 deletions
diff --git a/client-web/source/user/local.ts b/client-web/source/user/local.ts new file mode 100644 index 0000000..38bcfb9 --- /dev/null +++ b/client-web/source/user/local.ts @@ -0,0 +1,131 @@ +/// <reference lib="dom" /> + +import { log } from "../logger.ts"; +import { PREFS } from "../preferences.ts"; +import { RemoteUser } from "./remote.ts"; +import { get_rnnoise_node } from "../rnnoise.ts"; +import { Room } from "../room.ts"; +import { TrackHandle } from "../track_handle.ts"; +import { User } from "./mod.ts"; + +export class LocalUser extends User { + mic_gain?: GainNode + default_gain: number = PREFS.microphone_gain + + constructor(room: Room, id: number) { + super(room, id) + this.el.classList.add("local") + this.local = true + this.create_controls() + this.add_initial_tracks() + log("usermodel", `added local user: ${this.display_name}`) + } + + async add_initial_tracks() { + if (PREFS.microphone_enabled) this.publish_track(await this.create_mic_track()) + if (PREFS.camera_enabled) this.publish_track(await this.create_camera_track()) + if (PREFS.screencast_enabled) this.publish_track(await this.create_screencast_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 = "Screencast" + + 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_screencast_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: { facingMode: { ideal: PREFS.camera_facing_mode } } + }) + return new TrackHandle(user_media.getVideoTracks()[0], true) + } + + async create_screencast_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 audio_contraints = PREFS.rnnoise ? { + channelCount: { ideal: 1 }, + noiseSuppression: { ideal: false }, + echoCancellation: { ideal: true }, + autoGainControl: { ideal: true }, + } : { + channelCount: { ideal: 1 }, + noiseSuppression: { ideal: false }, + echoCancellation: { ideal: true }, + autoGainControl: { ideal: 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 (PREFS.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/user/mod.ts b/client-web/source/user/mod.ts new file mode 100644 index 0000000..6cb8715 --- /dev/null +++ b/client-web/source/user/mod.ts @@ -0,0 +1,75 @@ +/// <reference lib="dom" /> + +import { log } from "../logger.ts" +import { Room } from "../room.ts" +import { TrackHandle } from "../track_handle.ts"; + + +export abstract class User { + protected el: HTMLElement + public local = false + public name?: string + protected tracks: Set<TrackHandle> = new Set() + + constructor(public room: Room, public id: number) { + 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) + }) + } + + get display_name() { return this.name ?? `guest (${this.id})` } + + setup_view() { + const info_el = document.createElement("div") + info_el.classList.add("info") + const name_el = document.createElement("span") + name_el.textContent = this.display_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 diff --git a/client-web/source/user/remote.ts b/client-web/source/user/remote.ts new file mode 100644 index 0000000..7ded214 --- /dev/null +++ b/client-web/source/user/remote.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 "./mod.ts" + +export class RemoteUser extends User { + peer: RTCPeerConnection + negotiation_busy = false + + constructor(room: Room, id: number) { + super(room, id) + log("usermodel", `added remote user: ${id}`) + this.peer = new RTCPeerConnection(servers) + this.peer.onicecandidate = ev => { + if (!ev.candidate) return + room.signaling.send_relay({ ice_candidate: ev.candidate.toJSON() }, this.id) + } + 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.signaling.send_relay({ offer }, this.id) + } + 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.signaling.send_relay({ answer }, this.id) + 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 |