From 5ba64d2aeabc9c7a9ca006f6c0151c4e26a72646 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:43:41 +0100 Subject: [PATCH] Fix double audio, Chrome reload silence, and restore input gain functionality --- .../web/src/components/layout/AppLayout.tsx | 9 +- .../src/components/layout/ChannelSidebar.tsx | 238 +++++++++--------- .../web/src/components/layout/MainContent.tsx | 84 +++---- .../src/components/voice/VoiceControlBar.tsx | 7 - .../web/src/components/voice/VoiceGrid.tsx | 18 +- .../web/src/components/voice/VoiceUser.tsx | 117 +++++++-- packages/web/src/hooks/useLiveKit.ts | 177 +++++++++---- packages/web/src/hooks/useWebSocket.ts | 9 +- 8 files changed, 393 insertions(+), 266 deletions(-) diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index b9edf6af..31f11ae5 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -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(() => { diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 7a96ce33..2f8dd4d5 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -542,129 +542,121 @@ function UserAreaPanel({
- {/* Input Volume */} -
-
Input Volume
- { - 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 - 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" - style={{ - background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${inputVolume / 2}%, #4e5058 ${inputVolume / 2}%, #4e5058 100%)`, - }} - /> - {/* Mic level meter */} -
- {Array.from({ length: micBars }).map((_, i) => ( -
- ))} -
-
- -
- - {/* Voice Settings link */} - -
- )} - - {/* Output settings panel */} - {openPanel === 'output' && ( -
- {/* Output Device */} -
- - {showOutputDeviceList && ( -
- {outputDevices.map(d => ( - - ))} -
- )} -
- -
- - {/* Output Volume */} -
-
Output Volume
- { - 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={{ - background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${outputVolume / 2}%, #4e5058 ${outputVolume / 2}%, #4e5058 100%)`, - }} - /> -
- + {/* Input Volume */} +
+
Input Volume
+ { + const vol = Number(e.target.value); + storeSetInputVolume(vol); + + const room = getActiveRoom(); + 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" + style={{ + background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${inputVolume / 2}%, #4e5058 ${inputVolume / 2}%, #4e5058 100%)`, + }} + /> + {/* Mic level meter */} +
+ {Array.from({ length: micBars }).map((_, i) => ( +
+ ))} +
+
+ +
+ + {/* Voice Settings link */} + +
+ )} + + {/* Output settings panel */} + {openPanel === 'output' && ( +
+ {/* Output Device */} +
+ + {showOutputDeviceList && ( +
+ {outputDevices.map(d => ( + + ))} +
+ )} +
+ +
+ + {/* Output Volume */} +
+
Output Volume
+ { + const vol = Number(e.target.value); + storeSetOutputVolume(vol); + }} + 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={{ + background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${outputVolume / 2}%, #4e5058 ${outputVolume / 2}%, #4e5058 100%)`, + }} + /> +
{/* Voice Settings link */} diff --git a/packages/web/src/components/layout/MainContent.tsx b/packages/web/src/components/layout/MainContent.tsx index 06d1f085..b14a9eb9 100644 --- a/packages/web/src/components/layout/MainContent.tsx +++ b/packages/web/src/components/layout/MainContent.tsx @@ -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,19 +24,39 @@ 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(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) { @@ -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 (
@@ -74,7 +92,6 @@ export function MainContent() { return (
- {/* Outgoing call banner */} {isCallingThisDm && (
@@ -99,7 +116,6 @@ export function MainContent() { {dmName}
- {/* Voice Call */} - {/* Video Call */} - {/* Pinned Messages */} - {/* Add Friends to DM */} - {/* Divider */}
- {/* Search */} - {/* Inbox */} - {/* Help */} - {/* Notification Settings */} - {/* Pinned Messages */} - {/* Member List Toggle */} - {/* Divider */}
- {/* Search */} - {/* Inbox */} - {/* Help */}
- - {/* Messages */} - - {/* Typing indicator */} - - {/* Message input */}
); diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index 637aedb0..737e2106 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -131,13 +131,6 @@ export function VoiceControlBar() { }; const handleFullscreen = () => { - if (!voiceFullscreen) { - document.documentElement.requestFullscreen?.().catch(() => {}); - } else { - if (document.fullscreenElement) { - document.exitFullscreen().catch(() => {}); - } - } toggleVoiceFullscreen(); }; diff --git a/packages/web/src/components/voice/VoiceGrid.tsx b/packages/web/src/components/voice/VoiceGrid.tsx index 26825209..809ac413 100644 --- a/packages/web/src/components/voice/VoiceGrid.tsx +++ b/packages/web/src/components/voice/VoiceGrid.tsx @@ -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 ( -
+
{/* Main focused view */} -
+
{/* Back to grid button */}
- {/* Side strip of other participants */} + {/* Bottom strip of other participants */} {otherParticipants.length > 0 && ( -
+
{otherParticipants.map((p) => (
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" >
@@ -107,13 +107,13 @@ export function VoiceGrid({ participants }: VoiceGridProps) { })(); return ( -
-
+
+
{participants.map((p) => (
setFocusedParticipant(p.identity)} - className="cursor-pointer hover:opacity-90 transition-opacity" + className="cursor-pointer hover:opacity-90 transition-opacity h-full" >
diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx index 7b8a6e15..ed17f25a 100644 --- a/packages/web/src/components/voice/VoiceUser.tsx +++ b/packages/web/src/components/voice/VoiceUser.tsx @@ -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('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(null); + const sourceNodeRef = useRef(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