Proper vc leaving

This commit is contained in:
2026-08-27 20:32:16 +02:00
parent ee71a99f5d
commit 6fb889840e
5 changed files with 107 additions and 28 deletions
+19 -4
View File
@@ -180,21 +180,22 @@ pub fn list_output_devices() -> Result<Vec<String>, String> {
} }
// ============================================================================ // ============================================================================
// CONFIG UPDATES & DISCONNECT // DISCONNECT
// ============================================================================ // ============================================================================
#[tauri::command] #[tauri::command]
pub fn disconnect_from_vc(voice_state: State<VoiceState>) -> Result<(), String> { pub fn disconnect_from_vc(voice_state: State<VoiceState>) -> Result<(), String> {
if let Some(session) = voice_state.session.lock().unwrap().take() { let mut lock = voice_state.session.lock().map_err(|e| e.to_string())?;
// Signal shutdown atomic to terminate sender and receiver loops
if let Some(session) = lock.take() {
session.shutdown.store(true, Ordering::SeqCst); session.shutdown.store(true, Ordering::SeqCst);
// Pause CPAL hardware streams explicitly to flush playback drivers immediately
let _ = session.input_stream.pause(); let _ = session.input_stream.pause();
if let Ok(output) = session.output_stream.lock() { if let Ok(output) = session.output_stream.lock() {
let _ = output.pause(); let _ = output.pause();
} }
} }
Ok(()) Ok(())
} }
@@ -491,3 +492,17 @@ fn build_output_stream(
) )
.map_err(|e| e.to_string()) .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.");
}
}
+11
View File
@@ -219,6 +219,17 @@ export default class Enclave<P = Page> {
this.server?.getUsers([msg.pubkey]); this.server?.getUsers([msg.pubkey]);
return; 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": case "Speaking":
clearTimeout(server.voiceChatSpeakers[msg.pubkey]); clearTimeout(server.voiceChatSpeakers[msg.pubkey]);
+8
View File
@@ -40,6 +40,11 @@ export type ClientMethod =
channel_id: string; channel_id: string;
pubkey: string; pubkey: string;
} }
| {
method: "UserLeftVoice";
channel_id: string;
pubkey: string;
}
| { | {
method: "Speaking"; method: "Speaking";
pubkey: string; pubkey: string;
@@ -89,4 +94,7 @@ export type ServerMethod =
| { | {
method: "JoinVoice"; method: "JoinVoice";
channel_id: string; channel_id: string;
}
| {
method: "LeaveVoice";
}; };
+1 -1
View File
@@ -33,7 +33,7 @@ export default class EnclaveServer {
public messages: Record<string, Record<string, StoredMessage>>; public messages: Record<string, Record<string, StoredMessage>>;
public users: Record<string, ClientMeta | null>; public users: Record<string, ClientMeta | null>;
public voiceJoin?: (pin: number, channelId: string) => void; public voiceJoin?: (pin: bigint, channelId: string) => void;
public voiceChatUsers: Record<string, string[]>; public voiceChatUsers: Record<string, string[]>;
public voiceChatSpeakers: Record<string, NodeJS.Timeout>; public voiceChatSpeakers: Record<string, NodeJS.Timeout>;
+68 -23
View File
@@ -1,9 +1,11 @@
import Enclave from "@/app/app"; import Enclave from "@/app/app";
import { ChannelPageProps } from "../PageView"; import { ChannelPageProps } from "../PageView";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Phone, PhoneOff } from "lucide-react";
export default function VoiceChannel({ export default function VoiceChannel({
appRef, appRef,
@@ -13,28 +15,49 @@ export default function VoiceChannel({
const channel = appRef.current?.page?.channel; const channel = appRef.current?.page?.channel;
if (!channel) return null; if (!channel) return null;
const [isConnected, setIsConnected] = useState(false);
const lastChannelId = useRef<string | null>(null); const lastChannelId = useRef<string | null>(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; const hostname = appRef.current?.server?.hostname;
if (!hostname) return; if (!hostname || channel.kind !== "voice" || !appRef.current?.server)
if (lastChannelId.current === channel.id) return; return;
lastChannelId.current = channel.id;
invoke("disconnect_from_vc");
if (channel.kind !== "voice" || !appRef.current?.server) return;
appRef.current.server.voiceJoin = (pin, channelId) => { appRef.current.server.voiceJoin = (pin, channelId) => {
invoke("connect_to_vc", { hostname, pin, channelId }).catch( invoke("connect_to_vc", { hostname, pin, channelId })
console.error, .then(() => setIsConnected(true))
); .catch(console.error);
}; };
appRef.current.server.websocket?.send({ appRef.current.server.websocket?.send({
method: "JoinVoice", method: "JoinVoice",
channel_id: channel.id, 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]); }, [channel.id]);
const speakers = const speakers =
@@ -42,7 +65,6 @@ export default function VoiceChannel({
Object.keys(appRef.current?.server?.voiceChatSpeakers); Object.keys(appRef.current?.server?.voiceChatSpeakers);
const users = appRef.current?.server?.voiceChatUsers[channel.id]; const users = appRef.current?.server?.voiceChatUsers[channel.id];
const usersLength = users?.length || 0; const usersLength = users?.length || 0;
// Dynamic grid column layout based on user count // Dynamic grid column layout based on user count
@@ -61,13 +83,19 @@ export default function VoiceChannel({
}; };
return ( return (
<div className="flex flex-col h-screen gap-2"> <div className="flex flex-col h-screen gap-2 pb-3">
<header className="px-3 pt-3 pb-3 text-sm text-muted-foreground border-b border-b-border"> {/* Header */}
<header className="px-3 pt-3 pb-3 text-sm text-muted-foreground border-b border-b-border flex justify-between items-center">
<h2>{channel.name}</h2> <h2>{channel.name}</h2>
<span className="text-xs">
{usersLength} {usersLength === 1 ? "Participant" : "Participants"}
</span>
</header> </header>
{/* Main Grid */}
<div <div
className={cn( className={cn(
"grid p-4 w-full gap-4 h-full auto-rows-fr bg-background/50 rounded-xl overflow-y-auto", "grid p-4 w-full gap-4 flex-1 auto-rows-fr bg-background/50 rounded-xl overflow-y-auto",
getGridCols(), getGridCols(),
)} )}
> >
@@ -101,15 +129,9 @@ export default function VoiceChannel({
{fallbackLetter} {fallbackLetter}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
{/* Mute indicator badge overlay */}
{/*{user?.isMuted && (
<div className="absolute -bottom-1 -right-1 p-1.5 bg-destructive rounded-full text-destructive-foreground shadow-md">
<MicOff className="w-3.5 h-3.5" />
</div>
)}*/}
</div> </div>
{/* Name Plate */}
<div className="absolute bottom-3 left-3 max-w-[85%] truncate bg-background/80 backdrop-blur-md text-foreground py-1 px-2.5 rounded-md border border-border/50 text-xs font-medium shadow-sm"> <div className="absolute bottom-3 left-3 max-w-[85%] truncate bg-background/80 backdrop-blur-md text-foreground py-1 px-2.5 rounded-md border border-border/50 text-xs font-medium shadow-sm">
{displayName} {displayName}
</div> </div>
@@ -117,6 +139,29 @@ export default function VoiceChannel({
); );
})} })}
</div> </div>
{/* Bottom Control Bar */}
<footer className="flex items-center justify-center px-4 py-2 border-t border-border/40 bg-card/50 rounded-xl mx-2">
{isConnected ? (
<Button
variant="destructive"
onClick={handleLeave}
className="flex items-center gap-2 font-medium px-6"
>
<PhoneOff className="w-4 h-4" />
Disconnect
</Button>
) : (
<Button
variant="default"
onClick={handleJoin}
className="flex items-center gap-2 font-medium px-6 bg-emerald-600 hover:bg-emerald-700 text-white"
>
<Phone className="w-4 h-4" />
Join Voice
</Button>
)}
</footer>
</div> </div>
); );
} }