feat: voice status visibility + sidebar persistence fixes
- Add WebSocket voice_status/voice_status_update events so mute/deafen icons are visible in the sidebar without joining the voice channel - Server tracks voiceUserStates and includes them in the ready payload - Re-register voice channel on WebSocket reconnect to prevent sidebar users from disappearing after idle timeout - Re-broadcast deafen state to late joiners via LiveKit data channel - Fix black grid tile when video stops (enabled-flag guards) - Remove duplicate mute/deafen from VoiceControls (replaced with Video Quality + Noise Suppression) - Fix missing users in sidebar voice list (identity matching + fallback)
This commit is contained in:
@@ -156,6 +156,9 @@ export function handleClientEvent(
|
|||||||
case 'dm_call_end':
|
case 'dm_call_end':
|
||||||
handleDmCallEnd(event, userId);
|
handleDmCallEnd(event, userId);
|
||||||
break;
|
break;
|
||||||
|
case 'voice_status':
|
||||||
|
handleVoiceStatus(event, userId);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
connectionManager.sendToUser(userId, {
|
connectionManager.sendToUser(userId, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@@ -738,6 +741,27 @@ function handleChannelAck(event: Record<string, unknown>, userId: string): void
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleVoiceStatus(event: Record<string, unknown>, userId: string): void {
|
||||||
|
const isMuted = event.isMuted === true;
|
||||||
|
const isDeafened = event.isDeafened === true;
|
||||||
|
|
||||||
|
const channelId = connectionManager.getUserVoiceChannel(userId);
|
||||||
|
if (!channelId) return;
|
||||||
|
|
||||||
|
const serverId = getChannelServerId(channelId);
|
||||||
|
if (!serverId) return;
|
||||||
|
|
||||||
|
connectionManager.setVoiceUserStatus(userId, isMuted, isDeafened);
|
||||||
|
|
||||||
|
connectionManager.sendToServer(serverId, {
|
||||||
|
type: 'voice_status_update',
|
||||||
|
userId,
|
||||||
|
channelId,
|
||||||
|
isMuted,
|
||||||
|
isDeafened,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── DM Call Handlers ──────────────────────────────────────────────────────────
|
// ─── DM Call Handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function handleDmCallStart(event: Record<string, unknown>, userId: string, username: string): void {
|
function handleDmCallStart(event: Record<string, unknown>, userId: string, username: string): void {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ class ConnectionManager {
|
|||||||
private wsToUser: Map<WebSocket, string> = new Map();
|
private wsToUser: Map<WebSocket, string> = new Map();
|
||||||
// dmChannelId → { callerId, startedAt } — active DM calls
|
// dmChannelId → { callerId, startedAt } — active DM calls
|
||||||
private activeCalls: Map<string, { callerId: string; startedAt: number }> = new Map();
|
private activeCalls: Map<string, { callerId: string; startedAt: number }> = new Map();
|
||||||
|
// userId → { isMuted, isDeafened } — voice user status (mute/deafen state)
|
||||||
|
private voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean }> = new Map();
|
||||||
|
|
||||||
addConnection(userId: string, ws: WebSocket): void {
|
addConnection(userId: string, ws: WebSocket): void {
|
||||||
if (!this.connections.has(userId)) {
|
if (!this.connections.has(userId)) {
|
||||||
@@ -110,9 +112,11 @@ class ConnectionManager {
|
|||||||
this.voiceStates.delete(channelId);
|
this.voiceStates.delete(channelId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.voiceUserStates.delete(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
leaveAllVoice(userId: string): string | null {
|
leaveAllVoice(userId: string): string | null {
|
||||||
|
this.voiceUserStates.delete(userId);
|
||||||
for (const [channelId, users] of this.voiceStates) {
|
for (const [channelId, users] of this.voiceStates) {
|
||||||
if (users.has(userId)) {
|
if (users.has(userId)) {
|
||||||
users.delete(userId);
|
users.delete(userId);
|
||||||
@@ -138,6 +142,23 @@ class ConnectionManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Voice user status management
|
||||||
|
setVoiceUserStatus(userId: string, isMuted: boolean, isDeafened: boolean): void {
|
||||||
|
this.voiceUserStates.set(userId, { isMuted, isDeafened });
|
||||||
|
}
|
||||||
|
|
||||||
|
getVoiceUserStatus(userId: string): { isMuted: boolean; isDeafened: boolean } | undefined {
|
||||||
|
return this.voiceUserStates.get(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearVoiceUserStatus(userId: string): void {
|
||||||
|
this.voiceUserStates.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllVoiceUserStates(): Map<string, { isMuted: boolean; isDeafened: boolean }> {
|
||||||
|
return this.voiceUserStates;
|
||||||
|
}
|
||||||
|
|
||||||
// DM call management
|
// DM call management
|
||||||
startCall(dmChannelId: string, callerId: string): boolean {
|
startCall(dmChannelId: string, callerId: string): boolean {
|
||||||
if (this.activeCalls.has(dmChannelId)) return false; // Already in a call
|
if (this.activeCalls.has(dmChannelId)) return false; // Already in a call
|
||||||
@@ -206,6 +227,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
dmChannels: DmChannel[];
|
dmChannels: DmChannel[];
|
||||||
folders: ServerFolder[];
|
folders: ServerFolder[];
|
||||||
voiceStates: Record<string, string[]>;
|
voiceStates: Record<string, string[]>;
|
||||||
|
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean }>;
|
||||||
readStates: ReadState[];
|
readStates: ReadState[];
|
||||||
} {
|
} {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -422,6 +444,20 @@ function buildReadyPayload(userId: string): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build voice user states — tell the client mute/deafen status of voice users
|
||||||
|
const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean }> = {};
|
||||||
|
for (const chId of Object.keys(voiceStates)) {
|
||||||
|
const usersInChannel = voiceStates[chId];
|
||||||
|
if (usersInChannel) {
|
||||||
|
for (const uid of usersInChannel) {
|
||||||
|
const status = connectionManager.getVoiceUserStatus(uid);
|
||||||
|
if (status) {
|
||||||
|
voiceUserStates[uid] = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch read states for unread tracking
|
// Fetch read states for unread tracking
|
||||||
const readStateRows = db.select()
|
const readStateRows = db.select()
|
||||||
.from(schema.readStates)
|
.from(schema.readStates)
|
||||||
@@ -433,7 +469,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
lastReadMessageId: rs.lastReadMessageId,
|
lastReadMessageId: rs.lastReadMessageId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { user, servers, dmChannels, folders, voiceStates, readStates };
|
return { user, servers, dmChannels, folders, voiceStates, voiceUserStates, readStates };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||||
|
|||||||
@@ -185,11 +185,12 @@ export type ClientEvent =
|
|||||||
| { type: 'dm_call_start'; dmChannelId: string }
|
| { type: 'dm_call_start'; dmChannelId: string }
|
||||||
| { type: 'dm_call_accept'; dmChannelId: string }
|
| { type: 'dm_call_accept'; dmChannelId: string }
|
||||||
| { type: 'dm_call_reject'; dmChannelId: string }
|
| { type: 'dm_call_reject'; dmChannelId: string }
|
||||||
| { type: 'dm_call_end'; dmChannelId: string };
|
| { type: 'dm_call_end'; dmChannelId: string }
|
||||||
|
| { type: 'voice_status'; isMuted: boolean; isDeafened: boolean };
|
||||||
|
|
||||||
// Server → Client Events
|
// Server → Client Events
|
||||||
export type ServerEvent =
|
export type ServerEvent =
|
||||||
| { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record<string, string[]>; readStates?: ReadState[] }
|
| { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean }>; readStates?: ReadState[] }
|
||||||
| { type: 'message_created'; message: MessageWithUser }
|
| { type: 'message_created'; message: MessageWithUser }
|
||||||
| { type: 'message_updated'; message: MessageWithUser }
|
| { type: 'message_updated'; message: MessageWithUser }
|
||||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||||
@@ -211,6 +212,7 @@ export type ServerEvent =
|
|||||||
| { type: 'dm_call_accepted'; dmChannelId: string }
|
| { type: 'dm_call_accepted'; dmChannelId: string }
|
||||||
| { type: 'dm_call_rejected'; dmChannelId: string }
|
| { type: 'dm_call_rejected'; dmChannelId: string }
|
||||||
| { type: 'dm_call_ended'; dmChannelId: string }
|
| { type: 'dm_call_ended'; dmChannelId: string }
|
||||||
|
| { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
// ─── API Request/Response Types ─────────────────────────────────────────────
|
// ─── API Request/Response Types ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -40,10 +40,42 @@ export function ChannelSidebar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
toggleMic();
|
toggleMic();
|
||||||
|
// Broadcast mute status via WebSocket so non-joined users can see it
|
||||||
|
const willBeMuted = !isMuted;
|
||||||
|
wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeafenToggle = () => {
|
const handleDeafenToggle = async () => {
|
||||||
|
const room = getActiveRoom();
|
||||||
|
const willDeafen = !isDeafened;
|
||||||
|
// Update store FIRST so updateParticipants reads correct state when LiveKit events fire
|
||||||
toggleDeafen();
|
toggleDeafen();
|
||||||
|
if (willDeafen && !isMuted) toggleMic();
|
||||||
|
if (!willDeafen && isMuted) toggleMic();
|
||||||
|
// Broadcast status via WebSocket so non-joined users can see it
|
||||||
|
const willBeMuted = willDeafen ? true : false;
|
||||||
|
wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened: willDeafen });
|
||||||
|
if (room) {
|
||||||
|
try {
|
||||||
|
if (willDeafen) {
|
||||||
|
await room.localParticipant.setMicrophoneEnabled(false);
|
||||||
|
room.remoteParticipants.forEach((p) => p.setVolume(0));
|
||||||
|
} else {
|
||||||
|
const outputVolume = useVoiceStore.getState().outputVolume;
|
||||||
|
const scaled = outputVolume / 100;
|
||||||
|
room.remoteParticipants.forEach((p) => p.setVolume(scaled));
|
||||||
|
await room.localParticipant.setMicrophoneEnabled(true);
|
||||||
|
}
|
||||||
|
// Broadcast deafen state to other participants via LiveKit data channel
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
room.localParticipant.publishData(
|
||||||
|
encoder.encode(JSON.stringify({ type: 'deafen', deafened: willDeafen })),
|
||||||
|
{ reliable: true }
|
||||||
|
).catch(() => {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ChannelSidebar] Failed to toggle deafen:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = servers.find(s => s.id === currentServerId);
|
const server = servers.find(s => s.id === currentServerId);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
const EMPTY_VOICE_USERS: string[] = [];
|
const EMPTY_VOICE_USERS: string[] = [];
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
@@ -15,6 +16,10 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
|
|||||||
const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
|
const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
|
||||||
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
|
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
const participants = useVoiceStore((s) => s.participants);
|
const participants = useVoiceStore((s) => s.participants);
|
||||||
|
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
|
||||||
|
const localIsMuted = useVoiceStore((s) => s.isMuted);
|
||||||
|
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
||||||
|
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||||
const members = useServerStore((s) => s.members);
|
const members = useServerStore((s) => s.members);
|
||||||
const isActive = currentVoiceChannel === channelId;
|
const isActive = currentVoiceChannel === channelId;
|
||||||
|
|
||||||
@@ -39,22 +44,29 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
|
|||||||
<div className="ml-6 mt-0.5 space-y-0.5">
|
<div className="ml-6 mt-0.5 space-y-0.5">
|
||||||
{voiceUsers.map((userId) => {
|
{voiceUsers.map((userId) => {
|
||||||
const member = members.find(m => m.userId === userId);
|
const member = members.find(m => m.userId === userId);
|
||||||
if (!member) return null;
|
const participant = participants.find(p => p.userId === userId);
|
||||||
const displayName = member.user.displayName ?? member.user.username;
|
const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId;
|
||||||
|
const avatar = member?.user.avatar ?? null;
|
||||||
// Find participant for status badges
|
const status = member?.user.status;
|
||||||
const participant = participants.find(p => p.identity === userId || p.username === member.user.username);
|
// Resolve status: for local user use store directly, for remote users
|
||||||
const isMuted = participant ? !participant.audioTrack : false;
|
// try LiveKit participant first, then fall back to WebSocket voiceUserStates
|
||||||
const hasCamera = participant ? participant.videoTrack !== null : false;
|
const wsStatus = voiceUserStates.get(userId);
|
||||||
const isScreenSharing = participant ? participant.screenTrack !== null : false;
|
const isParticipantDeafened = userId === currentUserId
|
||||||
|
? localIsDeafened
|
||||||
|
: (participant?.isDeafened ?? wsStatus?.isDeafened ?? false);
|
||||||
|
const isMuted = userId === currentUserId
|
||||||
|
? localIsMuted
|
||||||
|
: (participant?.isMuted ?? wsStatus?.isMuted ?? false);
|
||||||
|
const hasCamera = participant?.isCameraOn ?? false;
|
||||||
|
const isScreenSharing = participant?.isScreenSharing ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={userId} className="flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-modifier-hover transition-colors">
|
<div key={userId} className="flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-modifier-hover transition-colors">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={member.user.avatar}
|
src={avatar}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
size={20}
|
size={20}
|
||||||
status={member.user.status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
<span className="text-[13px] text-discord-text-secondary truncate flex-1 min-w-0">{displayName}</span>
|
<span className="text-[13px] text-discord-text-secondary truncate flex-1 min-w-0">{displayName}</span>
|
||||||
{/* Status badges */}
|
{/* Status badges */}
|
||||||
@@ -66,6 +78,12 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
|
|||||||
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
{isParticipantDeafened && (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-discord-red">
|
||||||
|
<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" />
|
||||||
|
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
{hasCamera && (
|
{hasCamera && (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
|
||||||
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
|
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
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';
|
||||||
|
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VoiceControls renders the voice status + button rows.
|
* VoiceControls renders the voice status + button rows.
|
||||||
@@ -10,62 +11,22 @@ import { wsSend } from '../../hooks/useWebSocket';
|
|||||||
*/
|
*/
|
||||||
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 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 toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||||
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
const noiseSuppression = useVoiceStore((s) => s.noiseSuppression);
|
||||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
const toggleNoiseSuppression = useVoiceStore((s) => s.toggleNoiseSuppression);
|
||||||
const connectionError = useVoiceStore((s) => s.connectionError);
|
const connectionError = useVoiceStore((s) => s.connectionError);
|
||||||
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
||||||
const channels = useServerStore((s) => s.channels);
|
const channels = useServerStore((s) => s.channels);
|
||||||
|
const [showVideoQuality, setShowVideoQuality] = useState(false);
|
||||||
|
|
||||||
if (!currentVoiceChannelId) return null;
|
if (!currentVoiceChannelId) return null;
|
||||||
|
|
||||||
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
||||||
const channelName = channel?.name ?? 'Voice Channel';
|
const channelName = channel?.name ?? 'Voice Channel';
|
||||||
|
|
||||||
const handleMute = async () => {
|
|
||||||
const room = getActiveRoom();
|
|
||||||
if (room) {
|
|
||||||
try {
|
|
||||||
await room.localParticipant.setMicrophoneEnabled(isMuted);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[VoiceControls] Failed to toggle mic:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toggleMic();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeafen = async () => {
|
|
||||||
const room = getActiveRoom();
|
|
||||||
if (room) {
|
|
||||||
try {
|
|
||||||
const willDeafen = !isDeafened;
|
|
||||||
if (willDeafen) {
|
|
||||||
await room.localParticipant.setMicrophoneEnabled(false);
|
|
||||||
room.remoteParticipants.forEach((p) => {
|
|
||||||
p.setVolume(0);
|
|
||||||
});
|
|
||||||
if (!isMuted) toggleMic();
|
|
||||||
} else {
|
|
||||||
const outputVolume = useVoiceStore.getState().outputVolume;
|
|
||||||
const scaled = outputVolume / 100;
|
|
||||||
room.remoteParticipants.forEach((p) => {
|
|
||||||
p.setVolume(scaled);
|
|
||||||
});
|
|
||||||
await room.localParticipant.setMicrophoneEnabled(true);
|
|
||||||
if (isMuted) toggleMic();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[VoiceControls] Failed to toggle deafen:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toggleDeafen();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCamera = async () => {
|
const handleCamera = async () => {
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
@@ -88,6 +49,28 @@ export function VoiceControls() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNoiseSuppression = async () => {
|
||||||
|
const room = getActiveRoom();
|
||||||
|
if (room) {
|
||||||
|
try {
|
||||||
|
const micPub = room.localParticipant.getTrackPublications().find(
|
||||||
|
p => p.source === 'microphone'
|
||||||
|
);
|
||||||
|
const mediaTrack = micPub?.track?.mediaStreamTrack;
|
||||||
|
if (mediaTrack) {
|
||||||
|
await mediaTrack.applyConstraints({
|
||||||
|
noiseSuppression: !noiseSuppression,
|
||||||
|
echoCancellation: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[VoiceControls] Failed to toggle noise suppression:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toggleNoiseSuppression();
|
||||||
|
};
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
const handleDisconnect = () => {
|
||||||
wsSend({ type: 'voice_leave' });
|
wsSend({ type: 'voice_leave' });
|
||||||
useVoiceStore.getState().leaveVoice();
|
useVoiceStore.getState().leaveVoice();
|
||||||
@@ -145,39 +128,8 @@ export function VoiceControls() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Mute, Deafen, Camera, Screen Share */}
|
{/* Row 2: Camera, Screen Share, Video Quality, Noise Suppression */}
|
||||||
<div className="flex items-center gap-1 px-3 pb-2 pt-1">
|
<div className="relative flex items-center gap-1 px-3 pb-2 pt-1">
|
||||||
<button
|
|
||||||
onClick={handleMute}
|
|
||||||
className={`${btnBase} ${
|
|
||||||
isMuted || isDeafened
|
|
||||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
|
||||||
: btnDefaultStyle
|
|
||||||
}`}
|
|
||||||
title={isMuted ? 'Unmute' : 'Mute'}
|
|
||||||
>
|
|
||||||
<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) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleDeafen}
|
|
||||||
className={`${btnBase} ${
|
|
||||||
isDeafened
|
|
||||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
|
||||||
: btnDefaultStyle
|
|
||||||
}`}
|
|
||||||
title={isDeafened ? 'Undeafen' : 'Deafen'}
|
|
||||||
>
|
|
||||||
<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 && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleCamera}
|
onClick={handleCamera}
|
||||||
className={`${btnBase} ${
|
className={`${btnBase} ${
|
||||||
@@ -213,6 +165,51 @@ export function VoiceControls() {
|
|||||||
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
|
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Video Quality */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowVideoQuality(!showVideoQuality)}
|
||||||
|
className={`${btnBase} ${
|
||||||
|
showVideoQuality
|
||||||
|
? 'bg-[#111214] text-discord-blurple hover:bg-[#1a1b1e]'
|
||||||
|
: btnDefaultStyle
|
||||||
|
}`}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Noise Suppression */}
|
||||||
|
<button
|
||||||
|
onClick={handleNoiseSuppression}
|
||||||
|
className={`${btnBase} ${
|
||||||
|
noiseSuppression
|
||||||
|
? 'bg-[#111214] text-discord-green hover:bg-[#1a1b1e]'
|
||||||
|
: btnDefaultStyle
|
||||||
|
}`}
|
||||||
|
title={noiseSuppression ? 'Disable Noise Suppression' : 'Enable Noise Suppression'}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M7 9v6h4l5 5V4l-5 5H7z" />
|
||||||
|
{noiseSuppression ? (
|
||||||
|
<>
|
||||||
|
<path d="M19 12c0-1.66-.68-3.16-1.76-4.24l-1.42 1.42C16.55 9.9 17 10.9 17 12c0 1.1-.45 2.1-1.18 2.82l1.42 1.42C18.32 15.16 19 13.66 19 12z" />
|
||||||
|
<path d="M21 12c0-2.76-1.12-5.26-2.93-7.07l-1.42 1.42C18.2 7.9 19 9.85 19 12c0 2.15-.8 4.1-2.35 5.65l1.42 1.42C19.88 17.26 21 14.76 21 12z" opacity="0.6" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<line x1="19" y1="5" x2="19" y2="19" stroke="currentColor" strokeWidth="2" strokeLinecap="round" opacity="0.4" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Video Quality Popover */}
|
||||||
|
<VideoQualityPopover
|
||||||
|
open={showVideoQuality}
|
||||||
|
onClose={() => setShowVideoQuality(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ 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 readyState
|
// Determine active video track — prioritize screen share, check both enabled flag and readyState
|
||||||
const liveScreen = participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null;
|
const liveScreen = participant.isScreenSharing && participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null;
|
||||||
const liveCamera = 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;
|
||||||
@@ -154,6 +154,14 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{(isLocal ? isDeafened : participant.isDeafened) && (
|
||||||
|
<div className="w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
|
||||||
|
<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" />
|
||||||
|
<line x1="3" y1="3" x2="21" y2="21" stroke="white" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{participant.isScreenSharing && !isScreenShare && (
|
{participant.isScreenSharing && !isScreenShare && (
|
||||||
<div className="w-5 h-5 bg-discord-blurple/90 rounded-full flex items-center justify-center">
|
<div className="w-5 h-5 bg-discord-blurple/90 rounded-full flex items-center justify-center">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface ParticipantInfo {
|
|||||||
username: string;
|
username: string;
|
||||||
isSpeaking: boolean;
|
isSpeaking: boolean;
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
|
isDeafened: boolean;
|
||||||
isCameraOn: boolean;
|
isCameraOn: boolean;
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
isLocal: boolean;
|
isLocal: boolean;
|
||||||
@@ -118,16 +119,34 @@ export function useLiveKit() {
|
|||||||
const mt = track.mediaStreamTrack;
|
const mt = track.mediaStreamTrack;
|
||||||
if (!mt || mt.readyState !== 'live') return;
|
if (!mt || mt.readyState !== 'live') return;
|
||||||
if (pub.source === Track.Source.Microphone) audioTrack = mt;
|
if (pub.source === Track.Source.Microphone) audioTrack = mt;
|
||||||
else if (pub.source === Track.Source.Camera) videoTrack = mt;
|
else if (pub.source === Track.Source.Camera && p.isCameraEnabled) videoTrack = mt;
|
||||||
else if (pub.source === Track.Source.ScreenShare) screenTrack = mt;
|
else if (pub.source === Track.Source.ScreenShare && p.isScreenShareEnabled) screenTrack = mt;
|
||||||
});
|
});
|
||||||
allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: !p.isMicrophoneEnabled, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack });
|
let isDeafened = false;
|
||||||
|
if (isLocal) {
|
||||||
|
isDeafened = useVoiceStore.getState().isDeafened;
|
||||||
|
} else {
|
||||||
|
isDeafened = useVoiceStore.getState().deafenedUserIds.has(userId);
|
||||||
|
}
|
||||||
|
allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: !p.isMicrophoneEnabled, isDeafened, 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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleDataReceived = useCallback((payload: Uint8Array, participant?: RemoteParticipant) => {
|
||||||
|
try {
|
||||||
|
const text = new TextDecoder().decode(payload);
|
||||||
|
const msg = JSON.parse(text);
|
||||||
|
if (msg.type === 'deafen' && participant) {
|
||||||
|
const { userId } = parseIdentity(participant.identity);
|
||||||
|
useVoiceStore.getState().setUserDeafened(userId, msg.deafened === true);
|
||||||
|
updateParticipants();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, [updateParticipants]);
|
||||||
|
|
||||||
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;
|
||||||
@@ -141,7 +160,17 @@ export function useLiveKit() {
|
|||||||
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, guardedUpdate);
|
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||||
|
guardedUpdate();
|
||||||
|
// Re-broadcast local deafen state to newly connected participant
|
||||||
|
if (useVoiceStore.getState().isDeafened) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
newRoom.localParticipant.publishData(
|
||||||
|
encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })),
|
||||||
|
{ reliable: true }
|
||||||
|
).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);
|
||||||
@@ -150,6 +179,8 @@ export function useLiveKit() {
|
|||||||
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
|
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
|
||||||
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
|
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
|
||||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
|
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
|
||||||
|
newRoom.on(RoomEvent.ParticipantMetadataChanged, guardedUpdate);
|
||||||
|
newRoom.on(RoomEvent.DataReceived, handleDataReceived);
|
||||||
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
|
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||||
if (roomRef.current === newRoom) {
|
if (roomRef.current === newRoom) {
|
||||||
const connected = state === ConnectionState.Connected;
|
const connected = state === ConnectionState.Connected;
|
||||||
@@ -171,7 +202,7 @@ export function useLiveKit() {
|
|||||||
try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); }
|
try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); }
|
||||||
} 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]);
|
}, [updateParticipants, handleDataReceived]);
|
||||||
|
|
||||||
const connectDm = useCallback(async (dmChannelId: string) => {
|
const connectDm = useCallback(async (dmChannelId: string) => {
|
||||||
const gen = ++_connectGeneration;
|
const gen = ++_connectGeneration;
|
||||||
@@ -208,7 +239,7 @@ export function useLiveKit() {
|
|||||||
try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); }
|
try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); }
|
||||||
} 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]);
|
}, [updateParticipants, handleDataReceived]);
|
||||||
|
|
||||||
const disconnect = useCallback(async () => {
|
const disconnect = useCallback(async () => {
|
||||||
_connectGeneration++;
|
_connectGeneration++;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function handleEvent(event: ServerEvent): void {
|
|||||||
const { setUser } = useAuthStore.getState();
|
const { setUser } = useAuthStore.getState();
|
||||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState();
|
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState();
|
||||||
const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
||||||
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers } = useVoiceStore.getState();
|
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'ready':
|
case 'ready':
|
||||||
@@ -45,6 +45,21 @@ function handleEvent(event: ServerEvent): void {
|
|||||||
setVoiceUsers(channelId, userIds);
|
setVoiceUsers(channelId, userIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Populate voice user statuses (mute/deafen) from server
|
||||||
|
if (event.voiceUserStates) {
|
||||||
|
for (const [uid, status] of Object.entries(event.voiceUserStates)) {
|
||||||
|
setVoiceUserStatus(uid, status.isMuted, status.isDeafened);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-register in voice channel if we're still connected to LiveKit
|
||||||
|
// (WebSocket reconnect causes server to drop our voice tracking)
|
||||||
|
{
|
||||||
|
const { currentVoiceChannelId, isMuted: curMuted, isDeafened: curDeafened } = useVoiceStore.getState();
|
||||||
|
if (currentVoiceChannelId) {
|
||||||
|
wsSend({ type: 'voice_join', channelId: currentVoiceChannelId });
|
||||||
|
wsSend({ type: 'voice_status', isMuted: curMuted, isDeafened: curDeafened });
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'message_created':
|
case 'message_created':
|
||||||
@@ -78,9 +93,14 @@ function handleEvent(event: ServerEvent): void {
|
|||||||
addVoiceUser(event.channelId, event.userId);
|
addVoiceUser(event.channelId, event.userId);
|
||||||
} else {
|
} else {
|
||||||
removeVoiceUser(event.channelId, event.userId);
|
removeVoiceUser(event.channelId, event.userId);
|
||||||
|
clearVoiceUserStatus(event.userId);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'voice_status_update':
|
||||||
|
setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'member_joined':
|
case 'member_joined':
|
||||||
addMember(event.member);
|
addMember(event.member);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ interface VoiceState {
|
|||||||
toggleDeafen: () => void;
|
toggleDeafen: () => void;
|
||||||
setFocusedParticipant: (id: string | null) => void;
|
setFocusedParticipant: (id: string | null) => void;
|
||||||
setVideoQuality: (quality: '1080p' | '1080p60' | '720p' | '720p60' | '540p' | '360p') => void;
|
setVideoQuality: (quality: '1080p' | '1080p60' | '720p' | '720p60' | '540p' | '360p') => void;
|
||||||
|
noiseSuppression: boolean;
|
||||||
|
toggleNoiseSuppression: () => void;
|
||||||
|
deafenedUserIds: Set<string>;
|
||||||
|
setUserDeafened: (userId: string, deafened: boolean) => void;
|
||||||
|
// WebSocket-based voice user status (visible without joining LiveKit)
|
||||||
|
voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean }>;
|
||||||
|
setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean) => void;
|
||||||
|
clearVoiceUserStatus: (userId: string) => void;
|
||||||
getVoiceUsers: (channelId: string) => string[];
|
getVoiceUsers: (channelId: string) => string[];
|
||||||
clearAllVoiceUsers: () => void;
|
clearAllVoiceUsers: () => void;
|
||||||
leaveVoice: () => void;
|
leaveVoice: () => void;
|
||||||
@@ -123,10 +131,36 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
|
|
||||||
setFocusedParticipant: (id) => set({ focusedParticipantId: id }),
|
setFocusedParticipant: (id) => set({ focusedParticipantId: id }),
|
||||||
setVideoQuality: (quality) => set({ videoQuality: quality }),
|
setVideoQuality: (quality) => set({ videoQuality: quality }),
|
||||||
|
noiseSuppression: true,
|
||||||
|
toggleNoiseSuppression: () => set((state) => ({ noiseSuppression: !state.noiseSuppression })),
|
||||||
|
deafenedUserIds: new Set(),
|
||||||
|
setUserDeafened: (userId, deafened) => {
|
||||||
|
set((state) => {
|
||||||
|
const newSet = new Set(state.deafenedUserIds);
|
||||||
|
if (deafened) newSet.add(userId); else newSet.delete(userId);
|
||||||
|
return { deafenedUserIds: newSet };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
voiceUserStates: new Map(),
|
||||||
|
setVoiceUserStatus: (userId, isMuted, isDeafened) => {
|
||||||
|
set((state) => {
|
||||||
|
const newMap = new Map(state.voiceUserStates);
|
||||||
|
newMap.set(userId, { isMuted, isDeafened });
|
||||||
|
return { voiceUserStates: newMap };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearVoiceUserStatus: (userId) => {
|
||||||
|
set((state) => {
|
||||||
|
const newMap = new Map(state.voiceUserStates);
|
||||||
|
newMap.delete(userId);
|
||||||
|
return { voiceUserStates: newMap };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [],
|
getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [],
|
||||||
|
|
||||||
clearAllVoiceUsers: () => set({ voiceUsers: new Map() }),
|
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
|
||||||
|
|
||||||
// Leave voice without wiping the voiceUsers map (so sidebar still shows others)
|
// Leave voice without wiping the voiceUsers map (so sidebar still shows others)
|
||||||
leaveVoice: () => set({
|
leaveVoice: () => set({
|
||||||
@@ -143,6 +177,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
focusedParticipantId: null,
|
focusedParticipantId: null,
|
||||||
activeDmCall: null,
|
activeDmCall: null,
|
||||||
outgoingCall: null,
|
outgoingCall: null,
|
||||||
|
deafenedUserIds: new Set(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
reset: () => set({
|
reset: () => set({
|
||||||
@@ -162,5 +197,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
incomingCall: null,
|
incomingCall: null,
|
||||||
outgoingCall: null,
|
outgoingCall: null,
|
||||||
activeDmCall: null,
|
activeDmCall: null,
|
||||||
|
deafenedUserIds: new Set(),
|
||||||
|
voiceUserStates: new Map(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user