From ce6dc3ba5eca2ac881c90acaca1b664de9af588b Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Thu, 15 May 2025 15:45:09 -0500 Subject: [PATCH] basic VC gui --- src/webpage/app.html | 3 +- src/webpage/channel.ts | 153 ++++++++++++++++++++++++++++++++--- src/webpage/direct.ts | 2 + src/webpage/icons/call.svg | 1 + src/webpage/icons/hangup.svg | 1 + src/webpage/localuser.ts | 15 +++- src/webpage/style.css | 72 +++++++++++++++++ src/webpage/user.ts | 6 +- src/webpage/voice.ts | 86 ++++++++++++++++---- 9 files changed, 308 insertions(+), 31 deletions(-) create mode 100644 src/webpage/icons/call.svg create mode 100644 src/webpage/icons/hangup.svg diff --git a/src/webpage/app.html b/src/webpage/app.html index 3afb6a5..6321f79 100644 --- a/src/webpage/app.html +++ b/src/webpage/app.html @@ -89,7 +89,8 @@
-
+
+
diff --git a/src/webpage/channel.ts b/src/webpage/channel.ts index 42ceb82..c47ab40 100644 --- a/src/webpage/channel.ts +++ b/src/webpage/channel.ts @@ -729,6 +729,17 @@ class Channel extends SnowFlake { if (typeof memb !== "string") { await Member.new(memb, this.guild); } + + const users = this.usersDiv.deref(); + if (users) { + const user = await this.localuser.getUser(typeof memb === "string" ? memb : memb.id); + if (joined) { + this.makeUserBox(user, users); + } else { + this.destUserBox(user); + } + } + this.updateVoiceUsers(); if (this.voice === this.localuser.currentVoice) { AVoice.noises("join"); @@ -1060,7 +1071,124 @@ class Channel extends SnowFlake { if (!this.last_pin_timestamp && !this.lastpin) return false; return this.last_pin_timestamp !== this.lastpin; } - async getHTML(addstate = true, getMessages = true) { + boxMap = new Map(); + destUserBox(user: User) { + const box = this.boxMap.get(user.id); + if (!box) return; + box.remove(); + this.boxMap.delete(user.id); + } + async makeUserBox(user: User, users: HTMLElement) { + const memb = Member.resolveMember(user, this.guild); + const box = document.createElement("div"); + this.boxMap.set(user.id, box); + if (user.accent_color != undefined) { + box.style.setProperty( + "--accent_color", + `#${user.accent_color.toString(16).padStart(6, "0")}`, + ); + } + memb.then((_) => { + if (!_) return; + if (_.accent_color !== undefined) { + box.style.setProperty("--accent_color", `#${_.accent_color.toString(16).padStart(6, "0")}`); + } + }); + + box.append(user.buildpfp(this.guild)); + + const span = document.createElement("span"); + span.textContent = user.name; + memb.then((_) => { + if (!_) return; + span.textContent = _.name; + }); + span.classList.add("voiceUsername"); + box.append(span); + users.append(box); + } + usersDiv = new WeakRef(document.createElement("div")); + async setUpVoiceArea() { + if (!this.voice) throw new Error("voice not found?"); + const voiceArea = document.getElementById("voiceArea") as HTMLElement; + const buttonRow = document.createElement("div"); + buttonRow.classList.add("flexltr", "buttonRow"); + const updateMicIcon = () => { + mspan.classList.remove("svg-micmute", "svg-mic"); + mspan.classList.add(this.localuser.mute ? "svg-micmute" : "svg-mic"); + }; + + const mute = document.createElement("div"); + const mspan = document.createElement("span"); + mute.append(mspan); + updateMicIcon(); + this.localuser.updateOtherMic = updateMicIcon; + mute.onclick = () => { + this.localuser.mute = !this.localuser.mute; + this.localuser.updateMic(); + }; + mute.classList.add("muteVoiceIcon"); + + const updateCallIcon = () => { + cspan.classList.remove("svg-call", "svg-hangup"); + cspan.classList.add(this.voice?.open ? "svg-hangup" : "svg-call"); + }; + const call = document.createElement("div"); + const cspan = document.createElement("span"); + call.append(cspan); + updateCallIcon(); + call.onclick = async () => { + if (this.voice?.userids.has(this.localuser.user.id)) { + this.voice.leave(); + } else if (this.voice) { + await this.localuser.joinVoice(this); + } + updateCallIcon(); + }; + call.classList.add("callVoiceIcon"); + + buttonRow.append(mute, call); + + const users = document.createElement("div"); + users.classList.add("voiceUsers"); + this.voice.userids.forEach(async (_, id) => { + const user = await this.localuser.getUser(id); + this.makeUserBox(user, users); + }); + this.usersDiv = new WeakRef(users); + this.voice.onSpeakingChange = (id, speaking) => { + const box = this.boxMap.get(id); + if (!box) return; + if (speaking) { + box.classList.add("speaking"); + } else { + box.classList.remove("speaking"); + } + }; + + voiceArea.append(users, buttonRow); + } + async getHTML(addstate = true, getMessages: boolean | void = undefined) { + if (getMessages === undefined) { + getMessages = this.type !== 2 || !this.localuser.voiceAllowed; + } + + const messages = document.getElementById("channelw") as HTMLDivElement; + const messageContainers = Array.from(messages.getElementsByClassName("messagecontainer")); + for (const thing of messageContainers) { + thing.remove(); + } + const chatArea = document.getElementById("chatArea") as HTMLElement; + + const voiceArea = document.getElementById("voiceArea") as HTMLElement; + voiceArea.innerHTML = ""; + if (getMessages) { + chatArea.style.removeProperty("display"); + } else { + chatArea.style.setProperty("display", "none"); + this.setUpVoiceArea(); + } + const pinnedM = document.getElementById("pinnedMDiv"); if (pinnedM) { if (this.unreadPins()) { @@ -1069,11 +1197,6 @@ class Channel extends SnowFlake { pinnedM.classList.remove("unreadPin"); } } - const ghostMessages = document.getElementById("ghostMessages") as HTMLElement; - ghostMessages.innerHTML = ""; - for (const thing of this.fakeMessages) { - ghostMessages.append(thing[1]); - } if (addstate) { history.pushState([this.guild_id, this.id], "", "/channels/" + this.guild_id + "/" + this.id); } @@ -1095,6 +1218,7 @@ class Channel extends SnowFlake { if (this.guild !== this.localuser.lookingguild) { this.guild.loadGuild(); } + if (this.localuser.channelfocus && this.localuser.channelfocus.myhtml) { this.localuser.channelfocus.myhtml.classList.remove("viewChannel"); } @@ -1117,15 +1241,22 @@ class Channel extends SnowFlake { return; } - const prom = this.infinite.delete(); + const ghostMessages = document.getElementById("ghostMessages") as HTMLElement; + ghostMessages.innerHTML = ""; + for (const thing of this.fakeMessages) { + ghostMessages.append(thing[1]); + } - const loading = document.getElementById("loadingdiv") as HTMLDivElement; - Channel.regenLoadingMessages(); - loading.classList.add("loading"); + const prom = this.infinite.delete(); + if (getMessages) { + const loading = document.getElementById("loadingdiv") as HTMLDivElement; + Channel.regenLoadingMessages(); + loading.classList.add("loading"); + } this.rendertyping(); this.localuser.getSidePannel(); if (this.voice && this.localuser.voiceAllowed) { - this.localuser.joinVoice(this); + //this.localuser.joinVoice(this); } (document.getElementById("typebox") as HTMLDivElement).contentEditable = "" + this.canMessage; (document.getElementById("upload") as HTMLElement).style.visibility = this.canMessage diff --git a/src/webpage/direct.ts b/src/webpage/direct.ts index d99caf4..804ca79 100644 --- a/src/webpage/direct.ts +++ b/src/webpage/direct.ts @@ -54,6 +54,8 @@ class Direct extends Guild { } } getHTML() { + const voiceArea = document.getElementById("voiceArea") as HTMLElement; + voiceArea.innerHTML = ""; const sideContainDiv = document.getElementById("sideContainDiv"); if (sideContainDiv) { sideContainDiv.classList.remove("searchDiv"); diff --git a/src/webpage/icons/call.svg b/src/webpage/icons/call.svg new file mode 100644 index 0000000..063b494 --- /dev/null +++ b/src/webpage/icons/call.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/webpage/icons/hangup.svg b/src/webpage/icons/hangup.svg new file mode 100644 index 0000000..807cff6 --- /dev/null +++ b/src/webpage/icons/hangup.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/webpage/localuser.ts b/src/webpage/localuser.ts index 21fe074..4766bb2 100644 --- a/src/webpage/localuser.ts +++ b/src/webpage/localuser.ts @@ -249,9 +249,12 @@ class Localuser { } mute = true; deaf = false; - updateMic() { + updateOtherMic = () => {}; + updateMic(updateVoice: boolean = true) { + this.updateOtherMic(); const mic = document.getElementById("mic") as HTMLElement; mic.classList.remove("svg-mic", "svg-micmute"); + if (this.voiceFactory && updateVoice) this.voiceFactory.mute = this.mute; if (this.mute) { mic.classList.add("svg-micmute"); } else { @@ -271,7 +274,11 @@ class Localuser { this.mdBox(); - this.voiceFactory = new VoiceFactory({id: this.user.id}); + this.voiceFactory = new VoiceFactory({id: this.user.id}, (g) => { + if (this.ws) { + this.ws.send(JSON.stringify(g)); + } + }); this.handleVoice(); this.mfa_enabled = ready.d.user.mfa_enabled as boolean; this.userinfo.username = this.user.username; @@ -757,6 +764,10 @@ class Localuser { } break; case "VOICE_STATE_UPDATE": + if (this.user.id === temp.d.user_id) { + this.mute = temp.d.self_mute; + this.updateMic(false); + } if (this.voiceFactory) { this.voiceFactory.voiceStateUpdate(temp.d); } diff --git a/src/webpage/style.css b/src/webpage/style.css index 32eb23f..a005ce4 100644 --- a/src/webpage/style.css +++ b/src/webpage/style.css @@ -184,6 +184,69 @@ body { position: relative; width: 100%; } +#voiceArea:empty { + display: none; +} +#voiceArea { + background: var(--black); + position: relative; +} +.voiceUsers { + padding: 20px; + display: flex; + flex-direction: row; + flex-wrap: wrap; +} +.speaking { + border: solid var(--green) 3px; + padding: 77px 137px !important; +} +.voiceUsers > * { + background: var(--accent_color, var(--primary-bg)); + padding: 80px 140px; + width: fit-content; + border-radius: 8px; + position: relative; + box-sizing: border-box; + margin: 8px; + + img { + width: 60px; + height: 60px; + cursor: unset; + } +} +.buttonRow > * { + margin-right: 6px; + width: 54px; + height: 54px; + background: var(--secondary-hover); + border-radius: 100%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + * { + width: 32px !important; + height: 32px !important; + background: var(--primary-text); + } +} +.buttonRow { + position: absolute; + bottom: 20px; + width: 100%; + display: flex; + justify-content: center; +} +.voiceUsername { + position: absolute; + bottom: 10px; + left: 14px; + background: var(--secondary-bg); + padding: 4px; + border-radius: 6px; +} .flexgrow { flex-grow: 1; min-height: 0; @@ -415,6 +478,15 @@ textarea { display: block; mask-size: cover !important; } +.svg-call { + mask: url(/icons/call.svg); + mask-size: contain !important; +} +.svg-hangup { + mask: url(/icons/hangup.svg); + mask-size: contain !important; + background: var(--red); +} .svg-plainx { mask: url(/icons/plainx.svg); mask-size: contain !important; diff --git a/src/webpage/user.ts b/src/webpage/user.ts index a30c640..6faf1cf 100644 --- a/src/webpage/user.ts +++ b/src/webpage/user.ts @@ -499,14 +499,14 @@ class User extends SnowFlake { this.bind(div, guild); return div; } - async buildstatuspfp(guild: Guild | void | Member | null): Promise { + buildstatuspfp(guild: Guild | void | Member | null): HTMLDivElement { const div = document.createElement("div"); div.classList.add("pfpDiv"); const pfp = this.buildpfp(guild, div); div.append(pfp); const status = document.createElement("div"); status.classList.add("statusDiv"); - switch (await this.getStatus()) { + switch (this.getStatus()) { case "offline": case "invisible": status.classList.add("offlinestatus"); @@ -785,7 +785,7 @@ class User extends SnowFlake { badgediv.append(badge); } })(); - const pfp = await this.buildstatuspfp(guild); + const pfp = this.buildstatuspfp(guild); div.appendChild(pfp); const userbody = document.createElement("div"); userbody.classList.add("flexttb", "infosection"); diff --git a/src/webpage/voice.ts b/src/webpage/voice.ts index 6458616..f060e46 100644 --- a/src/webpage/voice.ts +++ b/src/webpage/voice.ts @@ -1,8 +1,13 @@ import {memberjson, sdpback, voiceserverupdate, voiceStatus, webRTCSocket} from "./jsontypes.js"; class VoiceFactory { settings: {id: string}; - constructor(usersettings: VoiceFactory["settings"]) { + handleGateway: (obj: Object) => void; + constructor( + usersettings: VoiceFactory["settings"], + handleGateway: VoiceFactory["handleGateway"], + ) { this.settings = usersettings; + this.handleGateway = handleGateway; } voices = new Map>(); voiceChannels = new Map(); @@ -20,19 +25,50 @@ class VoiceFactory { } const urlobj = this.guildUrlMap.get(guildid); if (!urlobj) throw new Error("url Object doesn't exist (InternalError)"); - const voice = new Voice(this.settings.id, settings, urlobj); + const voice = new Voice(this.settings.id, settings, urlobj, this); this.voiceChannels.set(channelId, voice); guild.set(channelId, voice); return voice; } onJoin = (_voice: Voice) => {}; onLeave = (_voice: Voice) => {}; + private imute = false; + get mute() { + return this.imute; + } + set mute(s) { + const changed = this.imute !== s; + this.imute = s; + if (this.currentVoice && changed) { + this.currentVoice.updateMute(); + this.updateSelf(); + } + } + updateSelf() { + if (this.currentVoice && this.currentVoice.open) { + this.handleGateway({ + op: 4, + d: { + guild_id: this.curGuild, + channel_id: this.curChan, + self_mute: this.imute, + self_deaf: false, + self_video: false, + flags: 3, + }, + }); + } + } + curGuild?: string; + curChan?: string; joinVoice(channelId: string, guildId: string, self_mute = false) { const voice = this.voiceChannels.get(channelId); + this.mute = self_mute; if (this.currentVoice && this.currentVoice.ws) { this.currentVoice.leave(); } - + this.curChan = channelId; + this.curGuild = guildId; if (!voice) throw new Error(`Voice ${channelId} does not exist`); voice.join(); this.currentVoice = voice; @@ -53,7 +89,7 @@ class VoiceFactory { voiceStateUpdate(update: voiceStatus) { const prev = this.userMap.get(update.user_id); console.log(prev, this.userMap); - if (prev) { + if (prev && update.channel_id !== this.curChan) { prev.disconnect(update.user_id); this.onLeave(prev); } @@ -93,10 +129,17 @@ class Voice { readonly userid: string; settings: {bitrate: number}; urlobj: {url?: string; token?: string; geturl: Promise; gotUrl: () => void}; - constructor(userid: string, settings: Voice["settings"], urlobj: Voice["urlobj"]) { + owner: VoiceFactory; + constructor( + userid: string, + settings: Voice["settings"], + urlobj: Voice["urlobj"], + owner: VoiceFactory, + ) { this.userid = userid; this.settings = settings; this.urlobj = urlobj; + this.owner = owner; } pc?: RTCPeerConnection; ws?: WebSocket; @@ -425,6 +468,7 @@ a=rtcp-mux\r`; if (!this.ws) return; const pair = this.ssrcMap.entries().next().value; if (!pair) return; + this.onSpeakingChange(this.userid, +this.speaking); this.ws.send( JSON.stringify({ op: 5, @@ -470,6 +514,12 @@ a=rtcp-mux\r`; } console.log(this.reciverMap); } + updateMute() { + if (!this.micTrack) return; + this.micTrack.enabled = !this.owner.mute; + } + mic?: RTCRtpSender; + micTrack?: MediaStreamTrack; async startWebRTC() { this.status = "Making offer"; const pc = new RTCPeerConnection(); @@ -497,14 +547,17 @@ a=rtcp-mux\r`; console.log(this.recivers); }; const audioStream = await navigator.mediaDevices.getUserMedia({video: false, audio: true}); - for (const track of audioStream.getAudioTracks()) { - //Add track + const [track] = audioStream.getAudioTracks(); + //Add track + + this.setupMic(audioStream); + const sender = pc.addTrack(track); + this.mic = sender; + this.micTrack = track; + track.enabled = !this.owner.mute; + this.senders.add(sender); + console.log(sender); - this.setupMic(audioStream); - const sender = pc.addTrack(track); - this.senders.add(sender); - console.log(sender); - } for (let i = 0; i < 10; i++) { pc.addTransceiver("audio", { direction: "inactive", @@ -697,9 +750,12 @@ a=rtcp-mux\r`; userids = new Map(); async voiceupdate(update: voiceStatus) { console.log("Update!"); + if (!this.userids.has(update.user_id)) { + this.onMemberChange(update?.member || update.user_id, true); + } this.userids.set(update.user_id, {deaf: update.deaf, muted: update.mute}); - this.onMemberChange(update?.member || update.user_id, true); - if (update.user_id === this.userid && this.open) { + + if (update.user_id === this.userid && this.open && !(this.status === "Done")) { if (!update) { this.status = "bad responce from WS"; return; @@ -757,6 +813,8 @@ a=rtcp-mux\r`; console.warn("leave"); this.open = false; this.status = "Left voice chat"; + this.onMemberChange(this.userid, false); + this.userids.delete(this.userid); if (this.ws) { this.ws.close(); this.ws = undefined;