From 11081dbf5a6a27d534498ffe9bd4cdc4729bb6ba Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:27:10 +0100 Subject: [PATCH] refactor(voice): extract action handlers into voiceActions.ts, remove hard-coded M/D shortcuts Move mute/deafen/camera/screen-share/disconnect logic out of VoiceControlBar into a shared voiceActions.ts utility so the same handlers can be called from both UI buttons and the upcoming keybind dispatcher. --- .../src/components/voice/VoiceControlBar.tsx | 100 ++-------------- packages/web/src/utils/voiceActions.ts | 110 ++++++++++++++++++ 2 files changed, 122 insertions(+), 88 deletions(-) create mode 100644 packages/web/src/utils/voiceActions.ts diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index 85940d87..8c342081 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -2,13 +2,10 @@ 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'; +import { handleMuteAction, handleDeafenAction, handleCameraAction, handleScreenShareAction, handleDisconnectAction } from '../../utils/voiceActions'; 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`; @@ -20,9 +17,6 @@ export function VoiceControlBar() { 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); @@ -30,7 +24,6 @@ export function VoiceControlBar() { 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 spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds); @@ -44,102 +37,33 @@ export function VoiceControlBar() { const [qualityOpen, setQualityOpen] = useState(false); const qualityBtnRef = useRef(null); - const handleMute = React.useCallback(async () => { - if (isSpaceMuted || isSpaceDeafened) 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(); - } - }, [isSpaceMuted, isSpaceDeafened, toggleMic]); + const handleMute = React.useCallback(() => { + handleMuteAction(isSpaceMuted, isSpaceDeafened); + }, [isSpaceMuted, isSpaceDeafened]); - const handleDeafen = React.useCallback(async () => { - if (isSpaceDeafened) return; - toggleDeafen(); - broadcastVoiceStatus(); - broadcastDeafenViaLiveKit(); - }, [isSpaceDeafened, toggleDeafen]); + const handleDeafen = React.useCallback(() => { + handleDeafenAction(isSpaceDeafened); + }, [isSpaceDeafened]); - 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 handleCamera = () => handleCameraAction(); - 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 handleScreenShare = () => handleScreenShareAction(); - const handleDisconnect = () => { - const { activeDmCall } = useVoiceStore.getState(); - if (activeDmCall) { - wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId)); - 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 handleDisconnect = () => handleDisconnectAction(); 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) { + if (e.key === 'Escape' && voiceFullscreen) { useUIStore.getState().setVoiceFullscreen(false); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleMute, handleDeafen, voiceFullscreen]); + }, [voiceFullscreen]); return (
diff --git a/packages/web/src/utils/voiceActions.ts b/packages/web/src/utils/voiceActions.ts new file mode 100644 index 00000000..83c548f5 --- /dev/null +++ b/packages/web/src/utils/voiceActions.ts @@ -0,0 +1,110 @@ +import { useVoiceStore } from '../stores/voiceStore'; +import { useUIStore } from '../stores/uiStore'; +import { getActiveRoom } from '../hooks/useLiveKit'; +import { wsSend } from '../hooks/useWebSocket'; +import { getChannelOrigin } from '../stores/spaceStore'; +import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from './voice'; +import { CAMERA_PRESET, startScreenShare, stopScreenShare } from './screenShare'; + +/** + * Toggle mute. Respects space-mute/deafen guards. + * Extracted from VoiceControlBar so keybinds and buttons share the same logic. + */ +export function handleMuteAction(isSpaceMuted: boolean, isSpaceDeafened: boolean): void { + if (isSpaceMuted || isSpaceDeafened) return; + const wasDeafened = useVoiceStore.getState().isDeafened; + useVoiceStore.getState().toggleMic(); + broadcastVoiceStatus(); + if (wasDeafened && !useVoiceStore.getState().isDeafened) { + broadcastDeafenViaLiveKit(); + } +} + +/** + * Toggle deafen. Respects space-deafen guard. + */ +export function handleDeafenAction(isSpaceDeafened: boolean): void { + if (isSpaceDeafened) return; + useVoiceStore.getState().toggleDeafen(); + broadcastVoiceStatus(); + broadcastDeafenViaLiveKit(); +} + +/** + * Toggle camera. Requires LiveKit room. + */ +export async function handleCameraAction(): Promise { + const room = getActiveRoom(); + if (!room) return; + const isCameraOn = useVoiceStore.getState().isCameraOn; + 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); + } + useVoiceStore.getState().toggleCamera(); + broadcastVoiceStatus(); + } catch (err) { + console.error('[voiceActions] Failed to toggle camera:', err); + } +} + +/** + * Toggle screen share. Requires LiveKit room. + * Note: startScreenShare/stopScreenShare manage voiceStore.isScreenSharing internally. + * Do NOT call toggleScreenShare() here — it would double-flip the state. + */ +export async function handleScreenShareAction(): Promise { + const room = getActiveRoom(); + if (!room) return; + const isScreenSharing = useVoiceStore.getState().isScreenSharing; + try { + if (!isScreenSharing) { + const started = await startScreenShare(room); + if (started) broadcastVoiceStatus(); + } else { + await stopScreenShare(room); + broadcastVoiceStatus(); + } + } catch (err) { + console.error('[voiceActions] Failed to toggle screen share:', err); + } +} + +/** + * Disconnect from voice. Handles DM call teardown and fullscreen exit. + */ +export function handleDisconnectAction(): void { + const voice = useVoiceStore.getState(); + const { activeDmCall, currentVoiceChannelId } = voice; + + if (activeDmCall) { + wsSend( + { type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, + getChannelOrigin(activeDmCall.dmChannelId) + ); + voice.setActiveDmCall(null); + } else if (currentVoiceChannelId) { + const origin = getChannelOrigin(currentVoiceChannelId); + wsSend({ type: 'voice_leave' }, origin); + voice.leaveVoice(); + } + + // Exit fullscreen if active + const voiceFullscreen = useUIStore.getState().voiceFullscreen; + if (voiceFullscreen) { + useUIStore.getState().setVoiceFullscreen(false); + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + } + } +}