Fix double audio, Chrome reload silence, and restore input gain functionality

This commit is contained in:
Jannis Braun
2026-02-19 19:43:41 +01:00
parent 6deed231ab
commit 5ba64d2aea
8 changed files with 393 additions and 266 deletions
@@ -50,7 +50,7 @@ export function AppLayout() {
} = useLiveKit();
// Initialize WebSocket
useWebSocket();
const { isConnected: isWsConnected } = useWebSocket();
// Sync participants to store
useEffect(() => {
@@ -59,12 +59,13 @@ export function AppLayout() {
// Manage voice connection (server voice channels)
useEffect(() => {
if (currentVoiceChannelId) {
if (!isLoading && user && isWsConnected && currentVoiceChannelId) {
console.log('[AppLayout] Auto-rejoining voice channel:', currentVoiceChannelId);
connectVoice(currentVoiceChannelId);
} else if (!activeDmCall) {
} else if (!isLoading && user && !currentVoiceChannelId && !activeDmCall) {
disconnectVoice();
}
}, [currentVoiceChannelId, connectVoice, disconnectVoice, activeDmCall]);
}, [currentVoiceChannelId, connectVoice, disconnectVoice, activeDmCall, isLoading, user, isWsConnected]);
// Manage DM call connection
useEffect(() => {
@@ -553,18 +553,19 @@ function UserAreaPanel({
onChange={(e) => {
const vol = Number(e.target.value);
storeSetInputVolume(vol);
// Apply gain to mic: at 0 = mute, 100 = normal, 200 = 2x boost
const room = getActiveRoom();
if (room && room.localParticipant.isMicrophoneEnabled) {
if (vol === 0) {
room.localParticipant.setMicrophoneEnabled(false).catch(() => {});
} else {
// Re-enable mic if it was muted by volume slider
if (room) {
const { isMuted: manuallyMuted, isDeafened: manuallyDeafened } = useVoiceStore.getState();
// If user is manually muted, hardware should stay off regardless of volume.
// If user is NOT manually muted and volume is 0, we can keep hardware ON
// (Web Audio handles silence) or turn it OFF for battery/privacy.
// Discord keeps it ON (green ring) but silent. We'll follow that.
if (!manuallyMuted && !manuallyDeafened && !room.localParticipant.isMicrophoneEnabled && vol > 0) {
room.localParticipant.setMicrophoneEnabled(true).catch(() => {});
}
}
}}
className="w-full h-1.5 rounded-full appearance-none cursor-pointer accent-discord-blurple bg-discord-bg-tertiary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md"
}} className="w-full h-1.5 rounded-full appearance-none cursor-pointer accent-discord-blurple bg-discord-bg-tertiary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md"
style={{
background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${inputVolume / 2}%, #4e5058 ${inputVolume / 2}%, #4e5058 100%)`,
}}
@@ -649,14 +650,6 @@ function UserAreaPanel({
onChange={(e) => {
const vol = Number(e.target.value);
storeSetOutputVolume(vol);
// Apply volume to all remote participants
const room = getActiveRoom();
if (room) {
const scaled = vol / 100; // 0-2 range (0%=0, 100%=1, 200%=2)
room.remoteParticipants.forEach((participant) => {
participant.setVolume(scaled);
});
}
}}
className="w-full h-1.5 rounded-full appearance-none cursor-pointer bg-discord-bg-tertiary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md"
style={{
@@ -664,7 +657,6 @@ function UserAreaPanel({
}}
/>
</div>
<div className="mx-4 border-t border-[#2b2d31]" />
{/* Voice Settings link */}
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
@@ -16,6 +16,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { wsSend } from '../../hooks/useWebSocket';
export function MainContent() {
// 1. ALL HOOKS AT THE TOP
const channels = useServerStore((s) => s.channels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const currentServerId = useServerStore((s) => s.currentServerId);
@@ -23,20 +24,40 @@ export function MainContent() {
const memberListOpen = useUIStore((s) => s.memberListOpen);
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
const setVoiceFullscreen = useUIStore((s) => s.setVoiceFullscreen);
const participants = useVoiceStore((s) => s.participants);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const showDms = useUIStore((s) => s.showDms);
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const outgoingCall = useVoiceStore((s) => s.outgoingCall);
const channel = channels.find(c => c.id === currentChannelId);
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
// DM view or no server selected
const dmChannels = useServerStore((s) => s.dmChannels);
const authUser = useAuthStore((s) => s.user);
const voiceContainerRef = useRef<HTMLDivElement>(null);
// Handle actual browser fullscreen API
useEffect(() => {
const handleFullscreenChange = () => {
setVoiceFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, [setVoiceFullscreen]);
useEffect(() => {
if (voiceFullscreen && voiceContainerRef.current && !document.fullscreenElement) {
voiceContainerRef.current.requestFullscreen().catch(err => {
console.error('Error attempting to enable full-screen mode:', err);
});
} else if (!voiceFullscreen && document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}, [voiceFullscreen]);
// 2. LOGIC AND EARLY RETURNS
const channel = channels.find(c => c.id === currentChannelId);
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
if (showDms || !currentServerId) {
if (!currentChannelId) {
return <FriendsPage />;
@@ -45,9 +66,7 @@ export function MainContent() {
const dmChannel = dmChannels.find(dm => dm.id === currentChannelId);
const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id);
const dmName = otherUser?.displayName ?? otherUser?.username ?? 'Direct Message';
const dmStatus = otherUser?.status as any;
// Show DmCallView if there's an active DM call for this channel
const isInDmCall = activeDmCall?.dmChannelId === currentChannelId;
const isCallingThisDm = outgoingCall?.dmChannelId === currentChannelId;
@@ -63,7 +82,6 @@ export function MainContent() {
wsSend({ type: 'dm_call_end', dmChannelId: currentChannelId });
};
// If in an active DM call, show the call view overlaid on top of the chat
if (isInDmCall) {
return (
<div className="flex-1 flex flex-col min-w-0 relative">
@@ -74,7 +92,6 @@ export function MainContent() {
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative">
{/* Outgoing call banner */}
{isCallingThisDm && (
<div className="bg-discord-green/10 border-b border-discord-green/20 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -99,7 +116,6 @@ export function MainContent() {
<span className="font-bold text-discord-text-primary truncate">{dmName}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Voice Call */}
<button
onClick={handleStartVoiceCall}
disabled={!!outgoingCall || !!activeDmCall}
@@ -110,7 +126,6 @@ export function MainContent() {
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
</svg>
</button>
{/* Video Call */}
<button
onClick={handleStartVoiceCall}
disabled={!!outgoingCall || !!activeDmCall}
@@ -121,34 +136,28 @@ export function MainContent() {
<path d="M21 6.5l-4 4V7c0-.55-.45-1-1-1H9.82L21 17.18V6.5zM3.27 2L2 3.27 4.73 6H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.21 0 .39-.08.54-.18L19.73 21 21 19.73 3.27 2z" />
</svg>
</button>
{/* Pinned Messages */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Pinned Messages">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" transform="rotate(45 12 12)" />
<path d="M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z" />
</svg>
</button>
{/* Add Friends to DM */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Add Friends to DM">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
</svg>
</button>
{/* Divider */}
<div className="w-[1px] h-6 bg-discord-modifier-accent mx-1" />
{/* Search */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Search">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.707 20.293l-5.395-5.395A7.457 7.457 0 0018 10.5 7.5 7.5 0 1010.5 18c1.575 0 3.027-.486 4.228-1.31l5.476 5.476a.997.997 0 001.414 0l.089-.089a1 1 0 000-1.414l.001-.37zM10.5 16a5.5 5.5 0 110-11 5.5 5.5 0 010 11z" />
</svg>
</button>
{/* Inbox */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Inbox">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 3H4.99c-1.11 0-1.98.9-1.98 2L3 19c0 1.1.88 2 1.99 2H19c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H4.99V5H19v10z" />
</svg>
</button>
{/* Help */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Help">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" />
@@ -163,7 +172,6 @@ export function MainContent() {
);
}
// No channel selected
if (!currentChannelId || !channel) {
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
@@ -177,11 +185,9 @@ export function MainContent() {
);
}
// Voice/Video channel view
if (isVoiceChannel) {
const isInThisChannel = currentVoiceChannelId === currentChannelId;
// Not connected — show "Join Voice" prompt with gradient
if (!isInThisChannel) {
return (
<div className="flex-1 flex flex-col bg-[#0b0c0e]">
@@ -194,7 +200,6 @@ export function MainContent() {
</div>
</div>
<div className="flex-1 flex flex-col items-center justify-center gap-8 relative">
{/* Radial gradient glow */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(88,101,242,0.12)_0%,transparent_70%)] animate-gradient-pulse pointer-events-none" />
<div className="text-center relative z-10">
<h2 className="text-[28px] font-bold text-white mb-3">{channel.name}</h2>
@@ -214,11 +219,12 @@ export function MainContent() {
);
}
// Connected — full voice view with grid + floating control bar
const voiceView = (
<div className={`flex-1 flex flex-col bg-[#0b0c0e] min-w-0 ${voiceFullscreen ? 'fixed inset-0 z-50' : ''} group/voice relative`}>
{/* Voice header */}
<div className="h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 bg-[#0b0c0e]">
return (
<div
ref={voiceContainerRef}
className={`flex-1 flex flex-col bg-[#0b0c0e] min-w-0 group/voice relative ${voiceFullscreen ? 'h-screen' : ''}`}
>
<div className={`h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 bg-[#0b0c0e] transition-opacity duration-300 ${voiceFullscreen ? 'opacity-0 hover:opacity-100' : ''}`}>
<div className="flex items-center gap-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" />
@@ -229,26 +235,20 @@ export function MainContent() {
</div>
</div>
{/* Main content: grid + optional chat */}
<div className="flex-1 flex overflow-hidden pb-20">
<VoiceGrid participants={participants} />
{voiceChatOpen && (
{voiceChatOpen && !voiceFullscreen && (
<VoiceChatPanel channelId={currentChannelId} channelName={channel.name} />
)}
</div>
{/* Floating control bar */}
<VoiceControlBar />
</div>
);
return voiceView;
}
// Text channel view
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative">
{/* Channel header */}
<div className="h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 z-10 bg-discord-bg-primary">
<div className="flex items-center gap-2 min-w-0">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0">
@@ -263,25 +263,21 @@ export function MainContent() {
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Threads */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Threads">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.43 21a.996.996 0 01-.98-.8l-.79-4.34H2.5a1 1 0 110-2h.93l-.55-3H1.5a1 1 0 010-2h1.15L1.87 4.86a1 1 0 011.96-.72L4.6 8.86h3.32l-.78-4.72a1 1 0 011.96-.28l.84 5H13.5a1 1 0 110 2h-3.33l.55 3H13.5a1 1 0 110 2h-2.55l.72 3.94a1 1 0 01-.79 1.16 1.034 1.034 0 01-.18.02.996.996 0 01-.98-.82L8.95 15.86H5.63l.72 3.94A1 1 0 015.43 21zM5.86 10.86l.55 3h3.32l-.55-3H5.86z" />
</svg>
</button>
{/* Notification Settings */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Notification Settings">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
</svg>
</button>
{/* Pinned Messages */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Pinned Messages">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z" />
</svg>
</button>
{/* Member List Toggle */}
<button
onClick={toggleMemberList}
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${
@@ -293,21 +289,17 @@ export function MainContent() {
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
</svg>
</button>
{/* Divider */}
<div className="w-[1px] h-6 bg-discord-modifier-accent mx-1" />
{/* Search */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Search">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.707 20.293l-5.395-5.395A7.457 7.457 0 0018 10.5 7.5 7.5 0 1010.5 18c1.575 0 3.027-.486 4.228-1.31l5.476 5.476a.997.997 0 001.414 0l.089-.089a1 1 0 000-1.414l.001-.37zM10.5 16a5.5 5.5 0 110-11 5.5 5.5 0 010 11z" />
</svg>
</button>
{/* Inbox */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Inbox">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 3H4.99c-1.11 0-1.98.9-1.98 2L3 19c0 1.1.88 2 1.99 2H19c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H4.99V5H19v10z" />
</svg>
</button>
{/* Help */}
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Help">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" />
@@ -315,14 +307,8 @@ export function MainContent() {
</button>
</div>
</div>
{/* Messages */}
<MessageList channelId={currentChannelId} />
{/* Typing indicator */}
<TypingIndicator channelId={currentChannelId} />
{/* Message input */}
<MessageInput channelId={currentChannelId} channelName={channel.name} />
</div>
);
@@ -131,13 +131,6 @@ export function VoiceControlBar() {
};
const handleFullscreen = () => {
if (!voiceFullscreen) {
document.documentElement.requestFullscreen?.().catch(() => {});
} else {
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}
toggleVoiceFullscreen();
};
@@ -56,15 +56,15 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
? participants.find((p) => p.identity === focusedParticipantId)
: null;
// Focus mode: one large tile + sidebar strip
// Focus mode: one large tile + bottom strip
if (focusedParticipant) {
const otherParticipants = participants.filter(
(p) => p.identity !== focusedParticipantId,
);
return (
<div className="flex-1 flex overflow-hidden">
<div className="flex-1 flex flex-col overflow-hidden relative">
{/* Main focused view */}
<div className="flex-1 p-2 relative">
<div className="flex-1 p-2 min-h-0">
<VoiceUser participant={focusedParticipant} large />
{/* Back to grid button */}
<button
@@ -79,14 +79,14 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
</button>
</div>
{/* Side strip of other participants */}
{/* Bottom strip of other participants */}
{otherParticipants.length > 0 && (
<div className="w-[200px] flex-shrink-0 overflow-y-auto p-2 space-y-2 bg-[#111214]/50">
<div className="h-[120px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-[#111214]/50 overflow-x-auto no-scrollbar">
{otherParticipants.map((p) => (
<div
key={p.identity}
onClick={() => setFocusedParticipant(p.identity)}
className="cursor-pointer hover:opacity-80 transition-opacity"
className="h-full aspect-video flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity"
>
<VoiceUser participant={p} />
</div>
@@ -107,13 +107,13 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
})();
return (
<div className="flex-1 p-3 overflow-auto flex items-center">
<div className={`grid ${gridClass} gap-2 w-full`}>
<div className="flex-1 p-3 overflow-auto flex items-center min-h-0">
<div className={`grid ${gridClass} gap-2 w-full max-h-full`}>
{participants.map((p) => (
<div
key={p.identity}
onClick={() => setFocusedParticipant(p.identity)}
className="cursor-pointer hover:opacity-90 transition-opacity"
className="cursor-pointer hover:opacity-90 transition-opacity h-full"
>
<VoiceUser participant={p} />
</div>
+95 -22
View File
@@ -1,6 +1,7 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore';
import { getSharedAudioCtx } from '../../hooks/useLiveKit';
import type { ParticipantInfo } from '../../hooks/useLiveKit';
interface VoiceUserProps {
@@ -19,14 +20,92 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
const isLocal = participant.isLocal;
// Determine active video track — prioritize screen share, check both enabled flag and readyState
const [ctxState, setCtxState] = useState<AudioContextState>('suspended');
// Monitor AudioContext state
useEffect(() => {
const ctx = getSharedAudioCtx();
if (!ctx) return;
setCtxState(ctx.state);
const handler = () => setCtxState(ctx.state);
ctx.addEventListener('statechange', handler);
return () => ctx.removeEventListener('statechange', handler);
}, []);
// Web Audio for volume boost (> 100%)
const gainNodeRef = useRef<GainNode | null>(null);
const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);
// Setup Web Audio graph
useEffect(() => {
if (isLocal || !participant.audioTrack) return;
const ctx = getSharedAudioCtx();
if (!ctx) return;
if (!gainNodeRef.current) {
gainNodeRef.current = ctx.createGain();
gainNodeRef.current.connect(ctx.destination);
}
const gainNode = gainNodeRef.current!;
if (sourceNodeRef.current) {
sourceNodeRef.current.disconnect();
}
const stream = new MediaStream([participant.audioTrack]);
sourceNodeRef.current = ctx.createMediaStreamSource(stream);
sourceNodeRef.current.connect(gainNode);
return () => {
sourceNodeRef.current?.disconnect();
};
}, [participant.audioTrack, isLocal]);
// Apply volume - STRICT DUAL PATH PREVENTION
useEffect(() => {
const audioEl = audioRef.current;
const ctx = getSharedAudioCtx();
if (isLocal || !audioEl || !ctx) return;
const perUserScaled = perUserVolume / 100;
const globalScaled = outputVolume / 100;
const combined = perUserScaled * globalScaled;
if (isDeafened) {
if (gainNodeRef.current) gainNodeRef.current.gain.setTargetAtTime(0, ctx.currentTime, 0.01);
audioEl.volume = 0;
audioEl.muted = true;
} else {
// Chrome/Safari Autoplay logic:
// If Context is Running: Use Web Audio (allows > 100% boost), Mute <audio>
// If Context is Blocked: Use <audio> (max 100%), Mute Web Audio
if (ctxState === 'running' && gainNodeRef.current) {
audioEl.muted = true; // Stop standard playback
gainNodeRef.current.gain.setTargetAtTime(combined, ctx.currentTime, 0.01);
} else {
if (gainNodeRef.current) {
gainNodeRef.current.gain.setTargetAtTime(0, ctx.currentTime, 0.01);
}
audioEl.muted = false; // Fallback to standard
audioEl.volume = Math.min(combined, 1);
audioEl.play().catch(() => {
// Truly blocked by browser
});
}
}
}, [isDeafened, outputVolume, perUserVolume, ctxState, isLocal]);
// Determine active video track
const liveScreen = participant.isScreenSharing && participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null;
const liveCamera = participant.isCameraOn && participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null;
const activeVideoTrack = liveScreen ?? liveCamera;
const hasVideo = activeVideoTrack !== null;
const isScreenShare = liveScreen !== null;
// Listen for track 'ended' events to force re-render when a stream stops
// Listen for track 'ended' events
useEffect(() => {
const tracks = [participant.videoTrack, participant.screenTrack].filter(
(t): t is MediaStreamTrack => t !== null,
@@ -53,22 +132,9 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
const audioEl = audioRef.current;
if (!audioEl || !participant.audioTrack) return;
audioEl.srcObject = new MediaStream([participant.audioTrack]);
// Note: play() and muted state are handled by the volume effect above
}, [participant.audioTrack]);
// Apply volume: combine outputVolume and per-participant volume, or mute if deafened
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl) return;
if (isDeafened) {
audioEl.volume = 0;
audioEl.muted = true;
} else {
const combined = (outputVolume / 100) * (perUserVolume / 100);
audioEl.volume = Math.min(Math.max(combined, 0), 1);
audioEl.muted = false;
}
}, [isDeafened, outputVolume, perUserVolume]);
// Volume context menu
const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null);
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
@@ -77,6 +143,10 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
(e: React.MouseEvent) => {
if (isLocal) return;
e.preventDefault();
const ctx = getSharedAudioCtx();
if (ctx && ctx.state === 'suspended') {
ctx.resume();
}
setVolumeMenu({ x: e.clientX, y: e.clientY });
},
[isLocal],
@@ -89,17 +159,23 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
return () => window.removeEventListener('click', close);
}, [volumeMenu]);
const handleInteraction = useCallback(() => {
const ctx = getSharedAudioCtx();
if (ctx && ctx.state === 'suspended') {
ctx.resume().catch(console.error);
}
}, []);
return (
<div
onClick={handleInteraction}
className={`relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${
participant.isSpeaking
? 'ring-[3px] ring-discord-green shadow-[0_0_12px_rgba(35,165,90,0.25)]'
: 'ring-1 ring-white/[0.06] hover:ring-white/10'
} ${large ? 'h-full' : ''}`}
style={large ? undefined : { aspectRatio: '16/9', minHeight: '140px' }}
} ${large ? 'h-full w-full' : 'h-full aspect-video'}`}
onContextMenu={handleContextMenu}
>
{/* Audio element for remote participants */}
{!isLocal && <audio ref={audioRef} autoPlay />}
{hasVideo ? (
@@ -125,14 +201,12 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
</div>
)}
{/* LIVE badge for screen shares */}
{isScreenShare && hasVideo && (
<div className="absolute top-2 left-2 px-1.5 py-0.5 bg-discord-red rounded text-[11px] font-bold text-white uppercase tracking-wide">
LIVE
</div>
)}
{/* Bottom overlay */}
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0">
@@ -173,7 +247,6 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
</div>
</div>
{/* Per-participant volume menu (right-click) */}
{volumeMenu && !isLocal && (
<div
className="fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px] border border-white/[0.06]"
+122 -45
View File
@@ -3,22 +3,18 @@ import {
Room,
RoomEvent,
Track,
LocalTrackPublication,
RemoteTrackPublication,
Participant,
RemoteParticipant,
LocalParticipant,
ConnectionState,
VideoPresets,
VideoEncoding,
VideoPreset,
LocalAudioTrack,
} from 'livekit-client';
import { api } from '../api/client';
import { useVoiceStore } from '../stores/voiceStore';
/**
* OPENCORD NATIVE OVERDRIVE PIPELINE v22
* "Soft-Launch Protocol": Always starts low to clear handshake, then upgrades to target.
* OPENCORD NATIVE OVERDRIVE PIPELINE v30
*/
const QUALITY_MAP: Record<string, VideoPreset> = {
@@ -33,6 +29,31 @@ const QUALITY_MAP: Record<string, VideoPreset> = {
const AUTO_PRESET = QUALITY_MAP['720p60']!;
let _activeRoom: Room | null = null;
let _sharedAudioCtx: AudioContext | null = null;
export function getSharedAudioCtx() {
if (typeof window === 'undefined') return null;
if (!_sharedAudioCtx) {
_sharedAudioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
}
return _sharedAudioCtx;
}
// Global gesture resumer
if (typeof window !== 'undefined') {
const resume = () => {
const ctx = getSharedAudioCtx();
if (ctx && ctx.state === 'suspended') {
ctx.resume().then(() => {
console.log('[Audio] Shared context resumed via interaction');
window.removeEventListener('click', resume);
window.removeEventListener('keydown', resume);
});
}
};
window.addEventListener('click', resume);
window.addEventListener('keydown', resume);
}
export function getActiveRoom(): Room | null {
return _activeRoom;
@@ -69,13 +90,11 @@ async function applyOverdriveHammer(room: Room, source: Track.Source, preset: Vi
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc;
if (pc) {
const senders = (pc as RTCPeerConnection).getSenders();
const sender = senders.find(s => s.track?.id === pub.track?.mediaStreamTrack?.id);
const sender = senders.find(s => s.track?.id === (pub.track as any).mediaStreamTrack?.id);
if (sender) {
const params = sender.getParameters();
if (params.encodings && params.encodings[0]) {
console.log(`[Overdrive] Upgrading ${source} to ${preset.encoding.maxBitrate}bps`);
params.encodings[0].maxBitrate = preset.encoding.maxBitrate;
// Gentle floor to keep stable
(params.encodings[0] as any).minBitrate = 2_000_000;
params.encodings[0].maxFramerate = preset.encoding.maxFramerate;
params.encodings[0].networkPriority = 'high';
@@ -85,9 +104,6 @@ async function applyOverdriveHammer(room: Room, source: Track.Source, preset: Vi
}
}
}
if ((pub.track as any).mediaStreamTrack) {
(pub.track as any).mediaStreamTrack.contentHint = 'motion';
}
} catch (err) {}
}
@@ -99,18 +115,26 @@ export function useLiveKit() {
const [connectionError, setConnectionError] = useState<string | null>(null);
const roomRef = useRef<Room | null>(null);
const connectedChannelRef = useRef<string | null>(null);
const isMuted = useVoiceStore((s) => s.isMuted);
const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const videoQuality = useVoiceStore((s) => s.videoQuality);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const inputVolume = useVoiceStore((s) => s.inputVolume);
// Web Audio for Local Input Gain
const localGainNodeRef = useRef<GainNode | null>(null);
const localSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const localDestRef = useRef<MediaStreamAudioDestinationNode | null>(null);
const updateParticipants = useCallback(() => {
const r = roomRef.current;
if (!r) return;
const allParticipants: ParticipantInfo[] = [];
const processParticipant = (p: Participant, isLocal: boolean) => {
if (!p.identity) return;
const { userId, username } = parseIdentity(p.identity);
let audioTrack: MediaStreamTrack | null = null;
let videoTrack: MediaStreamTrack | null = null;
@@ -124,27 +148,34 @@ export function useLiveKit() {
else if (pub.source === Track.Source.Camera && p.isCameraEnabled) videoTrack = mt;
else if (pub.source === Track.Source.ScreenShare && p.isScreenShareEnabled) screenTrack = mt;
});
const userState = useVoiceStore.getState().voiceUserStates.get(userId);
let isDeafened = false;
let isMuted = !p.isMicrophoneEnabled;
let isPartDeafened = false;
let isPartMuted = !p.isMicrophoneEnabled;
if (isLocal) {
isDeafened = useVoiceStore.getState().isDeafened;
isMuted = useVoiceStore.getState().isMuted;
isPartDeafened = useVoiceStore.getState().isDeafened;
isPartMuted = useVoiceStore.getState().isMuted;
} else {
isDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
if (userState) {
isMuted = userState.isMuted;
}
isPartDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
if (userState) isPartMuted = userState.isMuted;
}
allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted, isDeafened, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack });
allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: isPartMuted, isDeafened: isPartDeafened, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack });
};
processParticipant(r.localParticipant, true);
r.remoteParticipants.forEach((p) => processParticipant(p, false));
setParticipants(allParticipants);
}, []);
// Sync Input Gain value
useEffect(() => {
if (localGainNodeRef.current) {
const ctx = getSharedAudioCtx();
localGainNodeRef.current.gain.setTargetAtTime(inputVolume / 100, ctx?.currentTime || 0, 0.01);
}
}, [inputVolume]);
const handleDataReceived = useCallback((payload: Uint8Array, participant?: RemoteParticipant) => {
try {
const text = new TextDecoder().decode(payload);
@@ -157,6 +188,40 @@ export function useLiveKit() {
} catch { }
}, [updateParticipants]);
const setupLocalGainPipeline = useCallback(async (room: Room, audioTrack: LocalAudioTrack) => {
try {
const ctx = getSharedAudioCtx();
if (!ctx) return;
if (!localGainNodeRef.current) {
localGainNodeRef.current = ctx.createGain();
localDestRef.current = ctx.createMediaStreamDestination();
localGainNodeRef.current.connect(localDestRef.current);
}
if (localSourceRef.current) localSourceRef.current.disconnect();
localSourceRef.current = ctx.createMediaStreamSource(new MediaStream([audioTrack.mediaStreamTrack]));
localSourceRef.current.connect(localGainNodeRef.current!);
// Initialize gain from store
localGainNodeRef.current!.gain.value = useVoiceStore.getState().inputVolume / 100;
const processedTrack = localDestRef.current!.stream.getAudioTracks()[0];
const engine = (room as any).engine;
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc;
if (pc && processedTrack) {
const senders = (pc as RTCPeerConnection).getSenders();
const sender = senders.find(s => s.track?.id === audioTrack.mediaStreamTrack.id);
if (sender) {
console.log('[LiveKit] Swapping raw mic for gain-processed track');
await sender.replaceTrack(processedTrack);
}
}
} catch (err) {
console.error('[LiveKit] Local gain setup failed:', err);
}
}, []);
const connect = useCallback(async (channelId: string) => {
if (connectedChannelRef.current === channelId && roomRef.current) return;
const gen = ++_connectGeneration;
@@ -166,13 +231,12 @@ export function useLiveKit() {
try {
const { token, url } = await api.livekit.token(channelId);
if (gen !== _connectGeneration) return;
// Disable simulcast for better 60fps stability on local networks
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
roomRef.current = newRoom;
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
guardedUpdate();
// Re-broadcast local deafen state to newly connected participant
if (useVoiceStore.getState().isDeafened) {
const encoder = new TextEncoder();
newRoom.localParticipant.publishData(
@@ -184,7 +248,12 @@ export function useLiveKit() {
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackPublished, (pub) => {
guardedUpdate();
if (pub.source === Track.Source.Microphone && pub.track instanceof LocalAudioTrack) {
setupLocalGainPipeline(newRoom, pub.track);
}
});
newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate);
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
@@ -203,26 +272,32 @@ export function useLiveKit() {
roomRef.current = null; _activeRoom = null; setIsConnected(false); setRoom(null); setParticipants([]);
useVoiceStore.getState().setIsLiveKitConnected(false);
});
await newRoom.connect(url, token);
if (gen !== _connectGeneration) { newRoom.disconnect(); return; }
_activeRoom = newRoom; connectedChannelRef.current = channelId; setRoom(newRoom); setIsConnected(true);
_activeRoom = newRoom;
connectedChannelRef.current = channelId;
setRoom(newRoom);
setIsConnected(true);
useVoiceStore.getState().setIsLiveKitConnected(true);
updateParticipants();
// Preserve mute/deafen state across channel switches, only reset camera/screen
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
if (!wasMuted && !wasDeafened) {
try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); }
await newRoom.localParticipant.setMicrophoneEnabled(true);
} else {
// Keep mic disabled — user was muted or deafened
await newRoom.localParticipant.setMicrophoneEnabled(false);
if (wasDeafened) {
newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
}
updateParticipants();
}
updateParticipants();
} catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); }
finally { if (gen === _connectGeneration) setIsConnecting(false); }
}, [updateParticipants, handleDataReceived, isMuted, isDeafened]);
}, [updateParticipants, handleDataReceived, setupLocalGainPipeline]);
const connectDm = useCallback(async (dmChannelId: string) => {
const gen = ++_connectGeneration;
@@ -238,7 +313,12 @@ export function useLiveKit() {
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackPublished, (pub) => {
guardedUpdate();
if (pub.source === Track.Source.Microphone && pub.track instanceof LocalAudioTrack) {
setupLocalGainPipeline(newRoom, pub.track);
}
});
newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate);
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
@@ -258,30 +338,35 @@ export function useLiveKit() {
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
if (!wasMuted && !wasDeafened) {
try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); }
await newRoom.localParticipant.setMicrophoneEnabled(true);
} else {
await newRoom.localParticipant.setMicrophoneEnabled(false);
if (wasDeafened) {
newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
}
updateParticipants();
}
updateParticipants();
} catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); }
finally { if (gen === _connectGeneration) setIsConnecting(false); }
}, [updateParticipants, handleDataReceived, isMuted, isDeafened]);
}, [updateParticipants, handleDataReceived, setupLocalGainPipeline]);
const disconnect = useCallback(async () => {
_connectGeneration++;
if (roomRef.current) { await roomRef.current.disconnect(); roomRef.current = null; _activeRoom = null; connectedChannelRef.current = null; setRoom(null); setIsConnected(false); setParticipants([]); useVoiceStore.getState().setIsLiveKitConnected(false); }
}, []);
const toggleMic = useCallback(async () => { if (roomRef.current) { await roomRef.current.localParticipant.setMicrophoneEnabled(isMuted); updateParticipants(); } }, [isMuted, updateParticipants]);
const toggleMic = useCallback(async () => {
if (roomRef.current) {
await roomRef.current.localParticipant.setMicrophoneEnabled(!isMuted);
updateParticipants();
}
}, [isMuted, updateParticipants]);
const toggleCamera = useCallback(async () => {
if (roomRef.current) {
if (!isCameraOn) {
const preset = QUALITY_MAP[videoQuality] || VideoPresets.h720;
await roomRef.current.localParticipant.setCameraEnabled(true, { resolution: preset.resolution, frameRate: preset.encoding.maxFramerate }, { videoCodec: 'h264', videoEncoding: preset.encoding, simulcast: false });
// Soft Start Camera
setTimeout(() => { if (roomRef.current) applyOverdriveHammer(roomRef.current, Track.Source.Camera, preset); }, 2000);
} else { await roomRef.current.localParticipant.setCameraEnabled(false); }
updateParticipants();
@@ -292,9 +377,6 @@ export function useLiveKit() {
if (roomRef.current) {
if (!isScreenSharing) {
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
console.log('[LiveKit] Soft-Launching Screen Share (360p start)...');
// SOFT LAUNCH: Start at 360p 30fps to clear handshake
const track = await roomRef.current.localParticipant.setScreenShareEnabled(true, {
resolution: VideoPresets.h360.resolution,
// @ts-ignore
@@ -304,10 +386,8 @@ export function useLiveKit() {
} as any);
if (track) {
// UPGRADE: After 2 seconds, switch to full 60fps quality
setTimeout(async () => {
if (roomRef.current && isScreenSharing) {
console.log('[LiveKit] Upgrading to Target Quality...');
const screenPub = roomRef.current.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
if (screenPub?.track?.mediaStreamTrack) {
await screenPub.track.mediaStreamTrack.applyConstraints({
@@ -319,8 +399,6 @@ export function useLiveKit() {
}
}
}, 2000);
// Re-apply hammer
setTimeout(() => applyOverdriveHammer(roomRef.current!, Track.Source.ScreenShare, preset), 5000);
}
} else { await roomRef.current.localParticipant.setScreenShareEnabled(false); }
@@ -332,7 +410,6 @@ export function useLiveKit() {
updateParticipants();
}, [voiceUserStates, isMuted, isDeafened, updateParticipants]);
// Sync quality changes
useEffect(() => {
if (!room) return;
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
@@ -379,5 +456,5 @@ export function useLiveKit() {
return () => { _connectGeneration++; if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; _activeRoom = null; } };
}, []);
return { room, participants, isConnected, isConnecting, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare };
return { room, participants, isConnected, isConnecting, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare, getSharedAudioCtx };
}
+7 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import { useAuthStore } from '../stores/authStore';
import { useServerStore } from '../stores/serverStore';
import { useChatStore } from '../stores/chatStore';
@@ -280,6 +280,7 @@ export function wsSend(event: ClientEvent): void {
export function useWebSocket() {
const token = useAuthStore((s) => s.token);
const prevToken = useRef(token);
const [isConnected, setIsConnected] = React.useState(false);
useEffect(() => {
if (token && (!isInitialized || token !== prevToken.current)) {
@@ -293,10 +294,14 @@ export function useWebSocket() {
}, [token]);
useEffect(() => {
const checkStatus = setInterval(() => {
setIsConnected(!!globalWs && globalWs.readyState === WebSocket.OPEN);
}, 500);
return () => {
clearInterval(checkStatus);
disconnect();
};
}, []);
return { send: wsSend };
return { send: wsSend, isConnected };
}