From 737267567e766453836f32d4dd09bc39da8df6c6 Mon Sep 17 00:00:00 2001 From: pavel Date: Tue, 24 Feb 2026 01:02:26 +0100 Subject: [PATCH] ii --- src/voice.rs | 38 +++++++++++++++++++ static/app.js | 95 ++++++++++++++++++++++++++++++++++++++++++----- static/index.html | 1 + 3 files changed, 125 insertions(+), 9 deletions(-) diff --git a/src/voice.rs b/src/voice.rs index ae8bf7f..e327d7a 100644 --- a/src/voice.rs +++ b/src/voice.rs @@ -17,6 +17,7 @@ pub struct VoiceHub { struct ClientHandle { display_name: String, is_sharing_video: bool, + is_sharing_screen: bool, tx: mpsc::UnboundedSender, } @@ -25,6 +26,7 @@ pub struct VoiceParticipant { pub user_id: Uuid, pub display_name: String, pub is_sharing_video: bool, + pub is_sharing_screen: bool, } #[derive(Serialize, Clone)] @@ -44,6 +46,10 @@ enum ServerEvent { user_id: Uuid, is_sharing_video: bool, }, + ScreenStatusChanged { + user_id: Uuid, + is_sharing_screen: bool, + }, Signal { from_user_id: Uuid, kind: String, @@ -65,6 +71,9 @@ enum ClientEvent { SetVideoStatus { is_sharing_video: bool, }, + SetScreenStatus { + is_sharing_screen: bool, + }, } impl VoiceHub { @@ -79,6 +88,7 @@ impl VoiceHub { user_id: *user_id, display_name: handle.display_name.clone(), is_sharing_video: handle.is_sharing_video, + is_sharing_screen: handle.is_sharing_screen, }) .collect() } @@ -99,6 +109,7 @@ impl VoiceHub { user_id: *peer_id, display_name: peer.display_name.clone(), is_sharing_video: peer.is_sharing_video, + is_sharing_screen: peer.is_sharing_screen, }) .collect::>(); @@ -107,6 +118,7 @@ impl VoiceHub { ClientHandle { display_name: display_name.clone(), is_sharing_video: false, + is_sharing_screen: false, tx, }, ); @@ -181,6 +193,26 @@ impl VoiceHub { } } } + + pub async fn set_screen_status(&self, room_id: Uuid, user_id: Uuid, is_sharing_screen: bool) { + let mut rooms = self.rooms.write().await; + let Some(room) = rooms.get_mut(&room_id) else { + return; + }; + + if let Some(handle) = room.get_mut(&user_id) { + handle.is_sharing_screen = is_sharing_screen; + + for (peer_id, peer) in room.iter() { + if *peer_id != user_id { + let _ = peer.tx.send(ServerEvent::ScreenStatusChanged { + user_id, + is_sharing_screen, + }); + } + } + } + } } pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) { @@ -229,6 +261,12 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us .set_video_status(room_id, user_id, is_sharing_video) .await; } + Ok(ClientEvent::SetScreenStatus { is_sharing_screen }) => { + state + .voice + .set_screen_status(room_id, user_id, is_sharing_screen) + .await; + } Err(err) => { let _ = tx.send(ServerEvent::Error { message: format!("invalid voice message: {err}"), diff --git a/static/app.js b/static/app.js index 5d5deb4..f315327 100644 --- a/static/app.js +++ b/static/app.js @@ -16,10 +16,12 @@ const state = { localStream: null, rawStream: null, videoStream: null, + screenStream: null, audioContext: null, peerConnections: new Map(), muted: false, sharingVideo: false, + sharingScreen: false, iceServers: [{ urls: "stun:stun.l.google.com:19302" }], }, voicePresencePollId: null, @@ -62,6 +64,7 @@ const el = { voiceConnection: document.getElementById("voice-connection"), vcChannelName: document.getElementById("vc-channel-name"), voiceVideoBtn: document.getElementById("voice-video-btn"), + voiceScreenBtn: document.getElementById("voice-screen-btn"), voiceMuteBtn: document.getElementById("voice-mute-btn"), voiceLeaveBtn: document.getElementById("voice-leave-btn"), videoGrid: document.getElementById("video-grid"), @@ -563,7 +566,17 @@ function stopAndClearVideoPipeline() { } state.voice.videoStream = null; state.voice.sharingVideo = false; - document.getElementById(`video-${state.me.id}`)?.parentElement?.remove(); + document.getElementById(`video-${state.me.id}-camera`)?.parentElement?.remove(); + updateVideoGridVisibility(); +} + +function stopAndClearScreenPipeline() { + if (state.voice.screenStream) { + for (const track of state.voice.screenStream.getTracks()) track.stop(); + } + state.voice.screenStream = null; + state.voice.sharingScreen = false; + document.getElementById(`video-${state.me.id}-screen`)?.parentElement?.remove(); updateVideoGridVisibility(); } @@ -576,14 +589,16 @@ function updateVideoGridVisibility() { } } -function renderVideo(peerId, displayName, stream) { - let videoEl = document.getElementById(`video-${peerId}`); +function renderVideo(peerId, displayName, stream, source) { + const videoId = `video-${peerId}-${source}`; + let videoEl = document.getElementById(videoId); if (!videoEl) { const container = document.createElement("div"); container.className = "video-item"; + const label = source === 'screen' ? `${escapeHtml(displayName)}'s Screen` : escapeHtml(displayName); container.innerHTML = ` - -
${escapeHtml(displayName)}
+ +
${label}
`; el.videoGrid.appendChild(container); videoEl = container.querySelector("video"); @@ -646,6 +661,12 @@ function ensurePeerConnection(peerId) { } } + if (state.voice.screenStream) { + for (const track of state.voice.screenStream.getTracks()) { + pc.addTrack(track, state.voice.screenStream); + } + } + pc.onicecandidate = (event) => { if (!event.candidate || !state.voice.ws) return; state.voice.ws.send(JSON.stringify({ @@ -669,7 +690,13 @@ function ensurePeerConnection(peerId) { audio.srcObject = event.streams[0]; } else if (event.track.kind === "video") { const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" }; - renderVideo(peerId, peer.display_name, event.streams[0]); + // Distinguish screen share from camera by checking track labels or signaling + // For simplicity, we can use the stream index or a dedicated track check. + // A more robust way is to have separate transceivers/signaling. + // Here we check if the label suggests a screen. + const isScreen = event.track.label.toLowerCase().includes('screen') || + event.track.label.toLowerCase().includes('monitor'); + renderVideo(peerId, peer.display_name, event.streams[0], isScreen ? 'screen' : 'camera'); } }; @@ -785,7 +812,12 @@ async function joinVoice() { await handleSignal(msg.from_user_id, msg.kind, msg.data); } else if (msg.type === "video_status_changed") { if (!msg.is_sharing_video) { - document.getElementById(`video-${msg.user_id}`)?.parentElement?.remove(); + document.getElementById(`video-${msg.user_id}-camera`)?.parentElement?.remove(); + updateVideoGridVisibility(); + } + } else if (msg.type === "screen_status_changed") { + if (!msg.is_sharing_screen) { + document.getElementById(`video-${msg.user_id}-screen`)?.parentElement?.remove(); updateVideoGridVisibility(); } } @@ -825,9 +857,10 @@ async function toggleVideo() { if (state.voice.ws) { state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false })); } + const trackId = `video-${state.me.id}-camera`; for (const pc of state.voice.peerConnections.values()) { const senders = pc.getSenders(); - const videoSender = senders.find(s => s.track && s.track.kind === "video"); + const videoSender = senders.find(s => s.track && s.track.kind === "video" && !s.track.label.toLowerCase().includes('screen')); if (videoSender) pc.removeTrack(videoSender); } } else { @@ -835,7 +868,7 @@ async function toggleVideo() { const stream = await navigator.mediaDevices.getUserMedia({ video: true }); state.voice.videoStream = stream; state.voice.sharingVideo = true; - renderVideo(state.me.id, state.me.display_name, stream); + renderVideo(state.me.id, state.me.display_name, stream, 'camera'); if (state.voice.ws) { state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true })); @@ -858,6 +891,49 @@ async function toggleVideo() { lucide.createIcons(); } +async function toggleScreenShare() { + if (state.voice.sharingScreen) { + stopAndClearScreenPipeline(); + if (state.voice.ws) { + state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: false })); + } + for (const pc of state.voice.peerConnections.values()) { + const senders = pc.getSenders(); + const screenSender = senders.find(s => s.track && s.track.kind === "video" && (s.track.label.toLowerCase().includes('screen') || s.track.label.toLowerCase().includes('monitor'))); + if (screenSender) pc.removeTrack(screenSender); + } + } else { + try { + const stream = await navigator.mediaDevices.getDisplayMedia({ video: true }); + state.voice.screenStream = stream; + state.voice.sharingScreen = true; + renderVideo(state.me.id, state.me.display_name, stream, 'screen'); + + // Stop sharing if user clicks "Stop sharing" in browser UI + stream.getVideoTracks()[0].onended = () => { + if (state.voice.sharingScreen) toggleScreenShare(); + }; + + if (state.voice.ws) { + state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: true })); + } + + for (const pc of state.voice.peerConnections.values()) { + for (const track of stream.getTracks()) { + pc.addTrack(track, stream); + } + } + } catch (err) { + console.error("screen share denied", err); + return; + } + } + + el.voiceScreenBtn.innerHTML = state.voice.sharingScreen ? '' : ''; + el.voiceScreenBtn.style.color = state.voice.sharingScreen ? 'var(--green)' : 'var(--text-muted)'; + lucide.createIcons(); +} + // --- Mobile Logic --- function toggleMobileMenu() { @@ -1032,6 +1108,7 @@ async function init() { }; el.voiceVideoBtn.onclick = toggleVideo; + el.voiceScreenBtn.onclick = toggleScreenShare; el.voiceMuteBtn.onclick = toggleMute; el.voiceLeaveBtn.onclick = leaveVoice; diff --git a/static/index.html b/static/index.html index c539130..5bab6c8 100644 --- a/static/index.html +++ b/static/index.html @@ -86,6 +86,7 @@
+