ii
All checks were successful
/ upload (release) Successful in 18s

This commit is contained in:
pavel 2026-02-24 01:02:26 +01:00
commit 737267567e
3 changed files with 125 additions and 9 deletions

View file

@ -17,6 +17,7 @@ pub struct VoiceHub {
struct ClientHandle {
display_name: String,
is_sharing_video: bool,
is_sharing_screen: bool,
tx: mpsc::UnboundedSender<ServerEvent>,
}
@ -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::<Vec<_>>();
@ -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}"),

View file

@ -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 = `
<video id="video-${peerId}" autoplay playsinline ${peerId === state.me.id ? "muted" : ""}></video>
<div class="video-label">${escapeHtml(displayName)}</div>
<video id="${videoId}" autoplay playsinline ${peerId === state.me.id ? "muted" : ""}></video>
<div class="video-label">${label}</div>
`;
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 ? '<i data-lucide="monitor"></i>' : '<i data-lucide="monitor-off"></i>';
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;

View file

@ -86,6 +86,7 @@
</div>
<div class="vc-actions">
<button id="voice-video-btn" title="Turn on Camera"><i data-lucide="video-off"></i></button>
<button id="voice-screen-btn" title="Share Screen"><i data-lucide="monitor-off"></i></button>
<button id="voice-mute-btn" title="Mute"><i data-lucide="mic"></i></button>
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
</div>