This commit is contained in:
parent
3117116d12
commit
5f92356a9a
4 changed files with 208 additions and 13 deletions
57
src/voice.rs
57
src/voice.rs
|
|
@ -16,6 +16,7 @@ pub struct VoiceHub {
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ClientHandle {
|
struct ClientHandle {
|
||||||
display_name: String,
|
display_name: String,
|
||||||
|
is_sharing_video: bool,
|
||||||
tx: mpsc::UnboundedSender<ServerEvent>,
|
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,20 +24,34 @@ struct ClientHandle {
|
||||||
pub struct VoiceParticipant {
|
pub struct VoiceParticipant {
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
|
pub is_sharing_video: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
enum ServerEvent {
|
enum ServerEvent {
|
||||||
Peers { peers: Vec<VoiceParticipant> },
|
Peers {
|
||||||
PeerJoined { user_id: Uuid, display_name: String },
|
peers: Vec<VoiceParticipant>,
|
||||||
PeerLeft { user_id: Uuid },
|
},
|
||||||
|
PeerJoined {
|
||||||
|
user_id: Uuid,
|
||||||
|
display_name: String,
|
||||||
|
},
|
||||||
|
PeerLeft {
|
||||||
|
user_id: Uuid,
|
||||||
|
},
|
||||||
|
VideoStatusChanged {
|
||||||
|
user_id: Uuid,
|
||||||
|
is_sharing_video: bool,
|
||||||
|
},
|
||||||
Signal {
|
Signal {
|
||||||
from_user_id: Uuid,
|
from_user_id: Uuid,
|
||||||
kind: String,
|
kind: String,
|
||||||
data: serde_json::Value,
|
data: serde_json::Value,
|
||||||
},
|
},
|
||||||
Error { message: String },
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -47,6 +62,9 @@ enum ClientEvent {
|
||||||
kind: String,
|
kind: String,
|
||||||
data: serde_json::Value,
|
data: serde_json::Value,
|
||||||
},
|
},
|
||||||
|
SetVideoStatus {
|
||||||
|
is_sharing_video: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VoiceHub {
|
impl VoiceHub {
|
||||||
|
|
@ -60,6 +78,7 @@ impl VoiceHub {
|
||||||
.map(|(user_id, handle)| VoiceParticipant {
|
.map(|(user_id, handle)| VoiceParticipant {
|
||||||
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,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +98,7 @@ impl VoiceHub {
|
||||||
.map(|(peer_id, peer)| VoiceParticipant {
|
.map(|(peer_id, peer)| VoiceParticipant {
|
||||||
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,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
|
@ -86,6 +106,7 @@ impl VoiceHub {
|
||||||
user_id,
|
user_id,
|
||||||
ClientHandle {
|
ClientHandle {
|
||||||
display_name: display_name.clone(),
|
display_name: display_name.clone(),
|
||||||
|
is_sharing_video: false,
|
||||||
tx,
|
tx,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -119,7 +140,7 @@ impl VoiceHub {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn relay_signal(
|
pub async fn relay_signal(
|
||||||
&self,
|
&self,
|
||||||
room_id: Uuid,
|
room_id: Uuid,
|
||||||
from_user_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) {
|
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)
|
.relay_signal(room_id, user_id, to_user_id, kind, data)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
Ok(ClientEvent::SetVideoStatus { is_sharing_video }) => {
|
||||||
|
state
|
||||||
|
.voice
|
||||||
|
.set_video_status(room_id, user_id, is_sharing_video)
|
||||||
|
.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}"),
|
||||||
|
|
|
||||||
105
static/app.js
105
static/app.js
|
|
@ -15,9 +15,11 @@ const state = {
|
||||||
joinedChannelId: null,
|
joinedChannelId: null,
|
||||||
localStream: null,
|
localStream: null,
|
||||||
rawStream: null,
|
rawStream: null,
|
||||||
|
videoStream: null,
|
||||||
audioContext: null,
|
audioContext: null,
|
||||||
peerConnections: new Map(),
|
peerConnections: new Map(),
|
||||||
muted: false,
|
muted: false,
|
||||||
|
sharingVideo: false,
|
||||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||||
},
|
},
|
||||||
voicePresencePollId: null,
|
voicePresencePollId: null,
|
||||||
|
|
@ -59,8 +61,10 @@ const el = {
|
||||||
// Voice Connection
|
// Voice Connection
|
||||||
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"),
|
||||||
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"),
|
||||||
|
|
||||||
// Members
|
// Members
|
||||||
memberList: document.getElementById("member-list"),
|
memberList: document.getElementById("member-list"),
|
||||||
|
|
@ -553,6 +557,41 @@ function stopAndClearAudioPipeline() {
|
||||||
state.voice.audioContext = null;
|
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) {
|
async function buildAudioPipeline(rawStream) {
|
||||||
const audioContext = new AudioContext();
|
const audioContext = new AudioContext();
|
||||||
const source = audioContext.createMediaStreamSource(rawStream);
|
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) => {
|
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({
|
||||||
|
|
@ -612,6 +657,7 @@ function ensurePeerConnection(peerId) {
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.ontrack = (event) => {
|
pc.ontrack = (event) => {
|
||||||
|
if (event.track.kind === "audio") {
|
||||||
let audio = document.getElementById(`audio-${peerId}`);
|
let audio = document.getElementById(`audio-${peerId}`);
|
||||||
if (!audio) {
|
if (!audio) {
|
||||||
audio = document.createElement("audio");
|
audio = document.createElement("audio");
|
||||||
|
|
@ -621,6 +667,20 @@ function ensurePeerConnection(peerId) {
|
||||||
document.body.appendChild(audio);
|
document.body.appendChild(audio);
|
||||||
}
|
}
|
||||||
audio.srcObject = event.streams[0];
|
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);
|
state.voice.peerConnections.set(peerId, pc);
|
||||||
|
|
@ -707,6 +767,11 @@ async function joinVoice() {
|
||||||
document.getElementById(`audio-${msg.user_id}`)?.remove();
|
document.getElementById(`audio-${msg.user_id}`)?.remove();
|
||||||
} else if (msg.type === "signal") {
|
} else if (msg.type === "signal") {
|
||||||
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") {
|
||||||
|
if (!msg.is_sharing_video) {
|
||||||
|
document.getElementById(`video-${msg.user_id}`)?.parentElement?.remove();
|
||||||
|
updateVideoGridVisibility();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
refreshVoicePresence().catch(() => { });
|
refreshVoicePresence().catch(() => { });
|
||||||
};
|
};
|
||||||
|
|
@ -738,6 +803,45 @@ function toggleMute() {
|
||||||
lucide.createIcons();
|
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 ---
|
// --- Mobile Logic ---
|
||||||
|
|
||||||
function toggleMobileMenu() {
|
function toggleMobileMenu() {
|
||||||
|
|
@ -911,6 +1015,7 @@ async function init() {
|
||||||
} catch (err) { alert(err.message); }
|
} catch (err) { alert(err.message); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
el.voiceVideoBtn.onclick = toggleVideo;
|
||||||
el.voiceMuteBtn.onclick = toggleMute;
|
el.voiceMuteBtn.onclick = toggleMute;
|
||||||
el.voiceLeaveBtn.onclick = leaveVoice;
|
el.voiceLeaveBtn.onclick = leaveVoice;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@
|
||||||
<span id="vc-channel-name" class="vc-name">General</span>
|
<span id="vc-channel-name" class="vc-name">General</span>
|
||||||
</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-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>
|
||||||
|
|
@ -121,6 +122,7 @@
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div id="video-grid" class="video-grid hidden"></div>
|
||||||
<div id="message-list" class="message-list"></div>
|
<div id="message-list" class="message-list"></div>
|
||||||
|
|
||||||
<div class="chat-input-wrapper">
|
<div class="chat-input-wrapper">
|
||||||
|
|
|
||||||
|
|
@ -524,6 +524,47 @@ select {
|
||||||
color: var(--text-normal);
|
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 */
|
||||||
.chat-pane {
|
.chat-pane {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue