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(() => {
@@ -542,129 +542,121 @@ function UserAreaPanel({
<div className="mx-4 border-t border-[#2b2d31]" />
{/* Input Volume */}
<div className="px-4 py-3">
<div className="text-[15px] font-semibold text-discord-text-primary mb-2">Input Volume</div>
<input
type="range"
min={0}
max={200}
value={inputVolume}
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
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 */}
<div className="flex items-center gap-[3px] mt-2.5">
{Array.from({ length: micBars }).map((_, i) => (
<div
key={i}
className={`flex-1 h-[6px] rounded-[1px] transition-colors duration-75 ${
i < activeBars ? 'bg-discord-text-muted' : 'bg-[#313338]'
}`}
/>
))}
</div>
</div>
<div className="mx-4 border-t border-[#2b2d31]" />
{/* Voice Settings link */}
<button
onClick={onSettingsClick}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors"
>
<span className="text-[15px] font-semibold text-discord-text-primary">Voice Settings</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
</button>
</div>
)}
{/* Output settings panel */}
{openPanel === 'output' && (
<div className="absolute bottom-full left-0 right-0 mb-0 bg-[#1e1f22] rounded-t-lg shadow-lg z-50 border-t border-x border-discord-bg-tertiary">
{/* Output Device */}
<div className="relative">
<button
onClick={() => setShowOutputDeviceList(!showOutputDeviceList)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors"
>
<div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold text-discord-text-primary text-left">Output Device</div>
<div className="text-[13px] text-discord-text-muted truncate text-left">{selectedOutputLabel}</div>
</div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0 ml-2">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</button>
{showOutputDeviceList && (
<div className="bg-discord-bg-floating rounded-lg shadow-lg mx-2 mb-2 py-1 border border-discord-bg-tertiary">
{outputDevices.map(d => (
<button
key={d.deviceId}
onClick={() => selectOutput(d)}
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-discord-modifier-hover transition-colors flex items-center gap-2 ${
selectedOutput === d.deviceId ? 'text-discord-text-primary' : 'text-discord-text-secondary'
}`}
>
{selectedOutput === d.deviceId && (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-discord-blurple flex-shrink-0">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
)}
<span className={selectedOutput === d.deviceId ? '' : 'pl-6'}>{d.label || 'Default'}</span>
</button>
))}
</div>
)}
</div>
<div className="mx-4 border-t border-[#2b2d31]" />
{/* Output Volume */}
<div className="px-4 py-3">
<div className="text-[15px] font-semibold text-discord-text-primary mb-2">Output Volume</div>
<input
type="range"
min={0}
max={200}
value={outputVolume}
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={{
background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${outputVolume / 2}%, #4e5058 ${outputVolume / 2}%, #4e5058 100%)`,
}}
/>
</div>
{/* Input Volume */}
<div className="px-4 py-3">
<div className="text-[15px] font-semibold text-discord-text-primary mb-2">Input Volume</div>
<input
type="range"
min={0}
max={200}
value={inputVolume}
onChange={(e) => {
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 */}
<div className="flex items-center gap-[3px] mt-2.5">
{Array.from({ length: micBars }).map((_, i) => (
<div
key={i}
className={`flex-1 h-[6px] rounded-[1px] transition-colors duration-75 ${
i < activeBars ? 'bg-discord-text-muted' : 'bg-[#313338]'
}`}
/>
))}
</div>
</div>
<div className="mx-4 border-t border-[#2b2d31]" />
{/* Voice Settings link */}
<button
onClick={onSettingsClick}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors"
>
<span className="text-[15px] font-semibold text-discord-text-primary">Voice Settings</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
</button>
</div>
)}
{/* Output settings panel */}
{openPanel === 'output' && (
<div className="absolute bottom-full left-0 right-0 mb-0 bg-[#1e1f22] rounded-t-lg shadow-lg z-50 border-t border-x border-discord-bg-tertiary">
{/* Output Device */}
<div className="relative">
<button
onClick={() => setShowOutputDeviceList(!showOutputDeviceList)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors"
>
<div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold text-discord-text-primary text-left">Output Device</div>
<div className="text-[13px] text-discord-text-muted truncate text-left">{selectedOutputLabel}</div>
</div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0 ml-2">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</button>
{showOutputDeviceList && (
<div className="bg-discord-bg-floating rounded-lg shadow-lg mx-2 mb-2 py-1 border border-discord-bg-tertiary">
{outputDevices.map(d => (
<button
key={d.deviceId}
onClick={() => selectOutput(d)}
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-discord-modifier-hover transition-colors flex items-center gap-2 ${
selectedOutput === d.deviceId ? 'text-discord-text-primary' : 'text-discord-text-secondary'
}`}
>
{selectedOutput === d.deviceId && (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-discord-blurple flex-shrink-0">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
)}
<span className={selectedOutput === d.deviceId ? '' : 'pl-6'}>{d.label || 'Default'}</span>
</button>
))}
</div>
)}
</div>
<div className="mx-4 border-t border-[#2b2d31]" />
{/* Output Volume */}
<div className="px-4 py-3">
<div className="text-[15px] font-semibold text-discord-text-primary mb-2">Output Volume</div>
<input
type="range"
min={0}
max={200}
value={outputVolume}
onChange={(e) => {
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%)`,
}}
/>
</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,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<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) {
@@ -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]"