diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index ff2ffb3..a36c290 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -180,21 +180,22 @@ pub fn list_output_devices() -> Result, String> { } // ============================================================================ -// CONFIG UPDATES & DISCONNECT +// DISCONNECT // ============================================================================ #[tauri::command] pub fn disconnect_from_vc(voice_state: State) -> Result<(), String> { - if let Some(session) = voice_state.session.lock().unwrap().take() { - // Signal shutdown atomic to terminate sender and receiver loops + let mut lock = voice_state.session.lock().map_err(|e| e.to_string())?; + + if let Some(session) = lock.take() { session.shutdown.store(true, Ordering::SeqCst); - // Pause CPAL hardware streams explicitly to flush playback drivers immediately let _ = session.input_stream.pause(); if let Ok(output) = session.output_stream.lock() { let _ = output.pause(); } } + Ok(()) } @@ -491,3 +492,17 @@ fn build_output_stream( ) .map_err(|e| e.to_string()) } + +impl Drop for VoiceSession { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::SeqCst); + + let _ = self.input_stream.pause(); + if let Ok(output) = self.output_stream.lock() { + let _ = output.pause(); + } + + // Output hardware stream handle clean-up + eprintln!("[vc] VoiceSession dropped and audio streams paused."); + } +} diff --git a/src/app/app.ts b/src/app/app.ts index c261582..55efdec 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -219,6 +219,17 @@ export default class Enclave

{ this.server?.getUsers([msg.pubkey]); return; + case "UserLeftVoice": + if (!server.voiceChatUsers[msg.channel_id]) return; + + server.voiceChatUsers[msg.channel_id] = server.voiceChatUsers[ + msg.channel_id + ].filter((v) => v !== msg.pubkey); + + this.forceRender(); + + return; + case "Speaking": clearTimeout(server.voiceChatSpeakers[msg.pubkey]); diff --git a/src/app/protocol.ts b/src/app/protocol.ts index 93f5f49..a31272e 100644 --- a/src/app/protocol.ts +++ b/src/app/protocol.ts @@ -40,6 +40,11 @@ export type ClientMethod = channel_id: string; pubkey: string; } + | { + method: "UserLeftVoice"; + channel_id: string; + pubkey: string; + } | { method: "Speaking"; pubkey: string; @@ -89,4 +94,7 @@ export type ServerMethod = | { method: "JoinVoice"; channel_id: string; + } + | { + method: "LeaveVoice"; }; diff --git a/src/app/server.ts b/src/app/server.ts index 19442f1..a3e56d1 100644 --- a/src/app/server.ts +++ b/src/app/server.ts @@ -33,7 +33,7 @@ export default class EnclaveServer { public messages: Record>; public users: Record; - public voiceJoin?: (pin: number, channelId: string) => void; + public voiceJoin?: (pin: bigint, channelId: string) => void; public voiceChatUsers: Record; public voiceChatSpeakers: Record; diff --git a/src/components/page/channel/VoiceChannel.tsx b/src/components/page/channel/VoiceChannel.tsx index f29e13f..52a7ed0 100644 --- a/src/components/page/channel/VoiceChannel.tsx +++ b/src/components/page/channel/VoiceChannel.tsx @@ -1,9 +1,11 @@ import Enclave from "@/app/app"; import { ChannelPageProps } from "../PageView"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { cn } from "@/lib/utils"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Phone, PhoneOff } from "lucide-react"; export default function VoiceChannel({ appRef, @@ -13,28 +15,49 @@ export default function VoiceChannel({ const channel = appRef.current?.page?.channel; if (!channel) return null; + const [isConnected, setIsConnected] = useState(false); const lastChannelId = useRef(null); - useEffect(() => { + // Leave current channel setup + const handleLeave = () => { + appRef.current?.server?.websocket?.send({ method: "LeaveVoice" }); + invoke("disconnect_from_vc").catch(console.error); + setIsConnected(false); + }; + + // Explicit action to join the voice channel + const handleJoin = () => { const hostname = appRef.current?.server?.hostname; - if (!hostname) return; - if (lastChannelId.current === channel.id) return; - lastChannelId.current = channel.id; - - invoke("disconnect_from_vc"); - - if (channel.kind !== "voice" || !appRef.current?.server) return; + if (!hostname || channel.kind !== "voice" || !appRef.current?.server) + return; appRef.current.server.voiceJoin = (pin, channelId) => { - invoke("connect_to_vc", { hostname, pin, channelId }).catch( - console.error, - ); + invoke("connect_to_vc", { hostname, pin, channelId }) + .then(() => setIsConnected(true)) + .catch(console.error); }; appRef.current.server.websocket?.send({ method: "JoinVoice", channel_id: channel.id, }); + + setIsConnected(true); + }; + + // Clean up when channel changes or component unmounts + useEffect(() => { + if (lastChannelId.current !== channel.id) { + if (isConnected) { + invoke("disconnect_from_vc").catch(console.error); + setIsConnected(false); + } + lastChannelId.current = channel.id; + } + + return () => { + invoke("disconnect_from_vc").catch(console.error); + }; }, [channel.id]); const speakers = @@ -42,7 +65,6 @@ export default function VoiceChannel({ Object.keys(appRef.current?.server?.voiceChatSpeakers); const users = appRef.current?.server?.voiceChatUsers[channel.id]; - const usersLength = users?.length || 0; // Dynamic grid column layout based on user count @@ -61,13 +83,19 @@ export default function VoiceChannel({ }; return ( -

-
+
+ {/* Header */} +

{channel.name}

+ + {usersLength} {usersLength === 1 ? "Participant" : "Participants"} +
+ + {/* Main Grid */}
@@ -101,15 +129,9 @@ export default function VoiceChannel({ {fallbackLetter} - - {/* Mute indicator badge overlay */} - {/*{user?.isMuted && ( -
- -
- )}*/}
+ {/* Name Plate */}
{displayName}
@@ -117,6 +139,29 @@ export default function VoiceChannel({ ); })}
+ + {/* Bottom Control Bar */} +
); }