feat: Unified floating bottom panel — voice controls + user area in one card
Restructure VoiceControls and UserAreaPanel into a single fixed-positioned floating card that spans both server and channel sidebars. Panel expands when voice is connected and contracts to just user area when not.
This commit is contained in:
@@ -74,15 +74,37 @@ export function ChannelSidebar() {
|
|||||||
navigate(`/channels/${currentServerId}/${channelId}`);
|
navigate(`/channels/${currentServerId}/${channelId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Floating bottom panel — shared between DM view and server view
|
||||||
|
const floatingPanel = user ? (
|
||||||
|
<div className="fixed bottom-2 left-2 z-30 w-[296px]">
|
||||||
|
<div className="bg-[#1a1b1e] rounded-lg shadow-[0_4px_20px_rgba(0,0,0,0.5)] ring-1 ring-white/[0.06]">
|
||||||
|
{/* Voice controls (expands when connected) */}
|
||||||
|
{currentVoiceChannelId && <VoiceControls />}
|
||||||
|
{/* Separator between voice and user area */}
|
||||||
|
{currentVoiceChannelId && <div className="mx-3 border-t border-white/[0.06]" />}
|
||||||
|
{/* User area (always visible) */}
|
||||||
|
<UserAreaPanel
|
||||||
|
user={user}
|
||||||
|
isMuted={isMuted}
|
||||||
|
isDeafened={isDeafened}
|
||||||
|
onMicToggle={handleMicToggle}
|
||||||
|
onDeafenToggle={handleDeafenToggle}
|
||||||
|
onSettingsClick={() => openModal('userSettings')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none">
|
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none">
|
||||||
<div className="h-12 px-[10px] flex items-center shadow-header z-10">
|
<div className="h-12 px-[10px] flex items-center shadow-header z-10">
|
||||||
<button className="flex-1 bg-discord-bg-tertiary text-discord-text-muted text-[13px] font-medium py-[5px] px-2 rounded-[4px] text-left hover:bg-discord-bg-tertiary/80 transition-colors">
|
<button className="flex-1 bg-discord-bg-tertiary text-discord-text-muted text-[13px] font-medium py-[5px] px-2 rounded-[4px] text-left hover:bg-discord-bg-tertiary/80 transition-colors">
|
||||||
Find or start a conversation
|
Find or start a conversation
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto pt-4 px-2 no-scrollbar">
|
<div className="flex-1 overflow-y-auto pt-4 px-2 pb-[140px] no-scrollbar">
|
||||||
<div
|
<div
|
||||||
onClick={handleHomeClick}
|
onClick={handleHomeClick}
|
||||||
className={`flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer mb-[2px] transition-colors group ${
|
className={`flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer mb-[2px] transition-colors group ${
|
||||||
@@ -187,25 +209,14 @@ export function ChannelSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Voice controls — visible when in a call, even in DM view */}
|
|
||||||
{currentVoiceChannelId && <VoiceControls />}
|
|
||||||
|
|
||||||
{/* User area at bottom */}
|
|
||||||
{user && (
|
|
||||||
<UserAreaPanel
|
|
||||||
user={user}
|
|
||||||
isMuted={isMuted}
|
|
||||||
isDeafened={isDeafened}
|
|
||||||
onMicToggle={handleMicToggle}
|
|
||||||
onDeafenToggle={handleDeafenToggle}
|
|
||||||
onSettingsClick={() => openModal('userSettings')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{floatingPanel}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none">
|
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none">
|
||||||
{/* Server header */}
|
{/* Server header */}
|
||||||
<div className="h-12 flex items-center shadow-header z-10 group/header">
|
<div className="h-12 flex items-center shadow-header z-10 group/header">
|
||||||
@@ -230,7 +241,7 @@ export function ChannelSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channels */}
|
{/* Channels */}
|
||||||
<div className="flex-1 overflow-y-auto pt-3 px-2 space-y-[21px] no-scrollbar">
|
<div className="flex-1 overflow-y-auto pt-3 px-2 pb-[140px] space-y-[21px] no-scrollbar">
|
||||||
{/* Text Channels */}
|
{/* Text Channels */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between px-1 mb-1 group cursor-pointer">
|
<div className="flex items-center justify-between px-1 mb-1 group cursor-pointer">
|
||||||
@@ -321,21 +332,9 @@ export function ChannelSidebar() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Voice controls — VoiceControls reads state and calls LiveKit SDK directly */}
|
|
||||||
{currentVoiceChannelId && <VoiceControls />}
|
|
||||||
|
|
||||||
{/* User area */}
|
|
||||||
{user && (
|
|
||||||
<UserAreaPanel
|
|
||||||
user={user}
|
|
||||||
isMuted={isMuted}
|
|
||||||
isDeafened={isDeafened}
|
|
||||||
onMicToggle={handleMicToggle}
|
|
||||||
onDeafenToggle={handleDeafenToggle}
|
|
||||||
onSettingsClick={() => openModal('userSettings')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{floatingPanel}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +649,7 @@ function UserAreaPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User area bar */}
|
{/* User area bar */}
|
||||||
<div className="h-[52px] px-2 bg-discord-bg-user-area flex items-center select-none">
|
<div className="h-[52px] px-2 flex items-center select-none">
|
||||||
{/* Avatar + name */}
|
{/* Avatar + name */}
|
||||||
<div className="p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group">
|
<div className="p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group">
|
||||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} user={user} />
|
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} user={user} />
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { useServerStore } from '../../stores/serverStore';
|
|||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VoiceControls renders the voice status + button rows.
|
||||||
|
* It has NO wrapper/card styling — the parent provides the container.
|
||||||
|
*/
|
||||||
export function VoiceControls() {
|
export function VoiceControls() {
|
||||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
const isMuted = useVoiceStore((s) => s.isMuted);
|
const isMuted = useVoiceStore((s) => s.isMuted);
|
||||||
@@ -41,21 +45,19 @@ export function VoiceControls() {
|
|||||||
try {
|
try {
|
||||||
const willDeafen = !isDeafened;
|
const willDeafen = !isDeafened;
|
||||||
if (willDeafen) {
|
if (willDeafen) {
|
||||||
// Deafen: mute mic + mute all remote audio
|
|
||||||
await room.localParticipant.setMicrophoneEnabled(false);
|
await room.localParticipant.setMicrophoneEnabled(false);
|
||||||
room.remoteParticipants.forEach((p) => {
|
room.remoteParticipants.forEach((p) => {
|
||||||
p.setVolume(0);
|
p.setVolume(0);
|
||||||
});
|
});
|
||||||
if (!isMuted) toggleMic(); // Also mute mic when deafening
|
if (!isMuted) toggleMic();
|
||||||
} else {
|
} else {
|
||||||
// Undeafen: restore remote audio, unmute mic
|
|
||||||
const outputVolume = useVoiceStore.getState().outputVolume;
|
const outputVolume = useVoiceStore.getState().outputVolume;
|
||||||
const scaled = outputVolume / 100;
|
const scaled = outputVolume / 100;
|
||||||
room.remoteParticipants.forEach((p) => {
|
room.remoteParticipants.forEach((p) => {
|
||||||
p.setVolume(scaled);
|
p.setVolume(scaled);
|
||||||
});
|
});
|
||||||
await room.localParticipant.setMicrophoneEnabled(true);
|
await room.localParticipant.setMicrophoneEnabled(true);
|
||||||
if (isMuted) toggleMic(); // Unmute mic when undeafening
|
if (isMuted) toggleMic();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[VoiceControls] Failed to toggle deafen:', err);
|
console.error('[VoiceControls] Failed to toggle deafen:', err);
|
||||||
@@ -103,10 +105,13 @@ export function VoiceControls() {
|
|||||||
? 'bg-discord-green/20'
|
? 'bg-discord-green/20'
|
||||||
: 'bg-discord-yellow/20';
|
: 'bg-discord-yellow/20';
|
||||||
|
|
||||||
|
const btnBase = 'flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors';
|
||||||
|
const btnDefaultStyle = 'bg-[#111214] text-discord-text-muted hover:bg-[#1a1b1e] hover:text-discord-text-secondary';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#1a1b1e] border-t border-discord-bg-tertiary">
|
<>
|
||||||
{/* Row 1: Signal icon + status text + disconnect */}
|
{/* Row 1: Signal icon + status text + disconnect */}
|
||||||
<div className="flex items-center gap-2 px-2 pt-[10px] pb-1">
|
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
||||||
<div className={`w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0`}>
|
<div className={`w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0`}>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className={statusColor}>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className={statusColor}>
|
||||||
<path d="M1.5 21.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM3.14 15.75a.75.75 0 01-.09-1.06A8.46 8.46 0 0112 11a8.46 8.46 0 018.95 3.69.75.75 0 01-1.15.97A6.96 6.96 0 0012 12.5a6.96 6.96 0 00-7.8 3.16.75.75 0 01-1.06.09zM6.37 18.3a.75.75 0 01-.08-1.06A5.46 5.46 0 0112 15a5.46 5.46 0 015.71 2.24.75.75 0 01-1.14.97A3.96 3.96 0 0012 16.5a3.96 3.96 0 00-4.57 1.71.75.75 0 01-1.06.09z" />
|
<path d="M1.5 21.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM3.14 15.75a.75.75 0 01-.09-1.06A8.46 8.46 0 0112 11a8.46 8.46 0 018.95 3.69.75.75 0 01-1.15.97A6.96 6.96 0 0012 12.5a6.96 6.96 0 00-7.8 3.16.75.75 0 01-1.06.09zM6.37 18.3a.75.75 0 01-.08-1.06A5.46 5.46 0 0112 15a5.46 5.46 0 015.71 2.24.75.75 0 01-1.14.97A3.96 3.96 0 0012 16.5a3.96 3.96 0 00-4.57 1.71.75.75 0 01-1.06.09z" />
|
||||||
@@ -141,14 +146,13 @@ export function VoiceControls() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Mute, Deafen, Camera, Screen Share */}
|
{/* Row 2: Mute, Deafen, Camera, Screen Share */}
|
||||||
<div className="flex items-center gap-1 px-2 pb-[10px] pt-1">
|
<div className="flex items-center gap-1 px-3 pb-2 pt-1">
|
||||||
{/* Mute */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleMute}
|
onClick={handleMute}
|
||||||
className={`flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${
|
className={`${btnBase} ${
|
||||||
isMuted || isDeafened
|
isMuted || isDeafened
|
||||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||||
: 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'
|
: btnDefaultStyle
|
||||||
}`}
|
}`}
|
||||||
title={isMuted ? 'Unmute' : 'Mute'}
|
title={isMuted ? 'Unmute' : 'Mute'}
|
||||||
>
|
>
|
||||||
@@ -159,13 +163,12 @@ export function VoiceControls() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Deafen */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleDeafen}
|
onClick={handleDeafen}
|
||||||
className={`flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${
|
className={`${btnBase} ${
|
||||||
isDeafened
|
isDeafened
|
||||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||||
: 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'
|
: btnDefaultStyle
|
||||||
}`}
|
}`}
|
||||||
title={isDeafened ? 'Undeafen' : 'Deafen'}
|
title={isDeafened ? 'Undeafen' : 'Deafen'}
|
||||||
>
|
>
|
||||||
@@ -175,13 +178,12 @@ export function VoiceControls() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Camera */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleCamera}
|
onClick={handleCamera}
|
||||||
className={`flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${
|
className={`${btnBase} ${
|
||||||
isCameraOn
|
isCameraOn
|
||||||
? 'bg-discord-bg-tertiary text-discord-green hover:bg-discord-bg-tertiary/80'
|
? 'bg-[#111214] text-discord-green hover:bg-[#1a1b1e]'
|
||||||
: 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'
|
: btnDefaultStyle
|
||||||
}`}
|
}`}
|
||||||
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
|
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
|
||||||
>
|
>
|
||||||
@@ -197,13 +199,12 @@ export function VoiceControls() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Screen Share */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleScreenShare}
|
onClick={handleScreenShare}
|
||||||
className={`flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${
|
className={`${btnBase} ${
|
||||||
isScreenSharing
|
isScreenSharing
|
||||||
? 'bg-discord-bg-tertiary text-discord-green hover:bg-discord-bg-tertiary/80'
|
? 'bg-[#111214] text-discord-green hover:bg-[#1a1b1e]'
|
||||||
: 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'
|
: btnDefaultStyle
|
||||||
}`}
|
}`}
|
||||||
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
|
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
|
||||||
>
|
>
|
||||||
@@ -213,6 +214,6 @@ export function VoiceControls() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user