diff --git a/Frontend/Electron/src/components/Sidebar.jsx b/Frontend/Electron/src/components/Sidebar.jsx index 8a0cfdc..28d69bd 100644 --- a/Frontend/Electron/src/components/Sidebar.jsx +++ b/Frontend/Electron/src/components/Sidebar.jsx @@ -827,7 +827,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam return (
{users.map(user => ( -
+
{user.username} -
+
{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 ( -
); }; +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 +
+ ) : ( +
+
+
+
+ Loading stream... +
+ )} + + {/* 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

- - - -
{isScreenShareModalOpen && ( - setIsScreenShareModalOpen(false)} onSelectSource={handleScreenShareSelect} /> )} -
); }; diff --git a/convex/voiceState.ts b/convex/voiceState.ts index ebad364..d0731e3 100644 --- a/convex/voiceState.ts +++ b/convex/voiceState.ts @@ -85,15 +85,23 @@ export const getAll = query({ isMuted: boolean; isDeafened: boolean; isScreenSharing: boolean; + avatarUrl: string | null; }>> = {}; for (const s of states) { + const user = await ctx.db.get(s.userId); + let avatarUrl: string | null = null; + if (user?.avatarStorageId) { + avatarUrl = await ctx.storage.getUrl(user.avatarStorageId); + } + (grouped[s.channelId] ??= []).push({ userId: s.userId, username: s.username, isMuted: s.isMuted, isDeafened: s.isDeafened, isScreenSharing: s.isScreenSharing, + avatarUrl, }); }