bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

commit 7e56a8b26e7d3b869fd82b3b8331d3720a768f68
parent b00e6a7ffc16ced332fd4d9cf82cb05dedf97c30
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date:   Sun, 22 Feb 2026 12:46:52 -0700

allow stream guests to join audio

Diffstat:
Mpublic/app.js | 327++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpublic/index.html | 11+++++++++++
Mpublic/styles.css | 35+++++++++++++++++++++++++++++++++++
Mserver.js | 62+++++++++++++++++++++++++++++++++++++++-----------------------
4 files changed, 393 insertions(+), 42 deletions(-)

diff --git a/public/app.js b/public/app.js @@ -152,6 +152,11 @@ const streamStagePrimaryBtn = document.getElementById("streamStagePrimary"); const streamStageVideoEl = document.getElementById("streamStageVideo"); const streamStageAudioEl = document.getElementById("streamStageAudio"); const streamStagePlaceholderEl = document.getElementById("streamStagePlaceholder"); +const streamVoiceControlsEl = document.getElementById("streamVoiceControls"); +const streamVoiceJoinToggleEl = document.getElementById("streamVoiceJoinToggle"); +const streamVoiceMuteBtn = document.getElementById("streamVoiceMuteBtn"); +const streamVoiceDeafenBtn = document.getElementById("streamVoiceDeafenBtn"); +const streamVoiceUsersEl = document.getElementById("streamVoiceUsers"); const chatMessagesEl = document.getElementById("chatMessages"); const typingIndicator = document.getElementById("typingIndicator"); const chatForm = document.getElementById("chatForm"); @@ -324,6 +329,14 @@ let streamLocalMedia = null; let streamRemoteMedia = null; let streamRemoteKind = "webcam"; const streamPeerByClientId = new Map(); +const streamRemoteMediaByClientId = new Map(); +const streamPeerUsernameByClientId = new Map(); +const streamRemoteAudioByClientId = new Map(); +const streamPeerVolumeByClientId = new Map(); +let streamVoiceMedia = null; +let streamVoiceJoined = false; +let streamVoiceMuted = false; +let streamVoiceDeafened = false; const SESSION_TOKEN_KEY = "bzl_session_token"; const CLIENT_IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024; const CLIENT_AUDIO_UPLOAD_MAX_BYTES = 150 * 1024 * 1024; @@ -8881,8 +8894,21 @@ function closeStreamPeer(clientId) { const id = String(clientId || "").trim(); if (!id) return; const pc = streamPeerByClientId.get(id); - if (!pc) return; streamPeerByClientId.delete(id); + streamRemoteMediaByClientId.delete(id); + streamPeerUsernameByClientId.delete(id); + const remoteAudioEl = streamRemoteAudioByClientId.get(id); + if (remoteAudioEl) { + try { + remoteAudioEl.pause(); + } catch { + // ignore + } + remoteAudioEl.srcObject = null; + remoteAudioEl.remove(); + } + streamRemoteAudioByClientId.delete(id); + if (!pc) return; try { pc.onicecandidate = null; pc.onconnectionstatechange = null; @@ -8921,6 +8947,131 @@ function clearStreamMediaPreview() { streamRemoteMedia = null; } +function ensureRemoteAudioEl(clientId) { + const id = String(clientId || "").trim(); + if (!id) return null; + const existing = streamRemoteAudioByClientId.get(id); + if (existing) return existing; + const el = document.createElement("audio"); + el.autoplay = true; + el.controls = false; + el.preload = "none"; + el.className = "streamStageAudio hidden"; + streamRemoteAudioByClientId.set(id, el); + streamStageEl?.appendChild(el); + return el; +} + +function updateStreamOutputMuteState() { + const deafened = Boolean(streamVoiceDeafened); + const hostingPreview = streamCurrentRole === "host"; + if (streamStageAudioEl) streamStageAudioEl.muted = deafened || hostingPreview; + if (streamStageVideoEl) streamStageVideoEl.muted = deafened || hostingPreview; + for (const el of streamRemoteAudioByClientId.values()) { + el.muted = deafened; + } +} + +function updateStreamLocalMuteState() { + const media = streamCurrentRole === "host" ? streamLocalMedia : streamVoiceMedia; + const tracks = media && typeof media.getAudioTracks === "function" ? media.getAudioTracks() : []; + for (const track of tracks) track.enabled = !streamVoiceMuted; +} + +function streamDisplayNameForClientId(peerClientId) { + const id = String(peerClientId || "").trim(); + if (!id) return "Unknown"; + if (id === String(clientId || "").trim()) return `${loggedInUser ? `@${loggedInUser}` : "You"} (you)`; + const username = String(streamPeerUsernameByClientId.get(id) || "").trim(); + return username ? `@${username}` : `Peer ${id.slice(0, 6)}`; +} + +function renderStreamVoiceUsers() { + if (!streamVoiceUsersEl) return; + const rows = []; + for (const [peerId, audioEl] of streamRemoteAudioByClientId.entries()) { + if (!audioEl || peerId === streamCurrentHostClientId) continue; + const vol = Math.max(0, Math.min(1, Number(streamPeerVolumeByClientId.get(peerId) ?? audioEl.volume ?? 1))); + rows.push({ peerId, label: streamDisplayNameForClientId(peerId), vol }); + } + if (!rows.length) { + streamVoiceUsersEl.innerHTML = `<div>No active voice peers.</div>`; + return; + } + streamVoiceUsersEl.innerHTML = rows + .map( + (row) => `<label class="streamVoiceUserRow"> + <span>${escapeHtml(row.label)}</span> + <input type="range" min="0" max="100" step="1" value="${escapeHtml(String(Math.round(row.vol * 100)))}" data-streamvol="${escapeHtml(row.peerId)}" /> + </label>` + ) + .join(""); +} + +function renderStreamVoiceControls(post, isHosting, isViewing) { + if (!streamVoiceControlsEl) return; + const streamPost = post && isStreamPost(post) ? post : null; + const enabled = Boolean(streamPost && (isHosting || isViewing)); + streamVoiceControlsEl.classList.toggle("hidden", !enabled); + if (!enabled) return; + if (streamVoiceJoinToggleEl) { + streamVoiceJoinToggleEl.disabled = isHosting; + streamVoiceJoinToggleEl.checked = isHosting ? true : Boolean(streamVoiceJoined); + } + if (streamVoiceMuteBtn) { + streamVoiceMuteBtn.disabled = !(isHosting || streamVoiceJoined); + streamVoiceMuteBtn.textContent = streamVoiceMuted ? "Unmute mic" : "Mute mic"; + } + if (streamVoiceDeafenBtn) { + streamVoiceDeafenBtn.disabled = !(isHosting || isViewing); + streamVoiceDeafenBtn.textContent = streamVoiceDeafened ? "Undeafen" : "Deafen"; + } + renderStreamVoiceUsers(); +} + +function addVoiceTracksToPeer(peerClientId) { + const id = String(peerClientId || "").trim(); + if (!id) return; + const pc = streamPeerByClientId.get(id); + if (!pc) return; + const media = streamCurrentRole === "host" ? streamLocalMedia : streamVoiceMedia; + if (!media || typeof media.getAudioTracks !== "function") return; + const tracks = media.getAudioTracks(); + if (!tracks.length) return; + for (const track of tracks) { + const hasSender = pc + .getSenders() + .some((sender) => sender?.track && sender.track.id === track.id && sender.track.kind === "audio"); + if (hasSender) continue; + try { + pc.addTrack(track, media); + } catch { + // ignore addTrack failures + } + } +} + +async function renegotiateStreamPeer(peerClientId) { + const id = String(peerClientId || "").trim(); + if (!id) return; + const pc = streamPeerByClientId.get(id); + if (!pc || !streamCurrentPostId || !ws || ws.readyState !== WebSocket.OPEN) return; + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + ws.send( + JSON.stringify({ + type: "streamSignal", + postId: streamCurrentPostId, + targetClientId: id, + signal: { type: "offer", sdp: String(offer.sdp || "") }, + }) + ); + } catch (e) { + console.warn("stream renegotiation failed:", e?.message || e); + } +} + function attachStreamPreview(stream, kind, local = false) { const streamObj = stream && typeof stream.getTracks === "function" ? stream : null; if (!streamObj) { @@ -8940,6 +9091,7 @@ function attachStreamPreview(stream, kind, local = false) { streamStageAudioEl.muted = Boolean(local); streamStageAudioEl.play?.().catch(() => {}); } + updateStreamOutputMuteState(); return; } if (streamStageAudioEl) { @@ -8952,6 +9104,7 @@ function attachStreamPreview(stream, kind, local = false) { streamStageVideoEl.muted = Boolean(local); streamStageVideoEl.play?.().catch(() => {}); } + updateStreamOutputMuteState(); } function streamCanHostPost(post) { @@ -8963,13 +9116,22 @@ function streamCanHostPost(post) { function streamResetState(keepPostId = false) { closeAllStreamPeers(); stopStreamTracks(streamLocalMedia); + stopStreamTracks(streamVoiceMedia); streamLocalMedia = null; + streamVoiceMedia = null; + streamVoiceJoined = false; + streamVoiceMuted = false; + streamVoiceDeafened = false; streamRemoteMedia = null; streamRemoteHostClientId = ""; streamCurrentHostClientId = ""; streamCurrentRole = "idle"; + streamRemoteMediaByClientId.clear(); + streamPeerUsernameByClientId.clear(); + streamPeerVolumeByClientId.clear(); if (!keepPostId) streamCurrentPostId = ""; clearStreamMediaPreview(); + renderStreamVoiceUsers(); } function leaveActiveStream(sendSignal = true) { @@ -9012,12 +9174,24 @@ function createStreamPeer(targetClientId) { ); }; pc.ontrack = (evt) => { - if (streamCurrentRole !== "viewer") return; const remote = evt.streams && evt.streams[0] ? evt.streams[0] : null; if (!remote) return; - streamRemoteMedia = remote; - attachStreamPreview(remote, streamRemoteKind, false); - if (streamStagePlaceholderEl) streamStagePlaceholderEl.classList.add("hidden"); + streamRemoteMediaByClientId.set(target, remote); + if (target === streamCurrentHostClientId && streamCurrentRole !== "host") { + streamRemoteMedia = remote; + attachStreamPreview(remote, streamRemoteKind, false); + if (streamStagePlaceholderEl) streamStagePlaceholderEl.classList.add("hidden"); + } else { + const remoteAudioEl = ensureRemoteAudioEl(target); + if (remoteAudioEl) { + remoteAudioEl.srcObject = remote; + const savedVolume = Number(streamPeerVolumeByClientId.get(target)); + remoteAudioEl.volume = Number.isFinite(savedVolume) ? Math.max(0, Math.min(1, savedVolume)) : 1; + remoteAudioEl.play?.().catch(() => {}); + } + updateStreamOutputMuteState(); + renderStreamVoiceUsers(); + } }; pc.onconnectionstatechange = () => { const state = String(pc.connectionState || ""); @@ -9031,6 +9205,8 @@ function createStreamPeer(targetClientId) { // ignore } } + } else if (streamVoiceJoined && streamVoiceMedia) { + addVoiceTracksToPeer(target); } return pc; } @@ -9048,6 +9224,7 @@ async function handleStreamSignalMessage(msg) { try { if (type === "offer") { await pc.setRemoteDescription(new RTCSessionDescription({ type: "offer", sdp: String(signal.sdp || "") })); + if (streamCurrentRole === "host" || streamVoiceJoined) addVoiceTracksToPeer(fromClientId); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); ws?.send( @@ -9072,27 +9249,32 @@ async function handleStreamSignalMessage(msg) { } } -async function handleStreamViewerJoinMessage(msg) { +async function handleStreamPeerJoinMessage(msg) { const postId = String(msg.postId || "").trim(); - const viewerClientId = String(msg.viewerClientId || "").trim(); - if (!postId || !viewerClientId) return; - if (streamCurrentRole !== "host" || streamCurrentPostId !== postId) return; - const pc = createStreamPeer(viewerClientId); + const peerClientId = String(msg.peerClientId || msg.viewerClientId || "").trim(); + const peerUsername = String(msg.peerUsername || msg.viewerUsername || "").trim(); + const initiateOffer = msg.initiateOffer === true; + if (!postId || !peerClientId) return; + if (streamCurrentRole === "idle" || streamCurrentPostId !== postId) return; + if (peerUsername) streamPeerUsernameByClientId.set(peerClientId, peerUsername); + const pc = createStreamPeer(peerClientId); if (!pc) return; + if (!initiateOffer) return; try { + if (streamCurrentRole === "host" || streamVoiceJoined) addVoiceTracksToPeer(peerClientId); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); ws?.send( JSON.stringify({ type: "streamSignal", postId, - targetClientId: viewerClientId, + targetClientId: peerClientId, signal: { type: "offer", sdp: String(offer.sdp || "") }, }) ); } catch (e) { console.warn("stream offer failed:", e?.message || e); - closeStreamPeer(viewerClientId); + closeStreamPeer(peerClientId); } } @@ -9144,6 +9326,49 @@ async function ensureStreamAudioForKind(media, kind, opts = null) { return media; } +async function enableStreamVoice() { + if (streamCurrentRole !== "viewer" || !streamCurrentPostId) return false; + if (streamVoiceJoined && streamVoiceMedia) { + updateStreamLocalMuteState(); + return true; + } + if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") { + toast("Voice", "Microphone access is not available in this browser."); + return false; + } + try { + const media = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + if (!media.getAudioTracks().length) { + stopStreamTracks(media); + toast("Voice", "No microphone track available."); + return false; + } + streamVoiceMedia = media; + streamVoiceJoined = true; + updateStreamLocalMuteState(); + for (const peerId of streamPeerByClientId.keys()) { + addVoiceTracksToPeer(peerId); + renegotiateStreamPeer(peerId); + } + renderChatPanel(false); + return true; + } catch (e) { + toast("Voice", String(e?.message || "Unable to access microphone.")); + return false; + } +} + +function disableStreamVoice() { + if (!streamVoiceJoined && !streamVoiceMedia) return; + streamVoiceJoined = false; + stopStreamTracks(streamVoiceMedia); + streamVoiceMedia = null; + for (const peerId of streamPeerByClientId.keys()) { + renegotiateStreamPeer(peerId); + } + renderChatPanel(false); +} + async function startStreamHost(post) { if (!post || !isStreamPost(post)) return; if (!streamEnabled) { @@ -9194,6 +9419,11 @@ async function startStreamHost(post) { streamRemoteKind = kind; streamCurrentHostClientId = String(clientId || ""); streamLocalMedia = media; + streamVoiceJoined = true; + streamVoiceMuted = false; + streamVoiceDeafened = false; + updateStreamLocalMuteState(); + updateStreamOutputMuteState(); for (const track of media.getTracks()) { track.onended = () => { if (streamCurrentRole === "host" && streamCurrentPostId === String(post.id || "")) leaveActiveStream(true); @@ -9226,6 +9456,9 @@ function joinStream(post) { streamRemoteKind = normalizeStreamKind(post.streamKind || "webcam"); streamCurrentHostClientId = ""; streamRemoteHostClientId = ""; + streamVoiceJoined = false; + streamVoiceMuted = false; + streamVoiceDeafened = false; if (streamStagePlaceholderEl) { streamStagePlaceholderEl.classList.remove("hidden"); streamStagePlaceholderEl.textContent = "Connecting to stream..."; @@ -9238,6 +9471,7 @@ function renderStreamStage(post) { const streamPost = post && isStreamPost(post) ? post : null; if (!streamPost) { if (streamStageEl) streamStageEl.classList.add("hidden"); + if (streamVoiceControlsEl) streamVoiceControlsEl.classList.add("hidden"); return; } if (streamStageEl) streamStageEl.classList.remove("hidden"); @@ -9252,7 +9486,7 @@ function renderStreamStage(post) { } if (streamStageStatusEl) { if (isHosting) streamStageStatusEl.textContent = "You are live."; - else if (isViewing) streamStageStatusEl.textContent = streamRemoteMedia ? "Watching live stream." : "Connecting..."; + else if (isViewing) streamStageStatusEl.textContent = streamRemoteMedia ? "Watching live stream. Voice room connected." : "Connecting..."; else if (live) streamStageStatusEl.textContent = "Live now. Join to watch."; else if (canHost) streamStageStatusEl.textContent = "Offline. Start a stream for this hive."; else streamStageStatusEl.textContent = "Stream is offline."; @@ -9278,6 +9512,7 @@ function renderStreamStage(post) { ? "Tap Go live to start screen share, webcam, or audio stream." : "Waiting for the stream owner to go live."; } + renderStreamVoiceControls(streamPost, isHosting, isViewing); } function canWalkieTalkNow() { @@ -11531,20 +11766,32 @@ function onWsMessage(evt) { streamCurrentHostClientId = String(msg.hostClientId || ""); streamRemoteHostClientId = streamCurrentHostClientId; streamRemoteKind = normalizeStreamKind(msg.kind || "webcam"); + const peers = Array.isArray(msg.peerClientIds) ? msg.peerClientIds : []; + const peerUsers = msg.peerUsernames && typeof msg.peerUsernames === "object" ? msg.peerUsernames : {}; + for (const peerIdRaw of peers) { + const peerId = String(peerIdRaw || "").trim(); + if (!peerId || peerId === String(clientId || "")) continue; + const peerName = String(peerUsers[peerId] || "").trim(); + if (peerName) streamPeerUsernameByClientId.set(peerId, peerName); + handleStreamPeerJoinMessage({ postId, peerClientId: peerId, peerUsername: peerName, initiateOffer: true }); + } renderChatPanel(false); return; } - if (msg.type === "streamViewerJoin") { - handleStreamViewerJoinMessage(msg); + if (msg.type === "streamPeerJoin" || msg.type === "streamViewerJoin") { + handleStreamPeerJoinMessage(msg); return; } - if (msg.type === "streamViewerLeave") { + if (msg.type === "streamPeerLeave" || msg.type === "streamViewerLeave") { const postId = String(msg.postId || "").trim(); - const viewerClientId = String(msg.viewerClientId || "").trim(); - if (!postId || !viewerClientId) return; - if (streamCurrentRole === "host" && streamCurrentPostId === postId) closeStreamPeer(viewerClientId); + const peerClientId = String(msg.peerClientId || msg.viewerClientId || "").trim(); + if (!postId || !peerClientId) return; + if (streamCurrentPostId === postId) { + closeStreamPeer(peerClientId); + renderStreamVoiceUsers(); + } return; } @@ -12690,6 +12937,48 @@ streamStagePrimaryBtn?.addEventListener("click", () => { else startStreamHost(post); }); +streamVoiceJoinToggleEl?.addEventListener("change", async () => { + const enabled = Boolean(streamVoiceJoinToggleEl.checked); + if (streamCurrentRole !== "viewer") { + streamVoiceJoinToggleEl.checked = streamCurrentRole === "host"; + return; + } + if (enabled) { + const ok = await enableStreamVoice(); + if (!ok) streamVoiceJoinToggleEl.checked = false; + } else { + disableStreamVoice(); + streamVoiceJoinToggleEl.checked = false; + } + renderChatPanel(false); +}); + +streamVoiceMuteBtn?.addEventListener("click", () => { + if (!(streamCurrentRole === "host" || streamVoiceJoined)) return; + streamVoiceMuted = !streamVoiceMuted; + updateStreamLocalMuteState(); + renderChatPanel(false); +}); + +streamVoiceDeafenBtn?.addEventListener("click", () => { + if (!(streamCurrentRole === "host" || streamCurrentRole === "viewer")) return; + streamVoiceDeafened = !streamVoiceDeafened; + updateStreamOutputMuteState(); + renderChatPanel(false); +}); + +streamVoiceUsersEl?.addEventListener("input", (e) => { + const input = e.target; + if (!(input instanceof HTMLInputElement)) return; + const peerId = String(input.getAttribute("data-streamvol") || "").trim(); + if (!peerId) return; + const volPct = Math.max(0, Math.min(100, Number(input.value) || 0)); + const vol = volPct / 100; + streamPeerVolumeByClientId.set(peerId, vol); + const audioEl = streamRemoteAudioByClientId.get(peerId); + if (audioEl) audioEl.volume = vol; +}); + walkieRecordBtn?.addEventListener("pointerdown", (e) => { e.preventDefault(); startWalkieRecording(); diff --git a/public/index.html b/public/index.html @@ -403,6 +403,17 @@ <button id="streamStagePrimary" class="ghost smallBtn" type="button">Join stream</button> </div> <div id="streamStageStatus" class="small muted">Stream is offline.</div> + <div id="streamVoiceControls" class="streamVoiceControls hidden" aria-label="Voice controls"> + <label class="checkRow streamVoiceToggleRow"> + <span>Join voice</span> + <input id="streamVoiceJoinToggle" type="checkbox" /> + </label> + <div class="row streamVoiceButtonsRow"> + <button id="streamVoiceMuteBtn" class="ghost smallBtn" type="button">Mute mic</button> + <button id="streamVoiceDeafenBtn" class="ghost smallBtn" type="button">Deafen</button> + </div> + <div id="streamVoiceUsers" class="streamVoiceUsers small muted"></div> + </div> <video id="streamStageVideo" class="streamStageVideo hidden" playsinline autoplay controls muted></video> <audio id="streamStageAudio" class="streamStageAudio hidden" autoplay controls></audio> <div id="streamStagePlaceholder" class="streamStagePlaceholder small muted">Open a stream hive to watch, or start a stream if you own the post.</div> diff --git a/public/styles.css b/public/styles.css @@ -1470,6 +1470,10 @@ a.poweredByTile:hover { display: flex; flex-direction: column; gap: 8px; + position: sticky; + top: 0; + z-index: 4; + backdrop-filter: blur(2px); } .streamStageHeader { @@ -1503,6 +1507,37 @@ a.poweredByTile:hover { background: rgba(0, 0, 0, 0.12); } +.streamVoiceControls { + border: 1px solid rgba(246, 240, 255, 0.14); + border-radius: 12px; + background: rgba(0, 0, 0, 0.16); + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.streamVoiceToggleRow { + padding: 8px 10px; +} + +.streamVoiceButtonsRow { + gap: 8px; +} + +.streamVoiceUsers { + display: flex; + flex-direction: column; + gap: 6px; +} + +.streamVoiceUserRow { + display: grid; + grid-template-columns: 1fr minmax(90px, 170px); + align-items: center; + gap: 8px; +} + .app.sidebarHidden .sidebar { display: none; } diff --git a/server.js b/server.js @@ -2845,17 +2845,24 @@ function endStreamSession(postId, reason = "ended") { return true; } -function detachViewerFromStream(postId, clientId, notifyHost = true) { +function notifyStreamPeerLeave(postId, session, leavingClientId) { + if (!session) return; + const targets = new Set([session.hostClientId, ...(session.viewers || [])]); + targets.delete(leavingClientId); + const payload = JSON.stringify({ type: "streamPeerLeave", postId, peerClientId: leavingClientId }); + for (const targetId of targets) { + const target = findSocketByClientId(targetId); + if (!target || target.readyState !== target.OPEN) continue; + target.send(payload); + } +} + +function detachViewerFromStream(postId, clientId, notifyPeers = true) { const session = streamSessionsByPostId.get(postId); if (!session) return false; if (!session.viewers.has(clientId)) return false; session.viewers.delete(clientId); - if (notifyHost) { - const host = findSocketByClientId(session.hostClientId); - if (host && host.readyState === host.OPEN) { - host.send(JSON.stringify({ type: "streamViewerLeave", postId, viewerClientId: clientId })); - } - } + if (notifyPeers) notifyStreamPeerLeave(postId, session, clientId); sendStreamState(postId); return true; } @@ -5112,6 +5119,16 @@ wss.on("connection", (ws, req) => { ws.send(JSON.stringify({ type: "streamJoinAck", postId, live: false })); return; } + const participantIds = new Set([session.hostClientId, ...(session.viewers || [])]); + participantIds.delete(ws.clientId); + const peerClientIds = []; + const peerUsernames = {}; + for (const peerId of participantIds) { + const peerWs = findSocketByClientId(peerId); + if (!peerWs) continue; + peerClientIds.push(peerId); + peerUsernames[peerId] = normalizeUsername(peerWs.user?.username || "") || ""; + } if (session.hostClientId !== ws.clientId) { session.viewers.add(ws.clientId); } @@ -5123,20 +5140,22 @@ wss.on("connection", (ws, req) => { hostClientId: session.hostClientId, hostUsername: session.hostUsername, kind: sanitizePostStreamKind(POST_MODE_STREAM, session.kind), - viewerCount: session.viewers.size + viewerCount: session.viewers.size, + peerClientIds, + peerUsernames }) ); if (session.hostClientId !== ws.clientId) { - const host = findSocketByClientId(session.hostClientId); - if (host && host.readyState === host.OPEN) { - host.send( - JSON.stringify({ - type: "streamViewerJoin", - postId, - viewerClientId: ws.clientId, - viewerUsername: normalizeUsername(ws.user?.username || "") || "" - }) - ); + const peerJoinPayload = JSON.stringify({ + type: "streamPeerJoin", + postId, + peerClientId: ws.clientId, + peerUsername: normalizeUsername(ws.user?.username || "") || "" + }); + for (const peerId of participantIds) { + const peerWs = findSocketByClientId(peerId); + if (!peerWs || peerWs.readyState !== peerWs.OPEN) continue; + peerWs.send(peerJoinPayload); } } sendStreamState(postId); @@ -5159,11 +5178,8 @@ wss.on("connection", (ws, req) => { const signal = msg.signal; if (!signal || typeof signal !== "object") return; - const senderIsHost = session.hostClientId === ws.clientId; - const senderIsViewer = session.viewers.has(ws.clientId); - if (!senderIsHost && !senderIsViewer) return; - if (senderIsHost && !session.viewers.has(targetClientId)) return; - if (senderIsViewer && targetClientId !== session.hostClientId) return; + const participants = new Set([session.hostClientId, ...(session.viewers || [])]); + if (!participants.has(ws.clientId) || !participants.has(targetClientId)) return; const target = findSocketByClientId(targetClientId); if (!target || target.readyState !== target.OPEN) return;