feat(phase6): video grid, noise suppression, whisper system
- New VideoGrid component: live camera feeds (local + remote) in VoicePanel - Camera toggle in VoiceControls: enable/disable video per participant - Noise suppression toggle in VoiceControls + voice store - VOICE_WHISPER WS event: backend routes whisper to target user only - Whisper UI: per-participant whisper button, notification toasts with dismiss - Fix circular import between voice.ts and ws.ts (use lazy accessor)
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
} from 'livekit-client';
|
||||
import { api } from '../lib/api.ts';
|
||||
import { useWebSocketStore } from './ws.ts';
|
||||
import { useAuthStore } from './auth.ts';
|
||||
|
||||
export interface VoiceParticipant {
|
||||
identity: string;
|
||||
@@ -19,6 +20,13 @@ export interface VoiceParticipant {
|
||||
isSpeaking: boolean;
|
||||
}
|
||||
|
||||
export interface WhisperNotification {
|
||||
fromUserId: string;
|
||||
fromUsername: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface VoiceTokenResponse {
|
||||
token: string;
|
||||
livekit_url: string;
|
||||
@@ -32,16 +40,22 @@ interface VoiceState {
|
||||
isDeafened: boolean;
|
||||
isVideoOn: boolean;
|
||||
isScreenSharing: boolean;
|
||||
noiseSuppression: boolean;
|
||||
participants: VoiceParticipant[];
|
||||
error: string | null;
|
||||
_room: Room | null;
|
||||
isJoining: boolean;
|
||||
whispers: WhisperNotification[];
|
||||
joinVoice: (channelId: string, channelName: string) => Promise<void>;
|
||||
leaveVoice: () => Promise<void>;
|
||||
toggleMute: () => void;
|
||||
toggleDeafen: () => void;
|
||||
toggleVideo: () => void;
|
||||
toggleScreenShare: () => void;
|
||||
toggleNoiseSuppression: () => void;
|
||||
_addWhisper: (w: WhisperNotification) => void;
|
||||
whisperTo: (targetUserId: string, targetUsername: string, message?: string) => void;
|
||||
dismissWhisper: (timestamp: number) => void;
|
||||
}
|
||||
|
||||
function participantToVoice(p: Participant): VoiceParticipant {
|
||||
@@ -62,10 +76,12 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
||||
isDeafened: false,
|
||||
isVideoOn: false,
|
||||
isScreenSharing: false,
|
||||
noiseSuppression: true,
|
||||
participants: [],
|
||||
error: null,
|
||||
_room: null,
|
||||
isJoining: false,
|
||||
whispers: [],
|
||||
|
||||
joinVoice: async (channelId, channelName) => {
|
||||
if (get().isJoining) {
|
||||
@@ -290,4 +306,50 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
||||
await room.localParticipant.setScreenShareEnabled(newState);
|
||||
set({ isScreenSharing: newState });
|
||||
},
|
||||
|
||||
toggleNoiseSuppression: async () => {
|
||||
const room = get()._room;
|
||||
if (!room) return;
|
||||
|
||||
const newState = !get().noiseSuppression;
|
||||
set({ noiseSuppression: newState });
|
||||
|
||||
// Apply noise suppression + echo cancellation to local audio track
|
||||
try {
|
||||
const audioTrack = room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||||
if (audioTrack?.track?.mediaStreamTrack) {
|
||||
await audioTrack.track.mediaStreamTrack.applyConstraints({
|
||||
echoCancellation: newState,
|
||||
noiseSuppression: newState,
|
||||
autoGainControl: newState,
|
||||
} as MediaTrackConstraints);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[voice] failed to update audio processing:', err);
|
||||
}
|
||||
},
|
||||
|
||||
_addWhisper: (w) => {
|
||||
set((state) => ({ whispers: [...state.whispers, w] }));
|
||||
},
|
||||
|
||||
whisperTo: (targetUserId, targetUsername, message) => {
|
||||
const wsSend = useWebSocketStore.getState().send;
|
||||
const me = useAuthStore.getState().user;
|
||||
wsSend({
|
||||
type: 'VOICE_WHISPER',
|
||||
payload: {
|
||||
target_user_id: targetUserId,
|
||||
target_username: targetUsername,
|
||||
from_username: me?.username || 'unknown',
|
||||
message: message || '',
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
dismissWhisper: (timestamp) => {
|
||||
set((state) => ({
|
||||
whispers: state.whispers.filter((w) => w.timestamp !== timestamp),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useChannelStore } from './channel.ts';
|
||||
import { useServerStore } from './server.ts';
|
||||
import { usePresenceStore } from './presence.ts';
|
||||
import { useTypingStore } from './typing.ts';
|
||||
import { useVoiceStore } from './voice.ts';
|
||||
import type { Message } from './message.ts';
|
||||
import type { Channel } from './channel.ts';
|
||||
import type { Server } from './server.ts';
|
||||
@@ -176,6 +177,17 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'VOICE_WHISPER': {
|
||||
// Forwarded from backend — only intended recipient gets this
|
||||
const { from_user_id, from_username, message } = data.payload as Record<string, string>;
|
||||
useVoiceStore.getState()._addWhisper({
|
||||
fromUserId: from_user_id || '',
|
||||
fromUsername: from_username || 'unknown',
|
||||
message: message || '',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user