Fix double audio, Chrome reload silence, and restore input gain functionality
This commit is contained in:
@@ -50,7 +50,7 @@ export function AppLayout() {
|
|||||||
} = useLiveKit();
|
} = useLiveKit();
|
||||||
|
|
||||||
// Initialize WebSocket
|
// Initialize WebSocket
|
||||||
useWebSocket();
|
const { isConnected: isWsConnected } = useWebSocket();
|
||||||
|
|
||||||
// Sync participants to store
|
// Sync participants to store
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,12 +59,13 @@ export function AppLayout() {
|
|||||||
|
|
||||||
// Manage voice connection (server voice channels)
|
// Manage voice connection (server voice channels)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentVoiceChannelId) {
|
if (!isLoading && user && isWsConnected && currentVoiceChannelId) {
|
||||||
|
console.log('[AppLayout] Auto-rejoining voice channel:', currentVoiceChannelId);
|
||||||
connectVoice(currentVoiceChannelId);
|
connectVoice(currentVoiceChannelId);
|
||||||
} else if (!activeDmCall) {
|
} else if (!isLoading && user && !currentVoiceChannelId && !activeDmCall) {
|
||||||
disconnectVoice();
|
disconnectVoice();
|
||||||
}
|
}
|
||||||
}, [currentVoiceChannelId, connectVoice, disconnectVoice, activeDmCall]);
|
}, [currentVoiceChannelId, connectVoice, disconnectVoice, activeDmCall, isLoading, user, isWsConnected]);
|
||||||
|
|
||||||
// Manage DM call connection
|
// Manage DM call connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -542,129 +542,121 @@ function UserAreaPanel({
|
|||||||
|
|
||||||
<div className="mx-4 border-t border-[#2b2d31]" />
|
<div className="mx-4 border-t border-[#2b2d31]" />
|
||||||
|
|
||||||
{/* Input Volume */}
|
{/* Input Volume */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<div className="text-[15px] font-semibold text-discord-text-primary mb-2">Input Volume</div>
|
<div className="text-[15px] font-semibold text-discord-text-primary mb-2">Input Volume</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={200}
|
max={200}
|
||||||
value={inputVolume}
|
value={inputVolume}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const vol = Number(e.target.value);
|
const vol = Number(e.target.value);
|
||||||
storeSetInputVolume(vol);
|
storeSetInputVolume(vol);
|
||||||
// Apply gain to mic: at 0 = mute, 100 = normal, 200 = 2x boost
|
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
if (room && room.localParticipant.isMicrophoneEnabled) {
|
if (room) {
|
||||||
if (vol === 0) {
|
const { isMuted: manuallyMuted, isDeafened: manuallyDeafened } = useVoiceStore.getState();
|
||||||
room.localParticipant.setMicrophoneEnabled(false).catch(() => {});
|
// If user is manually muted, hardware should stay off regardless of volume.
|
||||||
} else {
|
// If user is NOT manually muted and volume is 0, we can keep hardware ON
|
||||||
// Re-enable mic if it was muted by volume slider
|
// (Web Audio handles silence) or turn it OFF for battery/privacy.
|
||||||
room.localParticipant.setMicrophoneEnabled(true).catch(() => {});
|
// 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={{
|
}} 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"
|
||||||
background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${inputVolume / 2}%, #4e5058 ${inputVolume / 2}%, #4e5058 100%)`,
|
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">
|
{/* Mic level meter */}
|
||||||
{Array.from({ length: micBars }).map((_, i) => (
|
<div className="flex items-center gap-[3px] mt-2.5">
|
||||||
<div
|
{Array.from({ length: micBars }).map((_, i) => (
|
||||||
key={i}
|
<div
|
||||||
className={`flex-1 h-[6px] rounded-[1px] transition-colors duration-75 ${
|
key={i}
|
||||||
i < activeBars ? 'bg-discord-text-muted' : 'bg-[#313338]'
|
className={`flex-1 h-[6px] rounded-[1px] transition-colors duration-75 ${
|
||||||
}`}
|
i < activeBars ? 'bg-discord-text-muted' : 'bg-[#313338]'
|
||||||
/>
|
}`}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mx-4 border-t border-[#2b2d31]" />
|
|
||||||
|
<div className="mx-4 border-t border-[#2b2d31]" />
|
||||||
{/* Voice Settings link */}
|
|
||||||
<button
|
{/* Voice Settings link */}
|
||||||
onClick={onSettingsClick}
|
<button
|
||||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors"
|
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">
|
<span className="text-[15px] font-semibold text-discord-text-primary">Voice Settings</span>
|
||||||
<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 width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
|
||||||
</svg>
|
<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" />
|
||||||
</button>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
{/* Output settings panel */}
|
|
||||||
{openPanel === 'output' && (
|
{/* Output settings panel */}
|
||||||
<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">
|
{openPanel === 'output' && (
|
||||||
{/* Output Device */}
|
<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">
|
||||||
<div className="relative">
|
{/* Output Device */}
|
||||||
<button
|
<div className="relative">
|
||||||
onClick={() => setShowOutputDeviceList(!showOutputDeviceList)}
|
<button
|
||||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors"
|
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="min-w-0 flex-1">
|
||||||
<div className="text-[13px] text-discord-text-muted truncate text-left">{selectedOutputLabel}</div>
|
<div className="text-[15px] font-semibold text-discord-text-primary text-left">Output Device</div>
|
||||||
</div>
|
<div className="text-[13px] text-discord-text-muted truncate text-left">{selectedOutputLabel}</div>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0 ml-2">
|
</div>
|
||||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0 ml-2">
|
||||||
</svg>
|
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||||
</button>
|
</svg>
|
||||||
{showOutputDeviceList && (
|
</button>
|
||||||
<div className="bg-discord-bg-floating rounded-lg shadow-lg mx-2 mb-2 py-1 border border-discord-bg-tertiary">
|
{showOutputDeviceList && (
|
||||||
{outputDevices.map(d => (
|
<div className="bg-discord-bg-floating rounded-lg shadow-lg mx-2 mb-2 py-1 border border-discord-bg-tertiary">
|
||||||
<button
|
{outputDevices.map(d => (
|
||||||
key={d.deviceId}
|
<button
|
||||||
onClick={() => selectOutput(d)}
|
key={d.deviceId}
|
||||||
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-discord-modifier-hover transition-colors flex items-center gap-2 ${
|
onClick={() => selectOutput(d)}
|
||||||
selectedOutput === d.deviceId ? 'text-discord-text-primary' : 'text-discord-text-secondary'
|
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">
|
{selectedOutput === d.deviceId && (
|
||||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-discord-blurple flex-shrink-0">
|
||||||
</svg>
|
<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>
|
<span className={selectedOutput === d.deviceId ? '' : 'pl-6'}>{d.label || 'Default'}</span>
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
<div className="mx-4 border-t border-[#2b2d31]" />
|
|
||||||
|
<div className="mx-4 border-t border-[#2b2d31]" />
|
||||||
{/* Output Volume */}
|
|
||||||
<div className="px-4 py-3">
|
{/* Output Volume */}
|
||||||
<div className="text-[15px] font-semibold text-discord-text-primary mb-2">Output Volume</div>
|
<div className="px-4 py-3">
|
||||||
<input
|
<div className="text-[15px] font-semibold text-discord-text-primary mb-2">Output Volume</div>
|
||||||
type="range"
|
<input
|
||||||
min={0}
|
type="range"
|
||||||
max={200}
|
min={0}
|
||||||
value={outputVolume}
|
max={200}
|
||||||
onChange={(e) => {
|
value={outputVolume}
|
||||||
const vol = Number(e.target.value);
|
onChange={(e) => {
|
||||||
storeSetOutputVolume(vol);
|
const vol = Number(e.target.value);
|
||||||
// Apply volume to all remote participants
|
storeSetOutputVolume(vol);
|
||||||
const room = getActiveRoom();
|
}}
|
||||||
if (room) {
|
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"
|
||||||
const scaled = vol / 100; // 0-2 range (0%=0, 100%=1, 200%=2)
|
style={{
|
||||||
room.remoteParticipants.forEach((participant) => {
|
background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${outputVolume / 2}%, #4e5058 ${outputVolume / 2}%, #4e5058 100%)`,
|
||||||
participant.setVolume(scaled);
|
}}
|
||||||
});
|
/>
|
||||||
}
|
</div>
|
||||||
}}
|
|
||||||
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]" />
|
<div className="mx-4 border-t border-[#2b2d31]" />
|
||||||
|
|
||||||
{/* Voice Settings link */}
|
{/* 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 { useServerStore } from '../../stores/serverStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
@@ -16,6 +16,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
|
|||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
|
|
||||||
export function MainContent() {
|
export function MainContent() {
|
||||||
|
// 1. ALL HOOKS AT THE TOP
|
||||||
const channels = useServerStore((s) => s.channels);
|
const channels = useServerStore((s) => s.channels);
|
||||||
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
||||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||||
@@ -23,19 +24,39 @@ export function MainContent() {
|
|||||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||||
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
|
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
|
||||||
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
||||||
|
const setVoiceFullscreen = useUIStore((s) => s.setVoiceFullscreen);
|
||||||
const participants = useVoiceStore((s) => s.participants);
|
const participants = useVoiceStore((s) => s.participants);
|
||||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
const showDms = useUIStore((s) => s.showDms);
|
const showDms = useUIStore((s) => s.showDms);
|
||||||
|
|
||||||
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
||||||
const outgoingCall = useVoiceStore((s) => s.outgoingCall);
|
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 dmChannels = useServerStore((s) => s.dmChannels);
|
||||||
const authUser = useAuthStore((s) => s.user);
|
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 (showDms || !currentServerId) {
|
||||||
if (!currentChannelId) {
|
if (!currentChannelId) {
|
||||||
@@ -45,9 +66,7 @@ export function MainContent() {
|
|||||||
const dmChannel = dmChannels.find(dm => dm.id === currentChannelId);
|
const dmChannel = dmChannels.find(dm => dm.id === currentChannelId);
|
||||||
const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id);
|
const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id);
|
||||||
const dmName = otherUser?.displayName ?? otherUser?.username ?? 'Direct Message';
|
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 isInDmCall = activeDmCall?.dmChannelId === currentChannelId;
|
||||||
const isCallingThisDm = outgoingCall?.dmChannelId === currentChannelId;
|
const isCallingThisDm = outgoingCall?.dmChannelId === currentChannelId;
|
||||||
|
|
||||||
@@ -63,7 +82,6 @@ export function MainContent() {
|
|||||||
wsSend({ type: 'dm_call_end', dmChannelId: currentChannelId });
|
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) {
|
if (isInDmCall) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-w-0 relative">
|
<div className="flex-1 flex flex-col min-w-0 relative">
|
||||||
@@ -74,7 +92,6 @@ export function MainContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative">
|
<div className="flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative">
|
||||||
{/* Outgoing call banner */}
|
|
||||||
{isCallingThisDm && (
|
{isCallingThisDm && (
|
||||||
<div className="bg-discord-green/10 border-b border-discord-green/20 px-4 py-3 flex items-center justify-between">
|
<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">
|
<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>
|
<span className="font-bold text-discord-text-primary truncate">{dmName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{/* Voice Call */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleStartVoiceCall}
|
onClick={handleStartVoiceCall}
|
||||||
disabled={!!outgoingCall || !!activeDmCall}
|
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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/* Video Call */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleStartVoiceCall}
|
onClick={handleStartVoiceCall}
|
||||||
disabled={!!outgoingCall || !!activeDmCall}
|
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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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">
|
<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">
|
<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="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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/* Divider */}
|
|
||||||
<div className="w-[1px] h-6 bg-discord-modifier-accent mx-1" />
|
<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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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">
|
<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">
|
<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" />
|
<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) {
|
if (!currentChannelId || !channel) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col bg-discord-bg-primary">
|
<div className="flex-1 flex flex-col bg-discord-bg-primary">
|
||||||
@@ -177,11 +185,9 @@ export function MainContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice/Video channel view
|
|
||||||
if (isVoiceChannel) {
|
if (isVoiceChannel) {
|
||||||
const isInThisChannel = currentVoiceChannelId === currentChannelId;
|
const isInThisChannel = currentVoiceChannelId === currentChannelId;
|
||||||
|
|
||||||
// Not connected — show "Join Voice" prompt with gradient
|
|
||||||
if (!isInThisChannel) {
|
if (!isInThisChannel) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col bg-[#0b0c0e]">
|
<div className="flex-1 flex flex-col bg-[#0b0c0e]">
|
||||||
@@ -194,7 +200,6 @@ export function MainContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-8 relative">
|
<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="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">
|
<div className="text-center relative z-10">
|
||||||
<h2 className="text-[28px] font-bold text-white mb-3">{channel.name}</h2>
|
<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
|
return (
|
||||||
const voiceView = (
|
<div
|
||||||
<div className={`flex-1 flex flex-col bg-[#0b0c0e] min-w-0 ${voiceFullscreen ? 'fixed inset-0 z-50' : ''} group/voice relative`}>
|
ref={voiceContainerRef}
|
||||||
{/* Voice header */}
|
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]">
|
>
|
||||||
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content: grid + optional chat */}
|
|
||||||
<div className="flex-1 flex overflow-hidden pb-20">
|
<div className="flex-1 flex overflow-hidden pb-20">
|
||||||
<VoiceGrid participants={participants} />
|
<VoiceGrid participants={participants} />
|
||||||
{voiceChatOpen && (
|
{voiceChatOpen && !voiceFullscreen && (
|
||||||
<VoiceChatPanel channelId={currentChannelId} channelName={channel.name} />
|
<VoiceChatPanel channelId={currentChannelId} channelName={channel.name} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating control bar */}
|
|
||||||
<VoiceControlBar />
|
<VoiceControlBar />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return voiceView;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text channel view
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative">
|
<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="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">
|
<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">
|
<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>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/* Member List Toggle */}
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleMemberList}
|
onClick={toggleMemberList}
|
||||||
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${
|
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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/* Divider */}
|
|
||||||
<div className="w-[1px] h-6 bg-discord-modifier-accent mx-1" />
|
<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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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">
|
<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">
|
<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" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
<MessageList channelId={currentChannelId} />
|
<MessageList channelId={currentChannelId} />
|
||||||
|
|
||||||
{/* Typing indicator */}
|
|
||||||
<TypingIndicator channelId={currentChannelId} />
|
<TypingIndicator channelId={currentChannelId} />
|
||||||
|
|
||||||
{/* Message input */}
|
|
||||||
<MessageInput channelId={currentChannelId} channelName={channel.name} />
|
<MessageInput channelId={currentChannelId} channelName={channel.name} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -131,13 +131,6 @@ export function VoiceControlBar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFullscreen = () => {
|
const handleFullscreen = () => {
|
||||||
if (!voiceFullscreen) {
|
|
||||||
document.documentElement.requestFullscreen?.().catch(() => {});
|
|
||||||
} else {
|
|
||||||
if (document.fullscreenElement) {
|
|
||||||
document.exitFullscreen().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toggleVoiceFullscreen();
|
toggleVoiceFullscreen();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -56,15 +56,15 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
|||||||
? participants.find((p) => p.identity === focusedParticipantId)
|
? participants.find((p) => p.identity === focusedParticipantId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Focus mode: one large tile + sidebar strip
|
// Focus mode: one large tile + bottom strip
|
||||||
if (focusedParticipant) {
|
if (focusedParticipant) {
|
||||||
const otherParticipants = participants.filter(
|
const otherParticipants = participants.filter(
|
||||||
(p) => p.identity !== focusedParticipantId,
|
(p) => p.identity !== focusedParticipantId,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||||
{/* Main focused view */}
|
{/* Main focused view */}
|
||||||
<div className="flex-1 p-2 relative">
|
<div className="flex-1 p-2 min-h-0">
|
||||||
<VoiceUser participant={focusedParticipant} large />
|
<VoiceUser participant={focusedParticipant} large />
|
||||||
{/* Back to grid button */}
|
{/* Back to grid button */}
|
||||||
<button
|
<button
|
||||||
@@ -79,14 +79,14 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Side strip of other participants */}
|
{/* Bottom strip of other participants */}
|
||||||
{otherParticipants.length > 0 && (
|
{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) => (
|
{otherParticipants.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.identity}
|
key={p.identity}
|
||||||
onClick={() => setFocusedParticipant(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} />
|
<VoiceUser participant={p} />
|
||||||
</div>
|
</div>
|
||||||
@@ -107,13 +107,13 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 p-3 overflow-auto flex items-center">
|
<div className="flex-1 p-3 overflow-auto flex items-center min-h-0">
|
||||||
<div className={`grid ${gridClass} gap-2 w-full`}>
|
<div className={`grid ${gridClass} gap-2 w-full max-h-full`}>
|
||||||
{participants.map((p) => (
|
{participants.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.identity}
|
key={p.identity}
|
||||||
onClick={() => setFocusedParticipant(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} />
|
<VoiceUser participant={p} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
|
import { getSharedAudioCtx } from '../../hooks/useLiveKit';
|
||||||
import type { ParticipantInfo } from '../../hooks/useLiveKit';
|
import type { ParticipantInfo } from '../../hooks/useLiveKit';
|
||||||
|
|
||||||
interface VoiceUserProps {
|
interface VoiceUserProps {
|
||||||
@@ -19,14 +20,92 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
|||||||
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
||||||
const isLocal = participant.isLocal;
|
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 liveScreen = participant.isScreenSharing && participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null;
|
||||||
const liveCamera = participant.isCameraOn && participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null;
|
const liveCamera = participant.isCameraOn && participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null;
|
||||||
const activeVideoTrack = liveScreen ?? liveCamera;
|
const activeVideoTrack = liveScreen ?? liveCamera;
|
||||||
const hasVideo = activeVideoTrack !== null;
|
const hasVideo = activeVideoTrack !== null;
|
||||||
const isScreenShare = liveScreen !== null;
|
const isScreenShare = liveScreen !== null;
|
||||||
|
|
||||||
// Listen for track 'ended' events to force re-render when a stream stops
|
// Listen for track 'ended' events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tracks = [participant.videoTrack, participant.screenTrack].filter(
|
const tracks = [participant.videoTrack, participant.screenTrack].filter(
|
||||||
(t): t is MediaStreamTrack => t !== null,
|
(t): t is MediaStreamTrack => t !== null,
|
||||||
@@ -53,22 +132,9 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
|||||||
const audioEl = audioRef.current;
|
const audioEl = audioRef.current;
|
||||||
if (!audioEl || !participant.audioTrack) return;
|
if (!audioEl || !participant.audioTrack) return;
|
||||||
audioEl.srcObject = new MediaStream([participant.audioTrack]);
|
audioEl.srcObject = new MediaStream([participant.audioTrack]);
|
||||||
|
// Note: play() and muted state are handled by the volume effect above
|
||||||
}, [participant.audioTrack]);
|
}, [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
|
// Volume context menu
|
||||||
const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null);
|
const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
|
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
|
||||||
@@ -77,6 +143,10 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
|||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (isLocal) return;
|
if (isLocal) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const ctx = getSharedAudioCtx();
|
||||||
|
if (ctx && ctx.state === 'suspended') {
|
||||||
|
ctx.resume();
|
||||||
|
}
|
||||||
setVolumeMenu({ x: e.clientX, y: e.clientY });
|
setVolumeMenu({ x: e.clientX, y: e.clientY });
|
||||||
},
|
},
|
||||||
[isLocal],
|
[isLocal],
|
||||||
@@ -89,17 +159,23 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
|||||||
return () => window.removeEventListener('click', close);
|
return () => window.removeEventListener('click', close);
|
||||||
}, [volumeMenu]);
|
}, [volumeMenu]);
|
||||||
|
|
||||||
|
const handleInteraction = useCallback(() => {
|
||||||
|
const ctx = getSharedAudioCtx();
|
||||||
|
if (ctx && ctx.state === 'suspended') {
|
||||||
|
ctx.resume().catch(console.error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
onClick={handleInteraction}
|
||||||
className={`relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${
|
className={`relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${
|
||||||
participant.isSpeaking
|
participant.isSpeaking
|
||||||
? 'ring-[3px] ring-discord-green shadow-[0_0_12px_rgba(35,165,90,0.25)]'
|
? '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'
|
: 'ring-1 ring-white/[0.06] hover:ring-white/10'
|
||||||
} ${large ? 'h-full' : ''}`}
|
} ${large ? 'h-full w-full' : 'h-full aspect-video'}`}
|
||||||
style={large ? undefined : { aspectRatio: '16/9', minHeight: '140px' }}
|
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
{/* Audio element for remote participants */}
|
|
||||||
{!isLocal && <audio ref={audioRef} autoPlay />}
|
{!isLocal && <audio ref={audioRef} autoPlay />}
|
||||||
|
|
||||||
{hasVideo ? (
|
{hasVideo ? (
|
||||||
@@ -125,14 +201,12 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* LIVE badge for screen shares */}
|
|
||||||
{isScreenShare && hasVideo && (
|
{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">
|
<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
|
LIVE
|
||||||
</div>
|
</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="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 justify-between">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
@@ -173,7 +247,6 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Per-participant volume menu (right-click) */}
|
|
||||||
{volumeMenu && !isLocal && (
|
{volumeMenu && !isLocal && (
|
||||||
<div
|
<div
|
||||||
className="fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px] border border-white/[0.06]"
|
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,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
Track,
|
Track,
|
||||||
LocalTrackPublication,
|
|
||||||
RemoteTrackPublication,
|
|
||||||
Participant,
|
Participant,
|
||||||
RemoteParticipant,
|
RemoteParticipant,
|
||||||
LocalParticipant,
|
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
VideoPresets,
|
VideoPresets,
|
||||||
VideoEncoding,
|
|
||||||
VideoPreset,
|
VideoPreset,
|
||||||
|
LocalAudioTrack,
|
||||||
} from 'livekit-client';
|
} from 'livekit-client';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OPENCORD NATIVE OVERDRIVE PIPELINE v22
|
* OPENCORD NATIVE OVERDRIVE PIPELINE v30
|
||||||
* "Soft-Launch Protocol": Always starts low to clear handshake, then upgrades to target.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const QUALITY_MAP: Record<string, VideoPreset> = {
|
const QUALITY_MAP: Record<string, VideoPreset> = {
|
||||||
@@ -33,6 +29,31 @@ const QUALITY_MAP: Record<string, VideoPreset> = {
|
|||||||
const AUTO_PRESET = QUALITY_MAP['720p60']!;
|
const AUTO_PRESET = QUALITY_MAP['720p60']!;
|
||||||
|
|
||||||
let _activeRoom: Room | null = null;
|
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 {
|
export function getActiveRoom(): Room | null {
|
||||||
return _activeRoom;
|
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;
|
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc;
|
||||||
if (pc) {
|
if (pc) {
|
||||||
const senders = (pc as RTCPeerConnection).getSenders();
|
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) {
|
if (sender) {
|
||||||
const params = sender.getParameters();
|
const params = sender.getParameters();
|
||||||
if (params.encodings && params.encodings[0]) {
|
if (params.encodings && params.encodings[0]) {
|
||||||
console.log(`[Overdrive] Upgrading ${source} to ${preset.encoding.maxBitrate}bps`);
|
|
||||||
params.encodings[0].maxBitrate = preset.encoding.maxBitrate;
|
params.encodings[0].maxBitrate = preset.encoding.maxBitrate;
|
||||||
// Gentle floor to keep stable
|
|
||||||
(params.encodings[0] as any).minBitrate = 2_000_000;
|
(params.encodings[0] as any).minBitrate = 2_000_000;
|
||||||
params.encodings[0].maxFramerate = preset.encoding.maxFramerate;
|
params.encodings[0].maxFramerate = preset.encoding.maxFramerate;
|
||||||
params.encodings[0].networkPriority = 'high';
|
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) {}
|
} catch (err) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,18 +115,26 @@ export function useLiveKit() {
|
|||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
const roomRef = useRef<Room | null>(null);
|
const roomRef = useRef<Room | null>(null);
|
||||||
const connectedChannelRef = useRef<string | null>(null);
|
const connectedChannelRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const isMuted = useVoiceStore((s) => s.isMuted);
|
const isMuted = useVoiceStore((s) => s.isMuted);
|
||||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||||
const videoQuality = useVoiceStore((s) => s.videoQuality);
|
const videoQuality = useVoiceStore((s) => s.videoQuality);
|
||||||
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
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 updateParticipants = useCallback(() => {
|
||||||
const r = roomRef.current;
|
const r = roomRef.current;
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
const allParticipants: ParticipantInfo[] = [];
|
const allParticipants: ParticipantInfo[] = [];
|
||||||
const processParticipant = (p: Participant, isLocal: boolean) => {
|
const processParticipant = (p: Participant, isLocal: boolean) => {
|
||||||
|
if (!p.identity) return;
|
||||||
const { userId, username } = parseIdentity(p.identity);
|
const { userId, username } = parseIdentity(p.identity);
|
||||||
let audioTrack: MediaStreamTrack | null = null;
|
let audioTrack: MediaStreamTrack | null = null;
|
||||||
let videoTrack: 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.Camera && p.isCameraEnabled) videoTrack = mt;
|
||||||
else if (pub.source === Track.Source.ScreenShare && p.isScreenShareEnabled) screenTrack = mt;
|
else if (pub.source === Track.Source.ScreenShare && p.isScreenShareEnabled) screenTrack = mt;
|
||||||
});
|
});
|
||||||
|
|
||||||
const userState = useVoiceStore.getState().voiceUserStates.get(userId);
|
const userState = useVoiceStore.getState().voiceUserStates.get(userId);
|
||||||
let isDeafened = false;
|
let isPartDeafened = false;
|
||||||
let isMuted = !p.isMicrophoneEnabled;
|
let isPartMuted = !p.isMicrophoneEnabled;
|
||||||
|
|
||||||
if (isLocal) {
|
if (isLocal) {
|
||||||
isDeafened = useVoiceStore.getState().isDeafened;
|
isPartDeafened = useVoiceStore.getState().isDeafened;
|
||||||
isMuted = useVoiceStore.getState().isMuted;
|
isPartMuted = useVoiceStore.getState().isMuted;
|
||||||
} else {
|
} else {
|
||||||
isDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
|
isPartDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
|
||||||
if (userState) {
|
if (userState) isPartMuted = userState.isMuted;
|
||||||
isMuted = 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);
|
processParticipant(r.localParticipant, true);
|
||||||
r.remoteParticipants.forEach((p) => processParticipant(p, false));
|
r.remoteParticipants.forEach((p) => processParticipant(p, false));
|
||||||
setParticipants(allParticipants);
|
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) => {
|
const handleDataReceived = useCallback((payload: Uint8Array, participant?: RemoteParticipant) => {
|
||||||
try {
|
try {
|
||||||
const text = new TextDecoder().decode(payload);
|
const text = new TextDecoder().decode(payload);
|
||||||
@@ -154,37 +185,75 @@ export function useLiveKit() {
|
|||||||
useVoiceStore.getState().setUserDeafened(userId, msg.deafened === true);
|
useVoiceStore.getState().setUserDeafened(userId, msg.deafened === true);
|
||||||
updateParticipants();
|
updateParticipants();
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
}, [updateParticipants]);
|
}, [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) => {
|
const connect = useCallback(async (channelId: string) => {
|
||||||
if (connectedChannelRef.current === channelId && roomRef.current) return;
|
if (connectedChannelRef.current === channelId && roomRef.current) return;
|
||||||
const gen = ++_connectGeneration;
|
const gen = ++_connectGeneration;
|
||||||
if (roomRef.current) { try { roomRef.current.disconnect(); } catch {} roomRef.current = null; }
|
if (roomRef.current) { try { roomRef.current.disconnect(); } catch { } roomRef.current = null; }
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
useVoiceStore.getState().setIsLiveKitConnected(false);
|
useVoiceStore.getState().setIsLiveKitConnected(false);
|
||||||
try {
|
try {
|
||||||
const { token, url } = await api.livekit.token(channelId);
|
const { token, url } = await api.livekit.token(channelId);
|
||||||
if (gen !== _connectGeneration) return;
|
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 } });
|
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
|
||||||
roomRef.current = newRoom;
|
roomRef.current = newRoom;
|
||||||
|
|
||||||
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
|
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
|
||||||
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
|
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||||
guardedUpdate();
|
guardedUpdate();
|
||||||
// Re-broadcast local deafen state to newly connected participant
|
|
||||||
if (useVoiceStore.getState().isDeafened) {
|
if (useVoiceStore.getState().isDeafened) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
newRoom.localParticipant.publishData(
|
newRoom.localParticipant.publishData(
|
||||||
encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })),
|
encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })),
|
||||||
{ reliable: true }
|
{ reliable: true }
|
||||||
).catch(() => {});
|
).catch(() => { });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
|
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
|
||||||
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
|
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
|
||||||
newRoom.on(RoomEvent.TrackUnsubscribed, 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.LocalTrackUnpublished, guardedUpdate);
|
||||||
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
|
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
|
||||||
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
|
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
|
||||||
@@ -203,30 +272,36 @@ export function useLiveKit() {
|
|||||||
roomRef.current = null; _activeRoom = null; setIsConnected(false); setRoom(null); setParticipants([]);
|
roomRef.current = null; _activeRoom = null; setIsConnected(false); setRoom(null); setParticipants([]);
|
||||||
useVoiceStore.getState().setIsLiveKitConnected(false);
|
useVoiceStore.getState().setIsLiveKitConnected(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
await newRoom.connect(url, token);
|
await newRoom.connect(url, token);
|
||||||
if (gen !== _connectGeneration) { newRoom.disconnect(); return; }
|
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);
|
useVoiceStore.getState().setIsLiveKitConnected(true);
|
||||||
|
|
||||||
updateParticipants();
|
updateParticipants();
|
||||||
// Preserve mute/deafen state across channel switches, only reset camera/screen
|
|
||||||
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
|
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
|
||||||
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
|
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
|
||||||
|
|
||||||
if (!wasMuted && !wasDeafened) {
|
if (!wasMuted && !wasDeafened) {
|
||||||
try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); }
|
await newRoom.localParticipant.setMicrophoneEnabled(true);
|
||||||
} else {
|
} else {
|
||||||
// Keep mic disabled — user was muted or deafened
|
await newRoom.localParticipant.setMicrophoneEnabled(false);
|
||||||
if (wasDeafened) {
|
if (wasDeafened) {
|
||||||
newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
|
newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
|
||||||
}
|
}
|
||||||
updateParticipants();
|
|
||||||
}
|
}
|
||||||
|
updateParticipants();
|
||||||
} catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); }
|
} catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); }
|
||||||
finally { if (gen === _connectGeneration) setIsConnecting(false); }
|
finally { if (gen === _connectGeneration) setIsConnecting(false); }
|
||||||
}, [updateParticipants, handleDataReceived, isMuted, isDeafened]);
|
}, [updateParticipants, handleDataReceived, setupLocalGainPipeline]);
|
||||||
|
|
||||||
const connectDm = useCallback(async (dmChannelId: string) => {
|
const connectDm = useCallback(async (dmChannelId: string) => {
|
||||||
const gen = ++_connectGeneration;
|
const gen = ++_connectGeneration;
|
||||||
if (roomRef.current) { try { roomRef.current.disconnect(); } catch {} roomRef.current = null; }
|
if (roomRef.current) { try { roomRef.current.disconnect(); } catch { } roomRef.current = null; }
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
try {
|
try {
|
||||||
const { token, url } = await api.livekit.dmToken(dmChannelId);
|
const { token, url } = await api.livekit.dmToken(dmChannelId);
|
||||||
@@ -238,7 +313,12 @@ export function useLiveKit() {
|
|||||||
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
|
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
|
||||||
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
|
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
|
||||||
newRoom.on(RoomEvent.TrackUnsubscribed, 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.LocalTrackUnpublished, guardedUpdate);
|
||||||
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
|
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
|
||||||
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
|
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
|
||||||
@@ -258,30 +338,35 @@ export function useLiveKit() {
|
|||||||
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
|
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
|
||||||
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
|
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
|
||||||
if (!wasMuted && !wasDeafened) {
|
if (!wasMuted && !wasDeafened) {
|
||||||
try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); }
|
await newRoom.localParticipant.setMicrophoneEnabled(true);
|
||||||
} else {
|
} else {
|
||||||
|
await newRoom.localParticipant.setMicrophoneEnabled(false);
|
||||||
if (wasDeafened) {
|
if (wasDeafened) {
|
||||||
newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
|
newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
|
||||||
}
|
}
|
||||||
updateParticipants();
|
|
||||||
}
|
}
|
||||||
|
updateParticipants();
|
||||||
} catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); }
|
} catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); }
|
||||||
finally { if (gen === _connectGeneration) setIsConnecting(false); }
|
finally { if (gen === _connectGeneration) setIsConnecting(false); }
|
||||||
}, [updateParticipants, handleDataReceived, isMuted, isDeafened]);
|
}, [updateParticipants, handleDataReceived, setupLocalGainPipeline]);
|
||||||
|
|
||||||
const disconnect = useCallback(async () => {
|
const disconnect = useCallback(async () => {
|
||||||
_connectGeneration++;
|
_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); }
|
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 () => {
|
const toggleCamera = useCallback(async () => {
|
||||||
if (roomRef.current) {
|
if (roomRef.current) {
|
||||||
if (!isCameraOn) {
|
if (!isCameraOn) {
|
||||||
const preset = QUALITY_MAP[videoQuality] || VideoPresets.h720;
|
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 });
|
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);
|
setTimeout(() => { if (roomRef.current) applyOverdriveHammer(roomRef.current, Track.Source.Camera, preset); }, 2000);
|
||||||
} else { await roomRef.current.localParticipant.setCameraEnabled(false); }
|
} else { await roomRef.current.localParticipant.setCameraEnabled(false); }
|
||||||
updateParticipants();
|
updateParticipants();
|
||||||
@@ -292,9 +377,6 @@ export function useLiveKit() {
|
|||||||
if (roomRef.current) {
|
if (roomRef.current) {
|
||||||
if (!isScreenSharing) {
|
if (!isScreenSharing) {
|
||||||
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
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, {
|
const track = await roomRef.current.localParticipant.setScreenShareEnabled(true, {
|
||||||
resolution: VideoPresets.h360.resolution,
|
resolution: VideoPresets.h360.resolution,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -304,10 +386,8 @@ export function useLiveKit() {
|
|||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
if (track) {
|
if (track) {
|
||||||
// UPGRADE: After 2 seconds, switch to full 60fps quality
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
if (roomRef.current && isScreenSharing) {
|
if (roomRef.current && isScreenSharing) {
|
||||||
console.log('[LiveKit] Upgrading to Target Quality...');
|
|
||||||
const screenPub = roomRef.current.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
const screenPub = roomRef.current.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
||||||
if (screenPub?.track?.mediaStreamTrack) {
|
if (screenPub?.track?.mediaStreamTrack) {
|
||||||
await screenPub.track.mediaStreamTrack.applyConstraints({
|
await screenPub.track.mediaStreamTrack.applyConstraints({
|
||||||
@@ -319,8 +399,6 @@ export function useLiveKit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
// Re-apply hammer
|
|
||||||
setTimeout(() => applyOverdriveHammer(roomRef.current!, Track.Source.ScreenShare, preset), 5000);
|
setTimeout(() => applyOverdriveHammer(roomRef.current!, Track.Source.ScreenShare, preset), 5000);
|
||||||
}
|
}
|
||||||
} else { await roomRef.current.localParticipant.setScreenShareEnabled(false); }
|
} else { await roomRef.current.localParticipant.setScreenShareEnabled(false); }
|
||||||
@@ -332,7 +410,6 @@ export function useLiveKit() {
|
|||||||
updateParticipants();
|
updateParticipants();
|
||||||
}, [voiceUserStates, isMuted, isDeafened, updateParticipants]);
|
}, [voiceUserStates, isMuted, isDeafened, updateParticipants]);
|
||||||
|
|
||||||
// Sync quality changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
||||||
@@ -370,7 +447,7 @@ export function useLiveKit() {
|
|||||||
console.log(`[Soft-Launch Diagnostic] ${report.frameWidth}x${report.frameHeight} @ ${fps} FPS (~${bitrate} Mbps) | ${report.qualityLimitationReason}`);
|
console.log(`[Soft-Launch Diagnostic] ${report.frameWidth}x${report.frameHeight} @ ${fps} FPS (~${bitrate} Mbps) | ${report.qualityLimitationReason}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {}
|
} catch (err) { }
|
||||||
}, 5000);
|
}, 5000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [room]);
|
}, [room]);
|
||||||
@@ -379,5 +456,5 @@ export function useLiveKit() {
|
|||||||
return () => { _connectGeneration++; if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; _activeRoom = null; } };
|
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 { useAuthStore } from '../stores/authStore';
|
||||||
import { useServerStore } from '../stores/serverStore';
|
import { useServerStore } from '../stores/serverStore';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../stores/chatStore';
|
||||||
@@ -280,6 +280,7 @@ export function wsSend(event: ClientEvent): void {
|
|||||||
export function useWebSocket() {
|
export function useWebSocket() {
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
const prevToken = useRef(token);
|
const prevToken = useRef(token);
|
||||||
|
const [isConnected, setIsConnected] = React.useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token && (!isInitialized || token !== prevToken.current)) {
|
if (token && (!isInitialized || token !== prevToken.current)) {
|
||||||
@@ -293,10 +294,14 @@ export function useWebSocket() {
|
|||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const checkStatus = setInterval(() => {
|
||||||
|
setIsConnected(!!globalWs && globalWs.readyState === WebSocket.OPEN);
|
||||||
|
}, 500);
|
||||||
return () => {
|
return () => {
|
||||||
|
clearInterval(checkStatus);
|
||||||
disconnect();
|
disconnect();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { send: wsSend };
|
return { send: wsSend, isConnected };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user