parent
f35fdda7a2
commit
737267567e
3 changed files with 125 additions and 9 deletions
38
src/voice.rs
38
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<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}"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue