Fix double audio, Chrome reload silence, and restore input gain functionality
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user