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 {
|
struct ClientHandle {
|
||||||
display_name: String,
|
display_name: String,
|
||||||
is_sharing_video: bool,
|
is_sharing_video: bool,
|
||||||
|
is_sharing_screen: bool,
|
||||||
tx: mpsc::UnboundedSender<ServerEvent>,
|
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ pub struct VoiceParticipant {
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub is_sharing_video: bool,
|
pub is_sharing_video: bool,
|
||||||
|
pub is_sharing_screen: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
|
|
@ -44,6 +46,10 @@ enum ServerEvent {
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
is_sharing_video: bool,
|
is_sharing_video: bool,
|
||||||
},
|
},
|
||||||
|
ScreenStatusChanged {
|
||||||
|
user_id: Uuid,
|
||||||
|
is_sharing_screen: bool,
|
||||||
|
},
|
||||||
Signal {
|
Signal {
|
||||||
from_user_id: Uuid,
|
from_user_id: Uuid,
|
||||||
kind: String,
|
kind: String,
|
||||||
|
|
@ -65,6 +71,9 @@ enum ClientEvent {
|
||||||
SetVideoStatus {
|
SetVideoStatus {
|
||||||
is_sharing_video: bool,
|
is_sharing_video: bool,
|
||||||
},
|
},
|
||||||
|
SetScreenStatus {
|
||||||
|
is_sharing_screen: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VoiceHub {
|
impl VoiceHub {
|
||||||
|
|
@ -79,6 +88,7 @@ impl VoiceHub {
|
||||||
user_id: *user_id,
|
user_id: *user_id,
|
||||||
display_name: handle.display_name.clone(),
|
display_name: handle.display_name.clone(),
|
||||||
is_sharing_video: handle.is_sharing_video,
|
is_sharing_video: handle.is_sharing_video,
|
||||||
|
is_sharing_screen: handle.is_sharing_screen,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
@ -99,6 +109,7 @@ impl VoiceHub {
|
||||||
user_id: *peer_id,
|
user_id: *peer_id,
|
||||||
display_name: peer.display_name.clone(),
|
display_name: peer.display_name.clone(),
|
||||||
is_sharing_video: peer.is_sharing_video,
|
is_sharing_video: peer.is_sharing_video,
|
||||||
|
is_sharing_screen: peer.is_sharing_screen,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
|
@ -107,6 +118,7 @@ impl VoiceHub {
|
||||||
ClientHandle {
|
ClientHandle {
|
||||||
display_name: display_name.clone(),
|
display_name: display_name.clone(),
|
||||||
is_sharing_video: false,
|
is_sharing_video: false,
|
||||||
|
is_sharing_screen: false,
|
||||||
tx,
|
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) {
|
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)
|
.set_video_status(room_id, user_id, is_sharing_video)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
Ok(ClientEvent::SetScreenStatus { is_sharing_screen }) => {
|
||||||
|
state
|
||||||
|
.voice
|
||||||
|
.set_screen_status(room_id, user_id, is_sharing_screen)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let _ = tx.send(ServerEvent::Error {
|
let _ = tx.send(ServerEvent::Error {
|
||||||
message: format!("invalid voice message: {err}"),
|
message: format!("invalid voice message: {err}"),
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,12 @@ const state = {
|
||||||
localStream: null,
|
localStream: null,
|
||||||
rawStream: null,
|
rawStream: null,
|
||||||
videoStream: null,
|
videoStream: null,
|
||||||
|
screenStream: null,
|
||||||
audioContext: null,
|
audioContext: null,
|
||||||
peerConnections: new Map(),
|
peerConnections: new Map(),
|
||||||
muted: false,
|
muted: false,
|
||||||
sharingVideo: false,
|
sharingVideo: false,
|
||||||
|
sharingScreen: false,
|
||||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||||
},
|
},
|
||||||
voicePresencePollId: null,
|
voicePresencePollId: null,
|
||||||
|
|
@ -62,6 +64,7 @@ const el = {
|
||||||
voiceConnection: document.getElementById("voice-connection"),
|
voiceConnection: document.getElementById("voice-connection"),
|
||||||
vcChannelName: document.getElementById("vc-channel-name"),
|
vcChannelName: document.getElementById("vc-channel-name"),
|
||||||
voiceVideoBtn: document.getElementById("voice-video-btn"),
|
voiceVideoBtn: document.getElementById("voice-video-btn"),
|
||||||
|
voiceScreenBtn: document.getElementById("voice-screen-btn"),
|
||||||
voiceMuteBtn: document.getElementById("voice-mute-btn"),
|
voiceMuteBtn: document.getElementById("voice-mute-btn"),
|
||||||
voiceLeaveBtn: document.getElementById("voice-leave-btn"),
|
voiceLeaveBtn: document.getElementById("voice-leave-btn"),
|
||||||
videoGrid: document.getElementById("video-grid"),
|
videoGrid: document.getElementById("video-grid"),
|
||||||
|
|
@ -563,7 +566,17 @@ function stopAndClearVideoPipeline() {
|
||||||
}
|
}
|
||||||
state.voice.videoStream = null;
|
state.voice.videoStream = null;
|
||||||
state.voice.sharingVideo = false;
|
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();
|
updateVideoGridVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -576,14 +589,16 @@ function updateVideoGridVisibility() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderVideo(peerId, displayName, stream) {
|
function renderVideo(peerId, displayName, stream, source) {
|
||||||
let videoEl = document.getElementById(`video-${peerId}`);
|
const videoId = `video-${peerId}-${source}`;
|
||||||
|
let videoEl = document.getElementById(videoId);
|
||||||
if (!videoEl) {
|
if (!videoEl) {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
container.className = "video-item";
|
container.className = "video-item";
|
||||||
|
const label = source === 'screen' ? `${escapeHtml(displayName)}'s Screen` : escapeHtml(displayName);
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<video id="video-${peerId}" autoplay playsinline ${peerId === state.me.id ? "muted" : ""}></video>
|
<video id="${videoId}" autoplay playsinline ${peerId === state.me.id ? "muted" : ""}></video>
|
||||||
<div class="video-label">${escapeHtml(displayName)}</div>
|
<div class="video-label">${label}</div>
|
||||||
`;
|
`;
|
||||||
el.videoGrid.appendChild(container);
|
el.videoGrid.appendChild(container);
|
||||||
videoEl = container.querySelector("video");
|
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) => {
|
pc.onicecandidate = (event) => {
|
||||||
if (!event.candidate || !state.voice.ws) return;
|
if (!event.candidate || !state.voice.ws) return;
|
||||||
state.voice.ws.send(JSON.stringify({
|
state.voice.ws.send(JSON.stringify({
|
||||||
|
|
@ -669,7 +690,13 @@ function ensurePeerConnection(peerId) {
|
||||||
audio.srcObject = event.streams[0];
|
audio.srcObject = event.streams[0];
|
||||||
} else if (event.track.kind === "video") {
|
} else if (event.track.kind === "video") {
|
||||||
const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
|
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);
|
await handleSignal(msg.from_user_id, msg.kind, msg.data);
|
||||||
} else if (msg.type === "video_status_changed") {
|
} else if (msg.type === "video_status_changed") {
|
||||||
if (!msg.is_sharing_video) {
|
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();
|
updateVideoGridVisibility();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -825,9 +857,10 @@ async function toggleVideo() {
|
||||||
if (state.voice.ws) {
|
if (state.voice.ws) {
|
||||||
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false }));
|
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()) {
|
for (const pc of state.voice.peerConnections.values()) {
|
||||||
const senders = pc.getSenders();
|
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);
|
if (videoSender) pc.removeTrack(videoSender);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -835,7 +868,7 @@ async function toggleVideo() {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||||
state.voice.videoStream = stream;
|
state.voice.videoStream = stream;
|
||||||
state.voice.sharingVideo = true;
|
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) {
|
if (state.voice.ws) {
|
||||||
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true }));
|
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true }));
|
||||||
|
|
@ -858,6 +891,49 @@ async function toggleVideo() {
|
||||||
lucide.createIcons();
|
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 ---
|
// --- Mobile Logic ---
|
||||||
|
|
||||||
function toggleMobileMenu() {
|
function toggleMobileMenu() {
|
||||||
|
|
@ -1032,6 +1108,7 @@ async function init() {
|
||||||
};
|
};
|
||||||
|
|
||||||
el.voiceVideoBtn.onclick = toggleVideo;
|
el.voiceVideoBtn.onclick = toggleVideo;
|
||||||
|
el.voiceScreenBtn.onclick = toggleScreenShare;
|
||||||
el.voiceMuteBtn.onclick = toggleMute;
|
el.voiceMuteBtn.onclick = toggleMute;
|
||||||
el.voiceLeaveBtn.onclick = leaveVoice;
|
el.voiceLeaveBtn.onclick = leaveVoice;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="vc-actions">
|
<div class="vc-actions">
|
||||||
<button id="voice-video-btn" title="Turn on Camera"><i data-lucide="video-off"></i></button>
|
<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-mute-btn" title="Mute"><i data-lucide="mic"></i></button>
|
||||||
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
|
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue