+ {/* Video or Avatar fallback */}
+ {hasVideo ? (
+
+ ) : (
+
+ {displayParticipant ? (
+
+
+ {displayParticipant.isSpeaking && (
+
+ )}
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* LIVE badge */}
+ {isScreen && (
+
+ LIVE
+
+ )}
+
+ {/* Close button */}
+
+
+ {/* Bottom overlay with name */}
+
+
+
{displayName}
+ {displayParticipant?.isSpeaking && (
+
+ )}
+
+
{channelName}
+
+
+ {/* Expand icon hint (bottom-right) */}
+
+
+ );
+}
diff --git a/packages/web/src/components/voice/VoiceUser.js b/packages/web/src/components/voice/VoiceUser.js
index 4725a594..2b4a58c3 100644
--- a/packages/web/src/components/voice/VoiceUser.js
+++ b/packages/web/src/components/voice/VoiceUser.js
@@ -44,7 +44,7 @@ export function VoiceUser({ participant, large }) {
}
audioEl.muted = isDeafened;
}, [isDeafened, outputVolume, perUserVolume]);
- const hasVideo = participant.isCameraOn || participant.isScreenSharing;
+ const hasVideo = !!(participant.videoTrack || participant.screenTrack);
const isLocal = participant.isLocal;
// Volume context menu
const [volumeMenu, setVolumeMenu] = useState(null);
diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx
index 418585d5..6f4282a5 100644
--- a/packages/web/src/components/voice/VoiceUser.tsx
+++ b/packages/web/src/components/voice/VoiceUser.tsx
@@ -52,7 +52,7 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
audioEl.muted = isDeafened;
}, [isDeafened, outputVolume, perUserVolume]);
- const hasVideo = participant.isCameraOn || participant.isScreenSharing;
+ const hasVideo = !!(participant.videoTrack || participant.screenTrack);
const isLocal = participant.isLocal;
// Volume context menu
diff --git a/packages/web/src/hooks/useLiveKit.js b/packages/web/src/hooks/useLiveKit.js
index 1a26b5c9..9c4d1c05 100644
--- a/packages/web/src/hooks/useLiveKit.js
+++ b/packages/web/src/hooks/useLiveKit.js
@@ -119,6 +119,9 @@ export function useLiveKit() {
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
+ newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
+ newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
+ newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate);
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
const connected = state === ConnectionState.Connected;
@@ -181,6 +184,14 @@ export function useLiveKit() {
return;
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
roomRef.current = newRoom;
+ const guardedUpdate = () => { if (roomRef.current === newRoom)
+ updateParticipants(); };
+ newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
+ newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
+ newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
+ newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
+ newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
+ newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate);
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
const connected = state === ConnectionState.Connected;
diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts
index 42384ade..8aa8f556 100644
--- a/packages/web/src/hooks/useLiveKit.ts
+++ b/packages/web/src/hooks/useLiveKit.ts
@@ -142,6 +142,9 @@ export function useLiveKit() {
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
+ newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
+ newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
+ newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate);
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
const connected = state === ConnectionState.Connected;
@@ -174,6 +177,13 @@ export function useLiveKit() {
if (gen !== _connectGeneration) return;
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
roomRef.current = newRoom;
+ const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
+ newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
+ newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
+ newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
+ newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
+ newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
+ newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate);
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
const connected = state === ConnectionState.Connected;
diff --git a/packages/web/src/stores/uiStore.js b/packages/web/src/stores/uiStore.js
index 668567df..fdde9b86 100644
--- a/packages/web/src/stores/uiStore.js
+++ b/packages/web/src/stores/uiStore.js
@@ -31,7 +31,9 @@ export const useUIStore = create((set) => ({
}),
voiceChatOpen: false,
voiceFullscreen: false,
+ pipCollapsed: false,
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
+ setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
}));
diff --git a/packages/web/src/stores/uiStore.ts b/packages/web/src/stores/uiStore.ts
index 4485bf44..9cfa137a 100644
--- a/packages/web/src/stores/uiStore.ts
+++ b/packages/web/src/stores/uiStore.ts
@@ -36,9 +36,11 @@ interface UIState {
closeUserProfile: () => void;
voiceChatOpen: boolean;
voiceFullscreen: boolean;
+ pipCollapsed: boolean;
toggleVoiceChat: () => void;
toggleVoiceFullscreen: () => void;
setVoiceFullscreen: (fullscreen: boolean) => void;
+ setPipCollapsed: (collapsed: boolean) => void;
}
export const useUIStore = create