+
{user.isScreenSharing &&
Live
}
{(user.isMuted || user.isDeafened) && (
diff --git a/Frontend/Electron/src/components/UpdateBanner.jsx b/Frontend/Electron/src/components/UpdateBanner.jsx
index a71cdca..c14e65e 100644
--- a/Frontend/Electron/src/components/UpdateBanner.jsx
+++ b/Frontend/Electron/src/components/UpdateBanner.jsx
@@ -93,3 +93,4 @@ export function TitleBarUpdateIcon() {
);
}
+
\ No newline at end of file
diff --git a/Frontend/Electron/src/components/VoiceStage.jsx b/Frontend/Electron/src/components/VoiceStage.jsx
index 75d33ec..237b5f3 100644
--- a/Frontend/Electron/src/components/VoiceStage.jsx
+++ b/Frontend/Electron/src/components/VoiceStage.jsx
@@ -1,7 +1,8 @@
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Track, RoomEvent } from 'livekit-client';
import { useVoice } from '../contexts/VoiceContext';
import ScreenShareModal from './ScreenShareModal';
+import Avatar from './Avatar';
// Icons
import muteIcon from '../assets/icons/mute.svg';
@@ -23,6 +24,20 @@ const getUserColor = (username) => {
return colors[Math.abs(hash) % colors.length];
};
+// Style constants
+const LIVE_BADGE_STYLE = {
+ backgroundColor: '#ed4245', borderRadius: '4px', padding: '2px 6px',
+ color: 'white', fontSize: '11px', fontWeight: 'bold',
+ textTransform: 'uppercase', letterSpacing: '0.5px',
+};
+const WATCH_STREAM_BUTTON_STYLE = {
+ backgroundColor: 'rgba(0,0,0,0.6)', color: 'white', border: 'none',
+ padding: '10px 20px', borderRadius: '4px', fontWeight: 'bold',
+ fontSize: '14px', cursor: 'pointer',
+};
+const THUMBNAIL_SIZE = { width: 120, height: 68 };
+const BOTTOM_BAR_HEIGHT = 140;
+
// Helper Component for coloring SVGs (Reused from Sidebar)
const ColoredIcon = ({ src, color, size = '24px' }) => (
(
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
- flexShrink: 0
+ flexShrink: 0
}}>
-
);
-const VideoRenderer = ({ track }) => {
+const VideoRenderer = ({ track, style }) => {
const videoRef = useRef(null);
useEffect(() => {
@@ -61,176 +76,115 @@ const VideoRenderer = ({ track }) => {
}, [track]);
return (
-
);
};
-// ... (VideoRenderer remains same)
+// --- Track Discovery Helpers ---
-const ParticipantTile = ({ participant, username }) => {
- // Manually track subscriptions for this participant
- const [videoTrack, setVideoTrack] = useState(null);
- const [isMicEnabled, setIsMicEnabled] = useState(participant.isMicrophoneEnabled);
-
- // Persistence for SS track if getTracks is flaky
- const lastSSPub = useRef(null);
+function findTrackPubs(participant) {
+ let cameraPub = null;
+ let screenSharePub = null;
- // Debug log
- useEffect(() => {
- if (videoTrack) console.log(`[VoiceStage] Rendering video for ${username || participant.identity} - Track: ${videoTrack.sid}`);
- }, [videoTrack, username, participant.identity]);
+ const trackMap = participant.tracks || participant.trackPublications;
+ if (trackMap) {
+ for (const pub of trackMap.values()) {
+ const source = pub.source ? pub.source.toString().toLowerCase() : '';
+ const name = pub.trackName ? pub.trackName.toLowerCase() : '';
- useEffect(() => {
- const updateState = (checkPub) => {
- // Safe track retrieval
- let screenSharePub = null;
- let cameraPub = null;
- let otherVideoPub = null;
-
- // Normalize track collection
- const trackMap = participant.tracks || participant.trackPublications;
-
- // Always iterate the tracks map for consistency
- if (trackMap) {
- for (const pub of trackMap.values()) {
- const source = pub.source.toString().toLowerCase();
- const name = pub.trackName.toLowerCase();
-
- // Debug log
- console.log(`[VoiceStage] Track ${pub.sid}: Kind=${pub.kind} Source=${source} Name=${name} Sub=${pub.isSubscribed}`);
-
- // Explicit Subscribe if missing
- if (!pub.isSubscribed) {
- console.warn(`[VoiceStage] Track ${pub.sid} not subscribed. Subscribing...`);
- pub.setSubscribed(true);
- }
-
- if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
- screenSharePub = pub;
- } else if (source === 'camera' || name.includes('camera')) {
- cameraPub = pub;
- } else if (pub.kind === 'video') {
- otherVideoPub = pub;
- }
- }
- }
-
- // Fallback for older SDKs or if .tracks is missing
- if ((!screenSharePub && !cameraPub) && participant.getTracks) {
- console.log("[VoiceStage] Fallback: using getTracks()");
- try {
- const tracks = participant.getTracks();
- for (const pub of tracks) {
- const source = pub.source ? pub.source.toString().toLowerCase() : 'unknown';
- const name = pub.trackName ? pub.trackName.toLowerCase() : '';
-
- if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
- screenSharePub = pub;
- } else if (source === 'camera' || name.includes('camera')) {
- cameraPub = pub;
- } else if (pub.kind === 'video') {
- otherVideoPub = pub;
- }
- }
- } catch (e) { console.error("getTracks error", e); }
- }
-
- // Fallback for getting *anything* to show
- if (!screenSharePub && otherVideoPub) {
- console.log("[VoiceStage] Fallback: using unspecified video track as screen share candidate");
- screenSharePub = otherVideoPub;
+ if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
+ screenSharePub = pub;
+ } else if (source === 'camera' || name.includes('camera')) {
+ cameraPub = pub;
+ } else if (pub.kind === 'video' && !screenSharePub) {
+ screenSharePub = pub;
}
+ }
+ }
- // Fallback: If checkPub is provided (from event), use it if it matches
- if (checkPub) {
- const s = checkPub.source.toString().toLowerCase();
- if (s === 'screenshare' || s === 'screen_share' || checkPub.trackName.toLowerCase().includes('screen')) {
- screenSharePub = checkPub;
+ // Fallback for older SDKs
+ if ((!screenSharePub && !cameraPub) && participant.getTracks) {
+ try {
+ for (const pub of participant.getTracks()) {
+ const source = pub.source ? pub.source.toString().toLowerCase() : '';
+ const name = pub.trackName ? pub.trackName.toLowerCase() : '';
+ if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
+ screenSharePub = pub;
+ } else if (source === 'camera' || name.includes('camera')) {
+ cameraPub = pub;
+ } else if (pub.kind === 'video' && !screenSharePub) {
+ screenSharePub = pub;
}
}
-
- if (screenSharePub) {
- lastSSPub.current = screenSharePub;
+ } catch (e) { /* ignore */ }
+ }
+
+ return { cameraPub, screenSharePub };
+}
+
+function useParticipantTrack(participant, source) {
+ const [track, setTrack] = useState(null);
+
+ useEffect(() => {
+ if (!participant) { setTrack(null); return; }
+
+ const resolve = () => {
+ const { cameraPub, screenSharePub } = findTrackPubs(participant);
+ const pub = source === 'camera' ? cameraPub : screenSharePub;
+
+ if (pub && !pub.isSubscribed && source === 'camera') {
+ pub.setSubscribed(true);
}
- // Prioritize Screen share
- let track = null;
- if (screenSharePub) {
- if (screenSharePub.track) {
- track = screenSharePub.track;
- }
- }
-
- if (!track && cameraPub && cameraPub.track) {
- track = cameraPub.track;
- }
-
- setVideoTrack(track);
- setIsMicEnabled(participant.isMicrophoneEnabled);
- };
-
- // Polling check for late joiners
- updateState();
- const interval = setInterval(updateState, 1000);
- const timeout = setTimeout(() => clearInterval(interval), 10000); // Stop polling after 10s
-
- const onTrackUpdate = (p) => {
- // NO-OP, we use participant.on(...)
+ setTrack(pub?.track || null);
};
- const onLocalTrackPublished = (pub) => {
- console.log("[VoiceStage] Local Track Published:", pub.sid, pub.source);
- updateState(pub);
- };
-
- const onTrackUnpublished = (pub) => {
- // Clear persistence if SS is unpublished
- if (pub.source === 'screen_share' || pub.source === Track.Source.ScreenShare) {
- lastSSPub.current = null;
- }
- updateState();
- };
+ resolve();
+ const interval = setInterval(resolve, 1000);
+ const timeout = setTimeout(() => clearInterval(interval), 10000);
- participant.on(RoomEvent.TrackPublished, updateState);
- participant.on(RoomEvent.TrackUnpublished, onTrackUnpublished);
- participant.on(RoomEvent.TrackSubscribed, (_, pub) => updateState(pub));
- participant.on(RoomEvent.TrackUnsubscribed, (_, pub) => onTrackUnpublished(pub));
- participant.on(RoomEvent.TrackSubscriptionFailed, (sid) => {
- console.error(`[VoiceStage] Track Subscription Failed for ${sid}`);
- });
- participant.on(RoomEvent.TrackMuted, updateState);
- participant.on(RoomEvent.TrackUnmuted, updateState);
- participant.on(RoomEvent.IsSpeakingChanged, () => updateState());
-
- // LocalParticipant specific events
- participant.on('localTrackPublished', onLocalTrackPublished);
- participant.on('localTrackUnpublished', onTrackUnpublished);
+ const onPub = () => resolve();
+ const onUnpub = () => resolve();
+
+ participant.on(RoomEvent.TrackPublished, onPub);
+ participant.on(RoomEvent.TrackUnpublished, onUnpub);
+ participant.on(RoomEvent.TrackSubscribed, onPub);
+ participant.on(RoomEvent.TrackUnsubscribed, onUnpub);
+ participant.on(RoomEvent.TrackMuted, onPub);
+ participant.on(RoomEvent.TrackUnmuted, onPub);
+ participant.on('localTrackPublished', onPub);
+ participant.on('localTrackUnpublished', onUnpub);
return () => {
clearInterval(interval);
clearTimeout(timeout);
- participant.off(RoomEvent.TrackPublished, updateState);
- participant.off(RoomEvent.TrackUnpublished, onTrackUnpublished);
- participant.off(RoomEvent.TrackSubscribed, updateState);
- participant.off(RoomEvent.TrackUnsubscribed, onTrackUnpublished);
- participant.off(RoomEvent.TrackMuted, updateState);
- participant.off(RoomEvent.TrackUnmuted, updateState);
- participant.off(RoomEvent.IsSpeakingChanged, updateState);
-
- participant.off('localTrackPublished', onLocalTrackPublished);
- participant.off('localTrackUnpublished', onTrackUnpublished);
+ participant.off(RoomEvent.TrackPublished, onPub);
+ participant.off(RoomEvent.TrackUnpublished, onUnpub);
+ participant.off(RoomEvent.TrackSubscribed, onPub);
+ participant.off(RoomEvent.TrackUnsubscribed, onUnpub);
+ participant.off(RoomEvent.TrackMuted, onPub);
+ participant.off(RoomEvent.TrackUnmuted, onPub);
+ participant.off('localTrackPublished', onPub);
+ participant.off('localTrackUnpublished', onUnpub);
};
- }, [participant]);
-
- // ... (rest of ParticipantTile render)
+ }, [participant, source]);
+
+ return track;
+}
+
+// --- Components ---
+
+const ParticipantTile = ({ participant, username, avatarUrl }) => {
+ const cameraTrack = useParticipantTrack(participant, 'camera');
+ const isMicEnabled = participant.isMicrophoneEnabled;
const displayName = username || participant.identity;
return (
{
height: '100%',
aspectRatio: '16/9',
}}>
- {/* ... render logic same ... */}
- {videoTrack ? (
-
+ {cameraTrack ? (
+
) : (
-
- {getInitials(displayName)}
-
+
)}
@@ -283,29 +227,349 @@ const ParticipantTile = ({ participant, username }) => {
alignItems: 'center',
gap: '6px'
}}>
- {isMicEnabled ? '🎤' : '🔇'}
+ {isMicEnabled ? '\u{1F3A4}' : '\u{1F507}'}
{displayName}
);
};
+const StreamPreviewTile = ({ participant, username, onWatchStream }) => {
+ const displayName = username || participant.identity;
+ const [hover, setHover] = useState(false);
+
+ return (
+
setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ >
+ {/* Static preview — no video subscription */}
+
+
+
+
+ {/* Overlay */}
+
+
+
+
+ {/* Bottom label */}
+
+
+ {displayName}
+ LIVE
+
+
+
+ );
+};
+
+const ParticipantThumbnail = ({ participant, username, avatarUrl, isStreamer, isMuted }) => {
+ const cameraTrack = useParticipantTrack(participant, 'camera');
+ const displayName = username || participant.identity;
+
+ return (
+
+ {cameraTrack ? (
+
+ ) : (
+
+ )}
+
+ {/* Bottom label */}
+
+
+ {isMuted && {'\u{1F507}'}}
+ {displayName}
+ {isStreamer && LIVE}
+
+
+
+ );
+};
+
+const FocusedStreamView = ({
+ streamParticipant,
+ streamerUsername,
+ allParticipants,
+ getUsername,
+ getAvatarUrl,
+ participantsCollapsed,
+ onToggleCollapse,
+ onStopWatching,
+ streamingIdentities,
+ voiceUsers,
+ isTabVisible,
+}) => {
+ const screenTrack = useParticipantTrack(streamParticipant, 'screenshare');
+ const [barHover, setBarHover] = useState(false);
+ const [bottomEdgeHover, setBottomEdgeHover] = useState(false);
+
+ // Auto-exit if stream track disappears
+ useEffect(() => {
+ if (!streamParticipant) {
+ onStopWatching();
+ return;
+ }
+
+ const checkTrack = () => {
+ const { screenSharePub } = findTrackPubs(streamParticipant);
+ if (!screenSharePub || !screenSharePub.track) {
+ onStopWatching();
+ }
+ };
+
+ // Give a brief grace period for track to appear
+ const timeout = setTimeout(checkTrack, 3000);
+
+ streamParticipant.on(RoomEvent.TrackUnpublished, checkTrack);
+ streamParticipant.on('localTrackUnpublished', checkTrack);
+
+ return () => {
+ clearTimeout(timeout);
+ streamParticipant.off(RoomEvent.TrackUnpublished, checkTrack);
+ streamParticipant.off('localTrackUnpublished', checkTrack);
+ };
+ }, [streamParticipant, onStopWatching]);
+
+ return (
+
+ {/* Stream area */}
+
+ {screenTrack && isTabVisible ? (
+
+ ) : screenTrack && !isTabVisible ? (
+
+
+ Stream paused
+
+ ) : (
+
+ )}
+
+ {/* Top-left: streamer info */}
+
+
+ {streamerUsername}
+
+ LIVE
+
+
+ {/* Top-right: close button */}
+
+
+
+ {/* Bottom participants bar */}
+
setBarHover(true)}
+ onMouseLeave={() => setBarHover(false)}
+ >
+ {/* Collapse/expand toggle */}
+ {!participantsCollapsed && barHover && (
+
+ )}
+
+
+
+ {allParticipants.map(p => {
+ const uname = getUsername(p.identity);
+ const user = voiceUsers?.find(u => u.userId === p.identity);
+ return (
+
+ );
+ })}
+
+
+
+
+ {/* Expand trigger when collapsed */}
+ {participantsCollapsed && (
+
setBottomEdgeHover(true)}
+ onMouseLeave={() => setBottomEdgeHover(false)}
+ style={{
+ position: 'absolute', bottom: 0, left: 0, right: 0,
+ height: '24px', zIndex: 10,
+ display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
+ }}
+ >
+ {bottomEdgeHover && (
+
+ )}
+
+ )}
+
+ );
+};
+
+// --- Main Component ---
+
const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
const [participants, setParticipants] = useState([]);
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice } = useVoice();
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
const [isScreenShareActive, setIsScreenShareActive] = useState(false);
+ // Stream viewing state
+ const [watchingStreamOf, setWatchingStreamOf] = useState(null);
+ const [participantsCollapsed, setParticipantsCollapsed] = useState(false);
+
useEffect(() => {
if (!room) return;
const updateParticipants = () => {
const remote = Array.from(room.remoteParticipants.values());
- // Ensure Local is always there
const local = [room.localParticipant];
setParticipants([...local, ...remote]);
-
- // Update Screen Share State
setIsScreenShareActive(room.localParticipant.isScreenShareEnabled);
};
@@ -313,11 +577,8 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
room.on(RoomEvent.ParticipantConnected, updateParticipants);
room.on(RoomEvent.ParticipantDisconnected, updateParticipants);
-
- // Listen for local track updates on the Room/Participant to update button state
room.localParticipant.on('localTrackPublished', updateParticipants);
room.localParticipant.on('localTrackUnpublished', (pub) => {
- // Play stop sound if screen share
if (pub.source === Track.Source.ScreenShare || pub.source === 'screen_share') {
new Audio(screenShareStopSound).play();
}
@@ -328,12 +589,73 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
room.off(RoomEvent.ParticipantConnected, updateParticipants);
room.off(RoomEvent.ParticipantDisconnected, updateParticipants);
room.localParticipant.off('localTrackPublished', updateParticipants);
- room.localParticipant.off('localTrackUnpublished', updateParticipants); // Handlers must be same ref to unbound correctly, but here we use closures.
- // Ideally extract the handler to a const to unbound cleanly.
+ room.localParticipant.off('localTrackUnpublished', updateParticipants);
};
}, [room]);
- // Screen Share Handler (Reused logic)
+ // Reset watching state when room disconnects
+ useEffect(() => {
+ if (!room) {
+ setWatchingStreamOf(null);
+ setParticipantsCollapsed(false);
+ }
+ }, [room]);
+
+ // Manage screen share subscriptions — only subscribe when actively watching
+ useEffect(() => {
+ if (!room) return;
+
+ const manageSubscriptions = () => {
+ for (const p of room.remoteParticipants.values()) {
+ const { screenSharePub } = findTrackPubs(p);
+ if (!screenSharePub) continue;
+
+ const shouldSubscribe = watchingStreamOf === p.identity;
+ if (screenSharePub.isSubscribed !== shouldSubscribe) {
+ screenSharePub.setSubscribed(shouldSubscribe);
+ }
+ }
+ };
+
+ manageSubscriptions();
+
+ // Re-run when new tracks are published (autoSubscribe will subscribe them,
+ // so we need to immediately unsubscribe if not watching)
+ const onTrackChange = () => manageSubscriptions();
+ room.on(RoomEvent.TrackPublished, onTrackChange);
+ room.on(RoomEvent.TrackSubscribed, onTrackChange);
+
+ return () => {
+ room.off(RoomEvent.TrackPublished, onTrackChange);
+ room.off(RoomEvent.TrackSubscribed, onTrackChange);
+ };
+ }, [room, watchingStreamOf]);
+
+ // Derive streaming identities from voiceStates
+ const voiceUsers = voiceStates?.[channelId] || [];
+ const streamingIdentities = new Set(
+ voiceUsers.filter(u => u.isScreenSharing).map(u => u.userId)
+ );
+
+ // Auto-exit if watched participant stops streaming or disconnects
+ useEffect(() => {
+ if (watchingStreamOf === null) return;
+
+ const watchedStillStreaming = streamingIdentities.has(watchingStreamOf);
+ const watchedStillConnected = participants.some(p => p.identity === watchingStreamOf);
+
+ if (!watchedStillStreaming || !watchedStillConnected) {
+ setWatchingStreamOf(null);
+ setParticipantsCollapsed(false);
+ }
+ }, [watchingStreamOf, streamingIdentities, participants]);
+
+ const handleStopWatching = useCallback(() => {
+ setWatchingStreamOf(null);
+ setParticipantsCollapsed(false);
+ }, []);
+
+ // Screen Share Handler
const handleScreenShareSelect = async (selection) => {
if (!room) return;
try {
@@ -357,7 +679,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
maxWidth: 1920,
minHeight: 720,
maxHeight: 1080,
- maxFrameRate: 30 // Lower FPS to reduce "ProcessFrame failed" spam
+ maxFrameRate: 30
}
}
});
@@ -370,7 +692,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
source: Track.Source.ScreenShare
});
setScreenSharing(true);
-
+
track.onended = () => {
setScreenSharing(false);
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
@@ -383,37 +705,46 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
};
const handleScreenShareClick = () => {
- // Use local state for immediate feedback/toggle logic
- if (isScreenShareActive) {
+ if (isScreenShareActive) {
room.localParticipant.setScreenShareEnabled(false);
setScreenSharing(false);
- // Sound will play in unpublish handler
} else {
setIsScreenShareModalOpen(true);
}
};
-
- // Helper to find username
+
const getUsername = (identity) => {
- const users = voiceStates?.[channelId] || [];
- const user = users.find(u => u.userId === identity);
+ const user = voiceUsers.find(u => u.userId === identity);
return user ? user.username : identity;
};
+
+ const getAvatarUrl = (identity) => {
+ const user = voiceUsers.find(u => u.userId === identity);
+ return user?.avatarUrl || null;
+ };
+
+ // Pause local stream preview when tab is not visible to save CPU/GPU
+ const [isTabVisible, setIsTabVisible] = useState(!document.hidden);
+ useEffect(() => {
+ const handler = () => setIsTabVisible(!document.hidden);
+ document.addEventListener('visibilitychange', handler);
+ return () => document.removeEventListener('visibilitychange', handler);
+ }, []);
+
if (!room) {
return (
-
- {/* Background Gradient similar to "Spotlight" */}
{
opacity: 0.8,
zIndex: 0
}} />
-
- {/* Content */}
+
-
{channelName || 'Voice Channel'}
-
No one is currently in voice
-