feat: Introduce core sidebar component with user controls, voice state management, and update banner.
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-02-12 00:53:02 -06:00
parent 0da09ebb2f
commit 1952a1fedf
4 changed files with 611 additions and 235 deletions

View File

@@ -827,7 +827,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
return ( return (
<div style={{ marginLeft: 32, marginBottom: 8 }}> <div style={{ marginLeft: 32, marginBottom: 8 }}>
{users.map(user => ( {users.map(user => (
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}> <div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
<Avatar <Avatar
username={user.username} username={user.username}
size={24} size={24}
@@ -837,7 +837,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
}} }}
/> />
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.username}</span> <span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.username}</span>
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}> <div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center', marginRight: "16px" }}>
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>} {user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
{(user.isMuted || user.isDeafened) && ( {(user.isMuted || user.isDeafened) && (
<ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" /> <ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" />

View File

@@ -93,3 +93,4 @@ export function TitleBarUpdateIcon() {
</button> </button>
); );
} }

View File

@@ -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 { Track, RoomEvent } from 'livekit-client';
import { useVoice } from '../contexts/VoiceContext'; import { useVoice } from '../contexts/VoiceContext';
import ScreenShareModal from './ScreenShareModal'; import ScreenShareModal from './ScreenShareModal';
import Avatar from './Avatar';
// Icons // Icons
import muteIcon from '../assets/icons/mute.svg'; import muteIcon from '../assets/icons/mute.svg';
@@ -23,6 +24,20 @@ const getUserColor = (username) => {
return colors[Math.abs(hash) % colors.length]; 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) // Helper Component for coloring SVGs (Reused from Sidebar)
const ColoredIcon = ({ src, color, size = '24px' }) => ( const ColoredIcon = ({ src, color, size = '24px' }) => (
<div style={{ <div style={{
@@ -47,7 +62,7 @@ const ColoredIcon = ({ src, color, size = '24px' }) => (
</div> </div>
); );
const VideoRenderer = ({ track }) => { const VideoRenderer = ({ track, style }) => {
const videoRef = useRef(null); const videoRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -63,169 +78,108 @@ const VideoRenderer = ({ track }) => {
return ( return (
<video <video
ref={videoRef} ref={videoRef}
style={{ width: '100%', height: '100%', objectFit: 'contain', backgroundColor: 'black' }} style={{ width: '100%', height: '100%', objectFit: 'contain', backgroundColor: 'black', ...style }}
/> />
); );
}; };
// ... (VideoRenderer remains same) // --- Track Discovery Helpers ---
const ParticipantTile = ({ participant, username }) => { function findTrackPubs(participant) {
// Manually track subscriptions for this participant let cameraPub = null;
const [videoTrack, setVideoTrack] = useState(null); let screenSharePub = null;
const [isMicEnabled, setIsMicEnabled] = useState(participant.isMicrophoneEnabled);
// Persistence for SS track if getTracks is flaky const trackMap = participant.tracks || participant.trackPublications;
const lastSSPub = useRef(null); if (trackMap) {
for (const pub of trackMap.values()) {
const source = pub.source ? pub.source.toString().toLowerCase() : '';
const name = pub.trackName ? pub.trackName.toLowerCase() : '';
// Debug log if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
useEffect(() => { screenSharePub = pub;
if (videoTrack) console.log(`[VoiceStage] Rendering video for ${username || participant.identity} - Track: ${videoTrack.sid}`); } else if (source === 'camera' || name.includes('camera')) {
}, [videoTrack, username, participant.identity]); cameraPub = pub;
} else if (pub.kind === 'video' && !screenSharePub) {
useEffect(() => { screenSharePub = pub;
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 // Fallback for older SDKs
if ((!screenSharePub && !cameraPub) && participant.getTracks) { if ((!screenSharePub && !cameraPub) && participant.getTracks) {
console.log("[VoiceStage] Fallback: using getTracks()"); try {
try { for (const pub of participant.getTracks()) {
const tracks = participant.getTracks(); const source = pub.source ? pub.source.toString().toLowerCase() : '';
for (const pub of tracks) { const name = pub.trackName ? pub.trackName.toLowerCase() : '';
const source = pub.source ? pub.source.toString().toLowerCase() : 'unknown'; if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
const name = pub.trackName ? pub.trackName.toLowerCase() : ''; screenSharePub = pub;
} else if (source === 'camera' || name.includes('camera')) {
if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) { cameraPub = pub;
screenSharePub = pub; } else if (pub.kind === 'video' && !screenSharePub) {
} else if (source === 'camera' || name.includes('camera')) { screenSharePub = pub;
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;
}
// 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;
} }
} }
} catch (e) { /* ignore */ }
}
if (screenSharePub) { return { cameraPub, screenSharePub };
lastSSPub.current = 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 setTrack(pub?.track || null);
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 resolve();
updateState(); const interval = setInterval(resolve, 1000);
const interval = setInterval(updateState, 1000); const timeout = setTimeout(() => clearInterval(interval), 10000);
const timeout = setTimeout(() => clearInterval(interval), 10000); // Stop polling after 10s
const onTrackUpdate = (p) => { const onPub = () => resolve();
// NO-OP, we use participant.on(...) const onUnpub = () => resolve();
};
const onLocalTrackPublished = (pub) => { participant.on(RoomEvent.TrackPublished, onPub);
console.log("[VoiceStage] Local Track Published:", pub.sid, pub.source); participant.on(RoomEvent.TrackUnpublished, onUnpub);
updateState(pub); participant.on(RoomEvent.TrackSubscribed, onPub);
}; participant.on(RoomEvent.TrackUnsubscribed, onUnpub);
participant.on(RoomEvent.TrackMuted, onPub);
const onTrackUnpublished = (pub) => { participant.on(RoomEvent.TrackUnmuted, onPub);
// Clear persistence if SS is unpublished participant.on('localTrackPublished', onPub);
if (pub.source === 'screen_share' || pub.source === Track.Source.ScreenShare) { participant.on('localTrackUnpublished', onUnpub);
lastSSPub.current = null;
}
updateState();
};
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);
return () => { return () => {
clearInterval(interval); clearInterval(interval);
clearTimeout(timeout); clearTimeout(timeout);
participant.off(RoomEvent.TrackPublished, updateState); participant.off(RoomEvent.TrackPublished, onPub);
participant.off(RoomEvent.TrackUnpublished, onTrackUnpublished); participant.off(RoomEvent.TrackUnpublished, onUnpub);
participant.off(RoomEvent.TrackSubscribed, updateState); participant.off(RoomEvent.TrackSubscribed, onPub);
participant.off(RoomEvent.TrackUnsubscribed, onTrackUnpublished); participant.off(RoomEvent.TrackUnsubscribed, onUnpub);
participant.off(RoomEvent.TrackMuted, updateState); participant.off(RoomEvent.TrackMuted, onPub);
participant.off(RoomEvent.TrackUnmuted, updateState); participant.off(RoomEvent.TrackUnmuted, onPub);
participant.off(RoomEvent.IsSpeakingChanged, updateState); participant.off('localTrackPublished', onPub);
participant.off('localTrackUnpublished', onUnpub);
participant.off('localTrackPublished', onLocalTrackPublished);
participant.off('localTrackUnpublished', onTrackUnpublished);
}; };
}, [participant]); }, [participant, source]);
// ... (rest of ParticipantTile render) return track;
}
// --- Components ---
const ParticipantTile = ({ participant, username, avatarUrl }) => {
const cameraTrack = useParticipantTrack(participant, 'camera');
const isMicEnabled = participant.isMicrophoneEnabled;
const displayName = username || participant.identity; const displayName = username || participant.identity;
return ( return (
@@ -239,9 +193,8 @@ const ParticipantTile = ({ participant, username }) => {
height: '100%', height: '100%',
aspectRatio: '16/9', aspectRatio: '16/9',
}}> }}>
{/* ... render logic same ... */} {cameraTrack ? (
{videoTrack ? ( <VideoRenderer track={cameraTrack} />
<VideoRenderer track={videoTrack} />
) : ( ) : (
<div style={{ <div style={{
width: '100%', width: '100%',
@@ -252,21 +205,12 @@ const ParticipantTile = ({ participant, username }) => {
justifyContent: 'center', justifyContent: 'center',
flexDirection: 'column' flexDirection: 'column'
}}> }}>
<div style={{ <Avatar
width: '80px', username={displayName}
height: '80px', avatarUrl={avatarUrl}
borderRadius: '50%', size={80}
backgroundColor: getUserColor(displayName), style={{ boxShadow: '0 4px 10px rgba(0,0,0,0.3)' }}
display: 'flex', />
alignItems: 'center',
justifyContent: 'center',
fontSize: '32px',
color: 'white',
fontWeight: 'bold',
boxShadow: '0 4px 10px rgba(0,0,0,0.3)'
}}>
{getInitials(displayName)}
</div>
</div> </div>
)} )}
@@ -283,29 +227,349 @@ const ParticipantTile = ({ participant, username }) => {
alignItems: 'center', alignItems: 'center',
gap: '6px' gap: '6px'
}}> }}>
{isMicEnabled ? '🎤' : '🔇'} {isMicEnabled ? '\u{1F3A4}' : '\u{1F507}'}
{displayName} {displayName}
</div> </div>
</div> </div>
); );
}; };
const StreamPreviewTile = ({ participant, username, onWatchStream }) => {
const displayName = username || participant.identity;
const [hover, setHover] = useState(false);
return (
<div
style={{
backgroundColor: '#202225',
borderRadius: '8px',
overflow: 'hidden',
position: 'relative',
display: 'flex',
width: '100%',
height: '100%',
aspectRatio: '16/9',
cursor: 'pointer',
}}
onClick={onWatchStream}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
{/* Static preview — no video subscription */}
<div style={{
width: '100%', height: '100%', backgroundColor: '#2f3136',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ColoredIcon src={screenIcon} color="#72767d" size="48px" />
</div>
{/* Overlay */}
<div style={{
position: 'absolute', inset: 0,
backgroundColor: hover ? 'rgba(0,0,0,0.6)' : 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: '8px',
transition: 'background-color 0.15s',
}}>
<button
style={{
...WATCH_STREAM_BUTTON_STYLE,
transform: hover ? 'scale(1.05)' : 'scale(1)',
transition: 'transform 0.15s',
}}
onClick={(e) => { e.stopPropagation(); onWatchStream(); }}
>
Watch Stream
</button>
</div>
{/* Bottom label */}
<div style={{
position: 'absolute', bottom: '10px', left: '10px',
display: 'flex', alignItems: 'center', gap: '6px', zIndex: 2,
}}>
<div style={{
backgroundColor: 'rgba(0,0,0,0.6)', padding: '4px 8px',
borderRadius: '4px', color: 'white', fontSize: '14px',
display: 'flex', alignItems: 'center', gap: '6px',
}}>
{displayName}
<span style={LIVE_BADGE_STYLE}>LIVE</span>
</div>
</div>
</div>
);
};
const ParticipantThumbnail = ({ participant, username, avatarUrl, isStreamer, isMuted }) => {
const cameraTrack = useParticipantTrack(participant, 'camera');
const displayName = username || participant.identity;
return (
<div style={{
width: THUMBNAIL_SIZE.width,
height: THUMBNAIL_SIZE.height,
borderRadius: '6px',
overflow: 'hidden',
position: 'relative',
backgroundColor: '#2f3136',
flexShrink: 0,
}}>
{cameraTrack ? (
<VideoRenderer track={cameraTrack} style={{ objectFit: 'cover' }} />
) : (
<div style={{
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Avatar username={displayName} avatarUrl={avatarUrl} size={32} />
</div>
)}
{/* Bottom label */}
<div style={{
position: 'absolute', bottom: '2px', left: '2px', right: '2px',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '3px',
}}>
<span style={{
backgroundColor: 'rgba(0,0,0,0.7)', padding: '1px 4px',
borderRadius: '3px', color: 'white', fontSize: '10px',
maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '3px',
}}>
{isMuted && <span style={{ fontSize: '9px' }}>{'\u{1F507}'}</span>}
{displayName}
{isStreamer && <span style={{ ...LIVE_BADGE_STYLE, fontSize: '8px', padding: '1px 3px' }}>LIVE</span>}
</span>
</div>
</div>
);
};
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 (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, position: 'relative' }}>
{/* Stream area */}
<div style={{
flex: 1,
position: 'relative',
backgroundColor: 'black',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}>
{screenTrack && isTabVisible ? (
<VideoRenderer track={screenTrack} style={{ objectFit: 'contain' }} />
) : screenTrack && !isTabVisible ? (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px',
color: '#72767d',
}}>
<ColoredIcon src={screenIcon} color="#72767d" size="48px" />
<span style={{ fontSize: '14px' }}>Stream paused</span>
</div>
) : (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px',
color: '#72767d',
}}>
<div style={{
width: '48px', height: '48px', borderRadius: '50%',
border: '3px solid #72767d', display: 'flex',
alignItems: 'center', justifyContent: 'center',
animation: 'spin 1s linear infinite',
}}>
<div style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#72767d' }} />
</div>
<span style={{ fontSize: '14px' }}>Loading stream...</span>
</div>
)}
{/* Top-left: streamer info */}
<div style={{
position: 'absolute', top: '12px', left: '12px',
display: 'flex', alignItems: 'center', gap: '8px',
backgroundColor: 'rgba(0,0,0,0.6)', padding: '6px 10px',
borderRadius: '6px',
}}>
<span style={{ color: 'white', fontSize: '14px', fontWeight: '500' }}>
{streamerUsername}
</span>
<span style={LIVE_BADGE_STYLE}>LIVE</span>
</div>
{/* Top-right: close button */}
<button
onClick={onStopWatching}
title="Stop Watching"
style={{
position: 'absolute', top: '12px', right: '12px',
width: '32px', height: '32px', borderRadius: '50%',
backgroundColor: 'rgba(0,0,0,0.6)', border: 'none',
color: 'white', fontSize: '18px', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.6)'}
>
</button>
</div>
{/* Bottom participants bar */}
<div
style={{ position: 'relative' }}
onMouseEnter={() => setBarHover(true)}
onMouseLeave={() => setBarHover(false)}
>
{/* Collapse/expand toggle */}
{!participantsCollapsed && barHover && (
<button
onClick={onToggleCollapse}
title="Collapse participants"
style={{
position: 'absolute', top: '-16px', left: '50%',
transform: 'translateX(-50%)', zIndex: 10,
width: '32px', height: '16px', borderRadius: '8px 8px 0 0',
backgroundColor: 'rgba(47,49,54,0.9)', border: 'none',
color: '#b9bbbe', fontSize: '12px', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
</button>
)}
<div style={{
height: participantsCollapsed ? 0 : BOTTOM_BAR_HEIGHT,
overflow: 'hidden',
transition: 'height 0.25s ease',
backgroundColor: '#1e1f22',
borderTop: participantsCollapsed ? 'none' : '1px solid #2f3136',
}}>
<div style={{
height: BOTTOM_BAR_HEIGHT,
display: 'flex',
alignItems: 'center',
padding: '0 16px',
gap: '8px',
overflowX: 'auto',
overflowY: 'hidden',
}}>
{allParticipants.map(p => {
const uname = getUsername(p.identity);
const user = voiceUsers?.find(u => u.userId === p.identity);
return (
<ParticipantThumbnail
key={p.identity}
participant={p}
username={uname}
avatarUrl={getAvatarUrl(p.identity)}
isStreamer={streamingIdentities.has(p.identity)}
isMuted={user ? user.isMuted : !p.isMicrophoneEnabled}
/>
);
})}
</div>
</div>
</div>
{/* Expand trigger when collapsed */}
{participantsCollapsed && (
<div
onMouseEnter={() => 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 && (
<button
onClick={onToggleCollapse}
title="Show participants"
style={{
width: '32px', height: '16px', borderRadius: '8px 8px 0 0',
backgroundColor: 'rgba(47,49,54,0.9)', border: 'none',
color: '#b9bbbe', fontSize: '12px', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: '0',
}}
>
</button>
)}
</div>
)}
</div>
);
};
// --- Main Component ---
const VoiceStage = ({ room, channelId, voiceStates, channelName }) => { const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
const [participants, setParticipants] = useState([]); const [participants, setParticipants] = useState([]);
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice } = useVoice(); const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice } = useVoice();
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false); const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
const [isScreenShareActive, setIsScreenShareActive] = useState(false); const [isScreenShareActive, setIsScreenShareActive] = useState(false);
// Stream viewing state
const [watchingStreamOf, setWatchingStreamOf] = useState(null);
const [participantsCollapsed, setParticipantsCollapsed] = useState(false);
useEffect(() => { useEffect(() => {
if (!room) return; if (!room) return;
const updateParticipants = () => { const updateParticipants = () => {
const remote = Array.from(room.remoteParticipants.values()); const remote = Array.from(room.remoteParticipants.values());
// Ensure Local is always there
const local = [room.localParticipant]; const local = [room.localParticipant];
setParticipants([...local, ...remote]); setParticipants([...local, ...remote]);
// Update Screen Share State
setIsScreenShareActive(room.localParticipant.isScreenShareEnabled); setIsScreenShareActive(room.localParticipant.isScreenShareEnabled);
}; };
@@ -313,11 +577,8 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
room.on(RoomEvent.ParticipantConnected, updateParticipants); room.on(RoomEvent.ParticipantConnected, updateParticipants);
room.on(RoomEvent.ParticipantDisconnected, 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('localTrackPublished', updateParticipants);
room.localParticipant.on('localTrackUnpublished', (pub) => { room.localParticipant.on('localTrackUnpublished', (pub) => {
// Play stop sound if screen share
if (pub.source === Track.Source.ScreenShare || pub.source === 'screen_share') { if (pub.source === Track.Source.ScreenShare || pub.source === 'screen_share') {
new Audio(screenShareStopSound).play(); new Audio(screenShareStopSound).play();
} }
@@ -328,12 +589,73 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
room.off(RoomEvent.ParticipantConnected, updateParticipants); room.off(RoomEvent.ParticipantConnected, updateParticipants);
room.off(RoomEvent.ParticipantDisconnected, updateParticipants); room.off(RoomEvent.ParticipantDisconnected, updateParticipants);
room.localParticipant.off('localTrackPublished', updateParticipants); room.localParticipant.off('localTrackPublished', updateParticipants);
room.localParticipant.off('localTrackUnpublished', updateParticipants); // Handlers must be same ref to unbound correctly, but here we use closures. room.localParticipant.off('localTrackUnpublished', updateParticipants);
// Ideally extract the handler to a const to unbound cleanly.
}; };
}, [room]); }, [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) => { const handleScreenShareSelect = async (selection) => {
if (!room) return; if (!room) return;
try { try {
@@ -357,7 +679,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
maxWidth: 1920, maxWidth: 1920,
minHeight: 720, minHeight: 720,
maxHeight: 1080, maxHeight: 1080,
maxFrameRate: 30 // Lower FPS to reduce "ProcessFrame failed" spam maxFrameRate: 30
} }
} }
}); });
@@ -383,22 +705,32 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
}; };
const handleScreenShareClick = () => { const handleScreenShareClick = () => {
// Use local state for immediate feedback/toggle logic
if (isScreenShareActive) { if (isScreenShareActive) {
room.localParticipant.setScreenShareEnabled(false); room.localParticipant.setScreenShareEnabled(false);
setScreenSharing(false); setScreenSharing(false);
// Sound will play in unpublish handler
} else { } else {
setIsScreenShareModalOpen(true); setIsScreenShareModalOpen(true);
} }
}; };
// Helper to find username
const getUsername = (identity) => { const getUsername = (identity) => {
const users = voiceStates?.[channelId] || []; const user = voiceUsers.find(u => u.userId === identity);
const user = users.find(u => u.userId === identity);
return user ? user.username : 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) { if (!room) {
return ( return (
<div style={{ <div style={{
@@ -413,7 +745,6 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
position: 'relative', position: 'relative',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
{/* Background Gradient similar to "Spotlight" */}
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
top: '50%', top: '50%',
@@ -426,7 +757,6 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
zIndex: 0 zIndex: 0
}} /> }} />
{/* Content */}
<div style={{ zIndex: 1, textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center' }}> <div style={{ zIndex: 1, textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<h2 style={{ <h2 style={{
color: 'white', color: 'white',
@@ -468,25 +798,63 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
} }
const isCameraOn = room.localParticipant.isCameraEnabled; const isCameraOn = room.localParticipant.isCameraEnabled;
// use state now
const isScreenShareOn = isScreenShareActive; const isScreenShareOn = isScreenShareActive;
// Find the participant being watched
const watchedParticipant = watchingStreamOf
? participants.find(p => p.identity === watchingStreamOf)
: null;
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', flex: 1, backgroundColor: 'black', width: '100%' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', flex: 1, backgroundColor: 'black', width: '100%' }}>
{/* Grid */} {watchingStreamOf && watchedParticipant ? (
<div style={{ /* Focused/Fullscreen View */
flex: 1, <FocusedStreamView
padding: '16px', streamParticipant={watchedParticipant}
display: 'grid', streamerUsername={getUsername(watchingStreamOf)}
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', allParticipants={participants}
gap: '16px', getUsername={getUsername}
overflowY: 'auto', getAvatarUrl={getAvatarUrl}
alignContent: 'start' participantsCollapsed={participantsCollapsed}
}}> onToggleCollapse={() => setParticipantsCollapsed(c => !c)}
{participants.map(p => ( onStopWatching={handleStopWatching}
<ParticipantTile key={p.identity} participant={p} username={getUsername(p.identity)} /> streamingIdentities={streamingIdentities}
))} voiceUsers={voiceUsers}
</div> isTabVisible={isTabVisible}
/>
) : (
/* Grid View */
<div style={{
flex: 1,
padding: '16px',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '16px',
overflowY: 'auto',
alignContent: 'start'
}}>
{participants.map(p => {
const isStreaming = streamingIdentities.has(p.identity);
return (
<React.Fragment key={p.identity}>
<ParticipantTile
participant={p}
username={getUsername(p.identity)}
avatarUrl={getAvatarUrl(p.identity)}
/>
{isStreaming && (
<StreamPreviewTile
key={`stream-${p.identity}`}
participant={p}
username={getUsername(p.identity)}
onWatchStream={() => setWatchingStreamOf(p.identity)}
/>
)}
</React.Fragment>
);
})}
</div>
)}
{/* Controls */} {/* Controls */}
<div style={{ <div style={{
@@ -560,7 +928,6 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
onSelectSource={handleScreenShareSelect} onSelectSource={handleScreenShareSelect}
/> />
)} )}
</div> </div>
); );
}; };

View File

@@ -85,15 +85,23 @@ export const getAll = query({
isMuted: boolean; isMuted: boolean;
isDeafened: boolean; isDeafened: boolean;
isScreenSharing: boolean; isScreenSharing: boolean;
avatarUrl: string | null;
}>> = {}; }>> = {};
for (const s of states) { 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({ (grouped[s.channelId] ??= []).push({
userId: s.userId, userId: s.userId,
username: s.username, username: s.username,
isMuted: s.isMuted, isMuted: s.isMuted,
isDeafened: s.isDeafened, isDeafened: s.isDeafened,
isScreenSharing: s.isScreenSharing, isScreenSharing: s.isScreenSharing,
avatarUrl,
}); });
} }