summaryrefslogtreecommitdiff
path: root/client-web/source/user
diff options
context:
space:
mode:
Diffstat (limited to 'client-web/source/user')
-rw-r--r--client-web/source/user/local.ts131
-rw-r--r--client-web/source/user/mod.ts75
-rw-r--r--client-web/source/user/remote.ts74
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