import React, { useEffect, useState, useRef } from 'react'; import { useVoiceStore } from '../../stores/voiceStore'; import { useUIStore } from '../../stores/uiStore'; import { useAuthStore } from '../../stores/authStore'; import { getActiveRoom } from '../../hooks/useLiveKit'; import { wsSend } from '../../hooks/useWebSocket'; import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore'; import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare'; import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; const btnBase = 'w-10 h-10 flex items-center justify-center rounded-full transition-colors'; const btnDefault = `${btnBase} bg-surface-channel text-txt-secondary hover:bg-surface-elevated hover:text-txt-primary`; const btnActive = (color: string) => `${btnBase} bg-${color}/20 text-${color} hover:bg-${color}/30`; const btnGreen = `${btnBase} bg-surface-channel text-status-online hover:bg-surface-elevated`; export function VoiceControlBar() { 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 toggleMic = useVoiceStore((s) => s.toggleMic); const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const toggleCamera = useVoiceStore((s) => s.toggleCamera); const voiceChatOpen = useUIStore((s) => s.voiceChatOpen); const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat); const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const myUser = useAuthStore((s) => s.user); const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null); const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : ''; const myOriginId = useSpaceStore((s) => currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : s.members.find(m => m.userId === myUser?.id)?.userId ?? myUser?.id); const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); const isServerMuted = !!(myOriginId && spaceId && serverMutedUserIds.has(`${spaceId}:${myOriginId}`)); const isServerDeafened = !!(myOriginId && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myOriginId}`)); const activeDmCall = useVoiceStore((s) => s.activeDmCall); const channelPerms = useSpaceStore((s) => currentVoiceChannelId ? s.channelPermissions.get(currentVoiceChannelId) : undefined); const isDmCall = !!activeDmCall; const canSpeak = isDmCall || hasPermissionBit(channelPerms, PermissionBits.SPEAK); const canStream = isDmCall || hasPermissionBit(channelPerms, PermissionBits.STREAM); const [qualityOpen, setQualityOpen] = useState(false); const qualityBtnRef = useRef(null); const handleMute = React.useCallback(async () => { if (isServerMuted || isServerDeafened) return; const wasDeafened = useVoiceStore.getState().isDeafened; toggleMic(); broadcastVoiceStatus(); // If unmuting while deafened cleared deafen, broadcast via LiveKit data channel if (wasDeafened && !useVoiceStore.getState().isDeafened) { broadcastDeafenViaLiveKit(); } }, [isServerMuted, isServerDeafened, toggleMic]); const handleDeafen = React.useCallback(async () => { if (isServerDeafened) return; toggleDeafen(); broadcastVoiceStatus(); broadcastDeafenViaLiveKit(); }, [isServerDeafened, toggleDeafen]); const handleCamera = async () => { const room = getActiveRoom(); if (!room) return; try { const willEnable = !isCameraOn; if (willEnable) { await room.localParticipant.setCameraEnabled(true, { resolution: CAMERA_PRESET.resolution }, { videoCodec: CAMERA_PRESET.codec, videoEncoding: CAMERA_PRESET.encoding, simulcast: true, } ); } else { await room.localParticipant.setCameraEnabled(false); } toggleCamera(); broadcastVoiceStatus(); } catch (err) { console.error('[VoiceControlBar] Failed to toggle camera:', err); } }; const handleScreenShare = async () => { const room = getActiveRoom(); if (!room) return; try { if (!isScreenSharing) { const started = await startScreenShare(room); if (started) broadcastVoiceStatus(); } else { await stopScreenShare(room); broadcastVoiceStatus(); } } catch (err) { console.error('[VoiceControlBar] Failed to toggle screen share:', err); } }; const handleDisconnect = () => { const { activeDmCall } = useVoiceStore.getState(); if (activeDmCall) { wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); // DM calls are home-only useVoiceStore.getState().setActiveDmCall(null); } else { wsSend({ type: 'voice_leave' }, voiceOrigin); useVoiceStore.getState().leaveVoice(); } if (voiceFullscreen) { useUIStore.getState().setVoiceFullscreen(false); if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}); } } }; const handleFullscreen = () => { toggleVoiceFullscreen(); }; // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.key === 'm' || e.key === 'M') { e.preventDefault(); handleMute(); } else if (e.key === 'd' || e.key === 'D') { e.preventDefault(); handleDeafen(); } else if (e.key === 'Escape' && voiceFullscreen) { useUIStore.getState().setVoiceFullscreen(false); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleMute, handleDeafen, voiceFullscreen]); return (
{/* Mute */} {/* Deafen */} {/* Camera */} {canSpeak && ( )} {/* Screen Share */} {canStream && ( )} {/* Video Quality */} setQualityOpen(false)} anchorRef={qualityBtnRef} /> {/* Separator */}
{/* Chat Toggle */} {/* Fullscreen Toggle */} {/* Separator */}
{/* Disconnect */}
); }