diff --git a/src/voice.rs b/src/voice.rs index 98d9fb9..ae8bf7f 100644 --- a/src/voice.rs +++ b/src/voice.rs @@ -16,6 +16,7 @@ pub struct VoiceHub { #[derive(Clone)] struct ClientHandle { display_name: String, + is_sharing_video: bool, tx: mpsc::UnboundedSender, } @@ -23,20 +24,34 @@ struct ClientHandle { pub struct VoiceParticipant { pub user_id: Uuid, pub display_name: String, + pub is_sharing_video: bool, } #[derive(Serialize, Clone)] #[serde(tag = "type", rename_all = "snake_case")] enum ServerEvent { - Peers { peers: Vec }, - PeerJoined { user_id: Uuid, display_name: String }, - PeerLeft { user_id: Uuid }, + Peers { + peers: Vec, + }, + PeerJoined { + user_id: Uuid, + display_name: String, + }, + PeerLeft { + user_id: Uuid, + }, + VideoStatusChanged { + user_id: Uuid, + is_sharing_video: bool, + }, Signal { from_user_id: Uuid, kind: String, data: serde_json::Value, }, - Error { message: String }, + Error { + message: String, + }, } #[derive(Deserialize)] @@ -47,6 +62,9 @@ enum ClientEvent { kind: String, data: serde_json::Value, }, + SetVideoStatus { + is_sharing_video: bool, + }, } impl VoiceHub { @@ -60,6 +78,7 @@ impl VoiceHub { .map(|(user_id, handle)| VoiceParticipant { user_id: *user_id, display_name: handle.display_name.clone(), + is_sharing_video: handle.is_sharing_video, }) .collect() } @@ -79,6 +98,7 @@ impl VoiceHub { .map(|(peer_id, peer)| VoiceParticipant { user_id: *peer_id, display_name: peer.display_name.clone(), + is_sharing_video: peer.is_sharing_video, }) .collect::>(); @@ -86,6 +106,7 @@ impl VoiceHub { user_id, ClientHandle { display_name: display_name.clone(), + is_sharing_video: false, tx, }, ); @@ -119,7 +140,7 @@ impl VoiceHub { } } - async fn relay_signal( + pub async fn relay_signal( &self, room_id: Uuid, from_user_id: Uuid, @@ -140,6 +161,26 @@ impl VoiceHub { }); } } + + pub async fn set_video_status(&self, room_id: Uuid, user_id: Uuid, is_sharing_video: 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_video = is_sharing_video; + + for (peer_id, peer) in room.iter() { + if *peer_id != user_id { + let _ = peer.tx.send(ServerEvent::VideoStatusChanged { + user_id, + is_sharing_video, + }); + } + } + } + } } pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) { @@ -182,6 +223,12 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us .relay_signal(room_id, user_id, to_user_id, kind, data) .await; } + Ok(ClientEvent::SetVideoStatus { is_sharing_video }) => { + state + .voice + .set_video_status(room_id, user_id, is_sharing_video) + .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 9cb45e0..fca5265 100644 --- a/static/app.js +++ b/static/app.js @@ -15,9 +15,11 @@ const state = { joinedChannelId: null, localStream: null, rawStream: null, + videoStream: null, audioContext: null, peerConnections: new Map(), muted: false, + sharingVideo: false, iceServers: [{ urls: "stun:stun.l.google.com:19302" }], }, voicePresencePollId: null, @@ -59,8 +61,10 @@ const el = { // Voice Connection voiceConnection: document.getElementById("voice-connection"), vcChannelName: document.getElementById("vc-channel-name"), + voiceVideoBtn: document.getElementById("voice-video-btn"), voiceMuteBtn: document.getElementById("voice-mute-btn"), voiceLeaveBtn: document.getElementById("voice-leave-btn"), + videoGrid: document.getElementById("video-grid"), // Members memberList: document.getElementById("member-list"), @@ -553,6 +557,41 @@ function stopAndClearAudioPipeline() { state.voice.audioContext = null; } +function stopAndClearVideoPipeline() { + if (state.voice.videoStream) { + for (const track of state.voice.videoStream.getTracks()) track.stop(); + } + state.voice.videoStream = null; + state.voice.sharingVideo = false; + document.getElementById(`video-${state.me.id}`)?.parentElement?.remove(); + updateVideoGridVisibility(); +} + +function updateVideoGridVisibility() { + const hasVideos = el.videoGrid.children.length > 0; + if (hasVideos) { + el.videoGrid.classList.remove("hidden"); + } else { + el.videoGrid.classList.add("hidden"); + } +} + +function renderVideo(peerId, displayName, stream) { + let videoEl = document.getElementById(`video-${peerId}`); + if (!videoEl) { + const container = document.createElement("div"); + container.className = "video-item"; + container.innerHTML = ` + +
${escapeHtml(displayName)}
+ `; + el.videoGrid.appendChild(container); + videoEl = container.querySelector("video"); + } + videoEl.srcObject = stream; + updateVideoGridVisibility(); +} + async function buildAudioPipeline(rawStream) { const audioContext = new AudioContext(); const source = audioContext.createMediaStreamSource(rawStream); @@ -601,6 +640,12 @@ function ensurePeerConnection(peerId) { } } + if (state.voice.videoStream) { + for (const track of state.voice.videoStream.getTracks()) { + pc.addTrack(track, state.voice.videoStream); + } + } + pc.onicecandidate = (event) => { if (!event.candidate || !state.voice.ws) return; state.voice.ws.send(JSON.stringify({ @@ -612,15 +657,30 @@ function ensurePeerConnection(peerId) { }; pc.ontrack = (event) => { - let audio = document.getElementById(`audio-${peerId}`); - if (!audio) { - audio = document.createElement("audio"); - audio.id = `audio-${peerId}`; - audio.autoplay = true; - audio.playsInline = true; - document.body.appendChild(audio); + if (event.track.kind === "audio") { + let audio = document.getElementById(`audio-${peerId}`); + if (!audio) { + audio = document.createElement("audio"); + audio.id = `audio-${peerId}`; + audio.autoplay = true; + audio.playsInline = true; + document.body.appendChild(audio); + } + 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]); + } + }; + + pc.onnegotiationneeded = async () => { + try { + if (shouldInitiateOffer(peerId)) { + await sendOffer(peerId); + } + } catch (err) { + console.error("negotiation failed", err); } - audio.srcObject = event.streams[0]; }; state.voice.peerConnections.set(peerId, pc); @@ -707,6 +767,11 @@ async function joinVoice() { document.getElementById(`audio-${msg.user_id}`)?.remove(); } else if (msg.type === "signal") { 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(); + updateVideoGridVisibility(); + } } refreshVoicePresence().catch(() => { }); }; @@ -738,6 +803,45 @@ function toggleMute() { lucide.createIcons(); } +async function toggleVideo() { + if (state.voice.sharingVideo) { + stopAndClearVideoPipeline(); + if (state.voice.ws) { + state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false })); + } + for (const pc of state.voice.peerConnections.values()) { + const senders = pc.getSenders(); + const videoSender = senders.find(s => s.track && s.track.kind === "video"); + if (videoSender) pc.removeTrack(videoSender); + } + } else { + try { + 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); + + if (state.voice.ws) { + state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true })); + } + + for (const pc of state.voice.peerConnections.values()) { + for (const track of stream.getTracks()) { + pc.addTrack(track, stream); + } + } + } catch (err) { + console.error("camera denied", err); + alert("Could not access camera."); + return; + } + } + + el.voiceVideoBtn.innerHTML = state.voice.sharingVideo ? '' : ''; + el.voiceVideoBtn.style.color = state.voice.sharingVideo ? 'var(--green)' : 'var(--text-muted)'; + lucide.createIcons(); +} + // --- Mobile Logic --- function toggleMobileMenu() { @@ -911,6 +1015,7 @@ async function init() { } catch (err) { alert(err.message); } }; + el.voiceVideoBtn.onclick = toggleVideo; el.voiceMuteBtn.onclick = toggleMute; el.voiceLeaveBtn.onclick = leaveVoice; diff --git a/static/index.html b/static/index.html index 7a2a85f..c539130 100644 --- a/static/index.html +++ b/static/index.html @@ -85,6 +85,7 @@ General
+
@@ -121,6 +122,7 @@ +
diff --git a/static/styles.css b/static/styles.css index 508c91a..cab7a30 100644 --- a/static/styles.css +++ b/static/styles.css @@ -524,6 +524,47 @@ select { color: var(--text-normal); } +/* Video Grid */ +.video-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; + padding: 16px; + background: var(--bg-tertiary); + max-height: 60vh; + overflow-y: auto; + border-bottom: 1px solid rgba(0, 0, 0, 0.2); +} + +.video-grid.hidden { + display: none; +} + +.video-item { + position: relative; + aspect-ratio: 16 / 9; + background: #000; + border-radius: 8px; + overflow: hidden; +} + +.video-item video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.video-item .video-label { + position: absolute; + bottom: 8px; + left: 8px; + background: rgba(0, 0, 0, 0.5); + color: #fff; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; +} + /* Chat Pane */ .chat-pane { display: flex;