diff --git a/packages/web/src/components/voice/PictureInPicture.tsx b/packages/web/src/components/voice/PictureInPicture.tsx index f71db55f..1b9c72fc 100644 --- a/packages/web/src/components/voice/PictureInPicture.tsx +++ b/packages/web/src/components/voice/PictureInPicture.tsx @@ -14,6 +14,7 @@ const EMPTY_PARTICIPANT: ParticipantInfo = { isMuted: false, isDeafened: false, isCameraOn: false, isScreenSharing: false, isLocal: false, audioTrack: null, videoTrack: null, screenTrack: null, screenAudioTrack: null, lkVideoTrack: null, lkScreenTrack: null, + cachedUser: null, }; const PIP_WIDTH = 320; diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index adf17bea..53520477 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -16,6 +16,8 @@ import { import { getApiForOrigin, getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore'; import { wsSend } from './useWebSocket'; import { useVoiceStore } from '../stores/voiceStore'; +import { useAuthStore } from '../stores/authStore'; +import type { User } from '@backspace/shared'; import { broadcastVoiceStatus } from '../utils/voice'; import { AudioManager } from '../audio/AudioManager'; import { SpeakingDetector } from '../audio/SpeakingDetector'; @@ -55,6 +57,7 @@ export interface ParticipantInfo { screenAudioTrack: MediaStreamTrack | null; lkVideoTrack: Track | null; // LiveKit Track for attach/detach (adaptive stream) lkScreenTrack: Track | null; // LiveKit Track for attach/detach (adaptive stream) + cachedUser: User | null; // Hydrated User from member lookup, carried forward across space switches } export interface UserTile { @@ -170,12 +173,35 @@ export function useLiveKit() { const updateParticipants = useCallback(() => { const r = roomRef.current; if (!r) return; + + // Carry-forward: snapshot previous participants for cachedUser preservation + const prevParticipants = useVoiceStore.getState().participants; + const prevCacheMap = new Map(); + for (const prev of prevParticipants) { + prevCacheMap.set(prev.identity, prev.cachedUser); + } + const allParticipants: ParticipantInfo[] = []; const processParticipant = (p: Participant, isLocal: boolean) => { if (!p.identity) return; const { userId, username } = parseIdentity(p.identity); const memberMatch = useSpaceStore.getState().members.find(m => m.userId === userId); - const homeUserId = memberMatch?.user.homeUserId ?? null; + let cachedUser: User | null; + let homeUserId: string | null; + + if (memberMatch) { + // Fresh data available — use and update cache + cachedUser = memberMatch.user as User; + homeUserId = memberMatch.user.homeUserId ?? null; + } else if (isLocal) { + // Local user safety net — authStore is always available + cachedUser = useAuthStore.getState().user; + homeUserId = cachedUser?.homeUserId ?? cachedUser?.id ?? null; + } else { + // Space switched — carry forward from previous cycle + cachedUser = prevCacheMap.get(p.identity) ?? null; + homeUserId = cachedUser?.homeUserId ?? null; + } let audioTrack: MediaStreamTrack | null = null; let videoTrack: MediaStreamTrack | null = null; let screenTrack: MediaStreamTrack | null = null; @@ -229,6 +255,7 @@ export function useLiveKit() { userId, username, homeUserId, + cachedUser, isMuted: isPartMuted, isDeafened: isPartDeafened, isCameraOn: hasCameraPublication && p.isCameraEnabled, // True even when unsubscribed