fix: derive voice sidebar from LiveKit participants to eliminate desync
The channel sidebar voice user list was maintained by a separate voiceUsers Map (fed by WS events + fragile hydration code) that diverged from reality after server restarts — users shown in wrong channels, duplicated across channels. The VoiceGrid was always correct because it reads LiveKit participants directly. Now VoiceChannel.tsx derives its user list from LiveKit participants for the connected channel (single source of truth) and only falls back to server-provided voiceUsers for channels the user is not connected to. Removed all hydration band-aids that tried to sync the two systems: - useLiveKit ParticipantDisconnected → removeVoiceUser - useLiveKit ConnectionStateChanged → addVoiceUser hydration loop - useWebSocket ready handler → dynamic import LiveKit hydration Also includes: voice channel settings gear icon on hover, persist per-user volume/mute prefs across sessions, default screen share audio off on Electron (no system audio capture support).
This commit is contained in:
@@ -1334,6 +1334,8 @@ function ChannelItem({
|
|||||||
channelName={channel.name}
|
channelName={channel.name}
|
||||||
onClick={() => canConnect && handleVoiceJoin(channel.id)}
|
onClick={() => canConnect && handleVoiceJoin(channel.id)}
|
||||||
locked={!canConnect}
|
locked={!canConnect}
|
||||||
|
canManage={canManage}
|
||||||
|
onSettingsClick={onSettingsClick}
|
||||||
dragState={voiceDragState}
|
dragState={voiceDragState}
|
||||||
onDragStart={onVoiceDragStart}
|
onDragStart={onVoiceDragStart}
|
||||||
onDragEnd={onVoiceDragEnd}
|
onDragEnd={onVoiceDragEnd}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useMemo } from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
@@ -19,15 +19,27 @@ interface VoiceChannelProps {
|
|||||||
channelName: string;
|
channelName: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
canManage?: boolean;
|
||||||
|
onSettingsClick?: () => void;
|
||||||
dragState?: VoiceChannelDragState | null;
|
dragState?: VoiceChannelDragState | null;
|
||||||
onDragStart?: (userId: string) => void;
|
onDragStart?: (userId: string) => void;
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VoiceChannel({ channelId, channelName, onClick, locked, dragState, onDragStart, onDragEnd }: VoiceChannelProps) {
|
export function VoiceChannel({ channelId, channelName, onClick, locked, canManage, onSettingsClick, dragState, onDragStart, onDragEnd }: VoiceChannelProps) {
|
||||||
const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
|
const serverVoiceUsers = 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 isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
||||||
|
|
||||||
|
// For OUR channel: LiveKit participants are the single source of truth.
|
||||||
|
// For other channels: use server-provided voiceUsers (only available source).
|
||||||
|
const voiceUsers = useMemo(() => {
|
||||||
|
if (currentVoiceChannel === channelId && isLiveKitConnected && participants.length > 0) {
|
||||||
|
return [...new Set(participants.map(p => p.userId))];
|
||||||
|
}
|
||||||
|
return serverVoiceUsers;
|
||||||
|
}, [currentVoiceChannel, channelId, isLiveKitConnected, participants, serverVoiceUsers]);
|
||||||
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
|
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
|
||||||
const localIsMuted = useVoiceStore((s) => s.isMuted);
|
const localIsMuted = useVoiceStore((s) => s.isMuted);
|
||||||
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
||||||
@@ -127,6 +139,21 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, dragStat
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
<span className="truncate text-[15px] font-medium">{channelName}</span>
|
<span className="truncate text-[15px] font-medium">{channelName}</span>
|
||||||
|
{canManage && onSettingsClick && (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="flex-shrink-0 opacity-0 group-hover:opacity-100 text-txt-tertiary hover:text-txt-primary transition-opacity"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSettingsClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Connected users */}
|
{/* Connected users */}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function setCameraSubscription(room: Room | null, targetIdentity: string,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseIdentity(identity: string): { userId: string; username: string } {
|
export function parseIdentity(identity: string): { userId: string; username: string } {
|
||||||
const parts = identity.split(':');
|
const parts = identity.split(':');
|
||||||
return { userId: parts[0] ?? identity, username: parts[1] ?? identity };
|
return { userId: parts[0] ?? identity, username: parts[1] ?? identity };
|
||||||
}
|
}
|
||||||
@@ -414,14 +414,9 @@ export function useLiveKit() {
|
|||||||
});
|
});
|
||||||
newRoom.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {
|
newRoom.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {
|
||||||
guardedUpdate();
|
guardedUpdate();
|
||||||
// Sync voiceUsers so channel sidebar updates immediately
|
// Clean up stale WS-based voice status for the departed participant
|
||||||
// (don't wait for server's 5s grace-period WS event)
|
|
||||||
const chId = connectedChannelRef.current;
|
|
||||||
if (chId && !chId.startsWith('dm-')) {
|
|
||||||
const { userId } = parseIdentity(participant.identity);
|
const { userId } = parseIdentity(participant.identity);
|
||||||
useVoiceStore.getState().removeVoiceUser(chId, userId);
|
|
||||||
useVoiceStore.getState().clearVoiceUserStatus(userId);
|
useVoiceStore.getState().clearVoiceUserStatus(userId);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||||
// LiveKit auto-attaches a hidden <audio> element for subscribed audio tracks.
|
// LiveKit auto-attaches a hidden <audio> element for subscribed audio tracks.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ParticipantInfo } from '../hooks/useLiveKit';
|
|||||||
import { AudioManager } from '../audio/AudioManager';
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from './spaceStore';
|
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from './spaceStore';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
|
import { isElectron } from '../platform/platform';
|
||||||
|
|
||||||
export interface ScreenShareConfig {
|
export interface ScreenShareConfig {
|
||||||
height: 1080 | 720 | 540;
|
height: 1080 | 720 | 540;
|
||||||
@@ -135,7 +136,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
inputDeviceId: 'default',
|
inputDeviceId: 'default',
|
||||||
outputDeviceId: 'default',
|
outputDeviceId: 'default',
|
||||||
focusedParticipantId: null,
|
focusedParticipantId: null,
|
||||||
screenShareConfig: { height: 720, fps: 60, mode: 'gaming', customBitrateKbps: null, shareAudio: true },
|
screenShareConfig: { height: 720, fps: 60, mode: 'gaming', customBitrateKbps: null, shareAudio: !isElectron() },
|
||||||
participantVolumes: new Map(),
|
participantVolumes: new Map(),
|
||||||
setParticipantVolume: (userId, volume) => {
|
setParticipantVolume: (userId, volume) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -400,9 +401,6 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
// Per-session media state
|
// Per-session media state
|
||||||
isCameraOn: false,
|
isCameraOn: false,
|
||||||
isScreenSharing: false,
|
isScreenSharing: false,
|
||||||
// Per-session maps
|
|
||||||
participantVolumes: new Map(),
|
|
||||||
participantMutes: new Map(),
|
|
||||||
deafenedUserIds: new Set(),
|
deafenedUserIds: new Set(),
|
||||||
streamVolumes: new Map(),
|
streamVolumes: new Map(),
|
||||||
streamMutes: new Map(),
|
streamMutes: new Map(),
|
||||||
@@ -528,7 +526,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'backspace-voice-settings',
|
name: 'backspace-voice-settings',
|
||||||
version: 10,
|
version: 11,
|
||||||
migrate: (persistedState: any, version: number) => {
|
migrate: (persistedState: any, version: number) => {
|
||||||
if (version === 0) {
|
if (version === 0) {
|
||||||
persistedState.streamAttenuationEnabled = false;
|
persistedState.streamAttenuationEnabled = false;
|
||||||
@@ -572,6 +570,11 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
persistedState.screenShareConfig.shareAudio = true;
|
persistedState.screenShareConfig.shareAudio = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (version < 11) {
|
||||||
|
if (persistedState.screenShareConfig) {
|
||||||
|
persistedState.screenShareConfig.shareAudio = !isElectron();
|
||||||
|
}
|
||||||
|
}
|
||||||
return persistedState;
|
return persistedState;
|
||||||
},
|
},
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
@@ -592,6 +595,9 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
soundEffectVolume: state.soundEffectVolume,
|
soundEffectVolume: state.soundEffectVolume,
|
||||||
streamAttenuationEnabled: state.streamAttenuationEnabled,
|
streamAttenuationEnabled: state.streamAttenuationEnabled,
|
||||||
streamAttenuationStrength: state.streamAttenuationStrength,
|
streamAttenuationStrength: state.streamAttenuationStrength,
|
||||||
|
// Per-user preferences (Map → plain object for JSON)
|
||||||
|
participantVolumes: Object.fromEntries(state.participantVolumes),
|
||||||
|
participantMutes: Object.fromEntries(state.participantMutes),
|
||||||
}),
|
}),
|
||||||
merge: (persistedState: any, currentState: VoiceState) => {
|
merge: (persistedState: any, currentState: VoiceState) => {
|
||||||
const merged = { ...currentState, ...persistedState };
|
const merged = { ...currentState, ...persistedState };
|
||||||
@@ -605,8 +611,12 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
merged.speakingUserIds = currentState.speakingUserIds;
|
merged.speakingUserIds = currentState.speakingUserIds;
|
||||||
merged.deafenedUserIds = currentState.deafenedUserIds;
|
merged.deafenedUserIds = currentState.deafenedUserIds;
|
||||||
merged.voiceUserStates = currentState.voiceUserStates;
|
merged.voiceUserStates = currentState.voiceUserStates;
|
||||||
merged.participantVolumes = currentState.participantVolumes;
|
merged.participantVolumes = persistedState?.participantVolumes
|
||||||
merged.participantMutes = currentState.participantMutes;
|
? new Map(Object.entries(persistedState.participantVolumes))
|
||||||
|
: currentState.participantVolumes;
|
||||||
|
merged.participantMutes = persistedState?.participantMutes
|
||||||
|
? new Map(Object.entries(persistedState.participantMutes))
|
||||||
|
: currentState.participantMutes;
|
||||||
merged.streamVolumes = currentState.streamVolumes;
|
merged.streamVolumes = currentState.streamVolumes;
|
||||||
merged.streamMutes = currentState.streamMutes;
|
merged.streamMutes = currentState.streamMutes;
|
||||||
merged.watchingStreams = currentState.watchingStreams;
|
merged.watchingStreams = currentState.watchingStreams;
|
||||||
|
|||||||
Reference in New Issue
Block a user