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.
211 lines
10 KiB
TypeScript
211 lines
10 KiB
TypeScript
import React, { useEffect, useState, useRef } from 'react';
|
|
import { useVoiceStore } from '../../stores/voiceStore';
|
|
import { useUIStore } from '../../stores/uiStore';
|
|
import { useAuthStore } from '../../stores/authStore';
|
|
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
|
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
|
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`;
|
|
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 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 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);
|
|
const isSpaceMuted = !!(myOriginId && spaceId && spaceMutedUserIds.has(`${spaceId}:${myOriginId}`));
|
|
const isSpaceDeafened = !!(myOriginId && spaceId && spaceDeafenedUserIds.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<HTMLButtonElement>(null);
|
|
|
|
const handleMute = React.useCallback(() => {
|
|
handleMuteAction(isSpaceMuted, isSpaceDeafened);
|
|
}, [isSpaceMuted, isSpaceDeafened]);
|
|
|
|
const handleDeafen = React.useCallback(() => {
|
|
handleDeafenAction(isSpaceDeafened);
|
|
}, [isSpaceDeafened]);
|
|
|
|
const handleCamera = () => handleCameraAction();
|
|
|
|
const handleScreenShare = () => handleScreenShareAction();
|
|
|
|
const handleDisconnect = () => handleDisconnectAction();
|
|
|
|
const handleFullscreen = () => {
|
|
toggleVoiceFullscreen();
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && voiceFullscreen) {
|
|
useUIStore.getState().setVoiceFullscreen(false);
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [voiceFullscreen]);
|
|
|
|
return (
|
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 opacity-0 translate-y-4 group-hover/voice:opacity-100 group-hover/voice:translate-y-0 transition-all duration-300 ease-out">
|
|
<div className="flex items-center gap-1.5 rounded-full px-3 py-2 glass-bubble">
|
|
{/* Mute */}
|
|
<button
|
|
onClick={handleMute}
|
|
className={(isSpaceMuted || isSpaceDeafened)
|
|
? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed`
|
|
: isMuted || isDeafened
|
|
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
|
|
: btnDefault
|
|
}
|
|
title={(isSpaceMuted || isSpaceDeafened) ? (isMuted ? 'Space Muted (self-muted)' : 'Space Muted') : isMuted ? 'Unmute (M)' : 'Mute (M)'}
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
|
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
|
{(isMuted || isDeafened || isSpaceMuted || isSpaceDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Deafen */}
|
|
<button
|
|
onClick={handleDeafen}
|
|
className={isSpaceDeafened
|
|
? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed`
|
|
: isDeafened
|
|
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
|
|
: btnDefault
|
|
}
|
|
title={isSpaceDeafened ? 'Space Deafened' : isDeafened ? 'Undeafen (D)' : 'Deafen (D)'}
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
|
|
{(isDeafened || isSpaceDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Camera */}
|
|
{canSpeak && (
|
|
<button
|
|
onClick={handleCamera}
|
|
className={isCameraOn ? btnGreen : btnDefault}
|
|
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
|
|
>
|
|
{isCameraOn ? (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
|
|
</svg>
|
|
) : (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
|
|
<line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{/* Screen Share */}
|
|
{canStream && (
|
|
<button
|
|
onClick={handleScreenShare}
|
|
className={isScreenSharing ? btnGreen : btnDefault}
|
|
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" />
|
|
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
{/* Video Quality */}
|
|
<button
|
|
ref={qualityBtnRef}
|
|
onClick={() => setQualityOpen(!qualityOpen)}
|
|
className={qualityOpen
|
|
? `${btnBase} bg-surface-channel text-txt-primary`
|
|
: btnDefault
|
|
}
|
|
title="Video Quality"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M3 5v14h18V5H3zm16 12H5V7h14v10z" />
|
|
<path d="M8 15l2.5-3.21L13 15l2-2.5L18 17H6z" />
|
|
</svg>
|
|
</button>
|
|
<ScreenShareSettingsPopover open={qualityOpen} onClose={() => setQualityOpen(false)} anchorRef={qualityBtnRef} />
|
|
|
|
{/* Separator */}
|
|
<div className="w-[1px] h-6 bg-white/10 mx-0.5" />
|
|
|
|
{/* Chat Toggle */}
|
|
<button
|
|
onClick={toggleVoiceChat}
|
|
className={voiceChatOpen
|
|
? `${btnBase} bg-surface-channel text-txt-primary`
|
|
: btnDefault
|
|
}
|
|
title="Toggle Chat"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-6H6V6h12v2z" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Fullscreen Toggle */}
|
|
<button
|
|
onClick={handleFullscreen}
|
|
className={voiceFullscreen
|
|
? `${btnBase} bg-surface-channel text-txt-primary`
|
|
: btnDefault
|
|
}
|
|
title={voiceFullscreen ? 'Exit Fullscreen (Esc)' : 'Fullscreen'}
|
|
>
|
|
{voiceFullscreen ? (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
|
|
</svg>
|
|
) : (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
|
|
{/* Separator */}
|
|
<div className="w-[1px] h-6 bg-white/10 mx-0.5" />
|
|
|
|
{/* Disconnect */}
|
|
<button
|
|
onClick={handleDisconnect}
|
|
className={`${btnBase} bg-accent-rose hover:bg-accent-rose/80 text-white`}
|
|
title="Disconnect"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|