camera?
All checks were successful
/ upload (release) Successful in 23s

This commit is contained in:
pavel 2026-02-24 00:49:56 +01:00
commit 5f92356a9a
4 changed files with 208 additions and 13 deletions

View file

@ -16,6 +16,7 @@ pub struct VoiceHub {
#[derive(Clone)]
struct ClientHandle {
display_name: String,
is_sharing_video: bool,
tx: mpsc::UnboundedSender<ServerEvent>,
}
@ -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<VoiceParticipant> },
PeerJoined { user_id: Uuid, display_name: String },
PeerLeft { user_id: Uuid },
Peers {
peers: Vec<VoiceParticipant>,
},
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::<Vec<_>>();
@ -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}"),

View file

@ -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 = `
<video id="video-${peerId}" autoplay playsinline ${peerId === state.me.id ? "muted" : ""}></video>
<div class="video-label">${escapeHtml(displayName)}</div>
`;
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,6 +657,7 @@ function ensurePeerConnection(peerId) {
};
pc.ontrack = (event) => {
if (event.track.kind === "audio") {
let audio = document.getElementById(`audio-${peerId}`);
if (!audio) {
audio = document.createElement("audio");
@ -621,6 +667,20 @@ function ensurePeerConnection(peerId) {
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);
}
};
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 ? '<i data-lucide="video"></i>' : '<i data-lucide="video-off"></i>';
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;

View file

@ -85,6 +85,7 @@
<span id="vc-channel-name" class="vc-name">General</span>
</div>
<div class="vc-actions">
<button id="voice-video-btn" title="Turn on Camera"><i data-lucide="video-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>
@ -121,6 +122,7 @@
</button>
</header>
<div id="video-grid" class="video-grid hidden"></div>
<div id="message-list" class="message-list"></div>
<div class="chat-input-wrapper">

View file

@ -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;