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 (
<div style={{ marginLeft: 32, marginBottom: 8 }}>
{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
username={user.username}
size={24}
@@ -837,7 +837,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
}}
/>
<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.isMuted || user.isDeafened) && (
<ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" />

View File

@@ -93,3 +93,4 @@ export function TitleBarUpdateIcon() {
</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 { 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' }) => (
<div style={{
@@ -47,7 +62,7 @@ const ColoredIcon = ({ src, color, size = '24px' }) => (
</div>
);
const VideoRenderer = ({ track }) => {
const VideoRenderer = ({ track, style }) => {
const videoRef = useRef(null);
useEffect(() => {
@@ -63,169 +78,108 @@ const VideoRenderer = ({ track }) => {
return (
<video
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 }) => {
// Manually track subscriptions for this participant
const [videoTrack, setVideoTrack] = useState(null);
const [isMicEnabled, setIsMicEnabled] = useState(participant.isMicrophoneEnabled);
function findTrackPubs(participant) {
let cameraPub = null;
let screenSharePub = null;
// Persistence for SS track if getTracks is flaky
const lastSSPub = useRef(null);
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() : '';
// Debug log
useEffect(() => {
if (videoTrack) console.log(`[VoiceStage] Rendering video for ${username || participant.identity} - Track: ${videoTrack.sid}`);
}, [videoTrack, username, participant.identity]);
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;
}
}
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 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;
}
// 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;
}
}
} catch (e) { /* ignore */ }
}
if (screenSharePub) {
lastSSPub.current = screenSharePub;
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);
setTrack(pub?.track || null);
};
// Polling check for late joiners
updateState();
const interval = setInterval(updateState, 1000);
const timeout = setTimeout(() => clearInterval(interval), 10000); // Stop polling after 10s
resolve();
const interval = setInterval(resolve, 1000);
const timeout = setTimeout(() => clearInterval(interval), 10000);
const onTrackUpdate = (p) => {
// NO-OP, we use participant.on(...)
};
const onPub = () => resolve();
const onUnpub = () => resolve();
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();
};
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);
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]);
}, [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;
return (
@@ -239,9 +193,8 @@ const ParticipantTile = ({ participant, username }) => {
height: '100%',
aspectRatio: '16/9',
}}>
{/* ... render logic same ... */}
{videoTrack ? (
<VideoRenderer track={videoTrack} />
{cameraTrack ? (
<VideoRenderer track={cameraTrack} />
) : (
<div style={{
width: '100%',
@@ -252,21 +205,12 @@ const ParticipantTile = ({ participant, username }) => {
justifyContent: 'center',
flexDirection: 'column'
}}>
<div style={{
width: '80px',
height: '80px',
borderRadius: '50%',
backgroundColor: getUserColor(displayName),
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>
<Avatar
username={displayName}
avatarUrl={avatarUrl}
size={80}
style={{ boxShadow: '0 4px 10px rgba(0,0,0,0.3)' }}
/>
</div>
)}
@@ -283,29 +227,349 @@ const ParticipantTile = ({ participant, username }) => {
alignItems: 'center',
gap: '6px'
}}>
{isMicEnabled ? '🎤' : '🔇'}
{isMicEnabled ? '\u{1F3A4}' : '\u{1F507}'}
{displayName}
</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 [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
}
}
});
@@ -383,22 +705,32 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
};
const handleScreenShareClick = () => {
// Use local state for immediate feedback/toggle logic
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 (
<div style={{
@@ -413,7 +745,6 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
position: 'relative',
overflow: 'hidden'
}}>
{/* Background Gradient similar to "Spotlight" */}
<div style={{
position: 'absolute',
top: '50%',
@@ -426,7 +757,6 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
zIndex: 0
}} />
{/* Content */}
<div style={{ zIndex: 1, textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<h2 style={{
color: 'white',
@@ -468,25 +798,63 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
}
const isCameraOn = room.localParticipant.isCameraEnabled;
// use state now
const isScreenShareOn = isScreenShareActive;
// Find the participant being watched
const watchedParticipant = watchingStreamOf
? participants.find(p => p.identity === watchingStreamOf)
: null;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', flex: 1, backgroundColor: 'black', width: '100%' }}>
{/* Grid */}
<div style={{
flex: 1,
padding: '16px',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '16px',
overflowY: 'auto',
alignContent: 'start'
}}>
{participants.map(p => (
<ParticipantTile key={p.identity} participant={p} username={getUsername(p.identity)} />
))}
</div>
{watchingStreamOf && watchedParticipant ? (
/* Focused/Fullscreen View */
<FocusedStreamView
streamParticipant={watchedParticipant}
streamerUsername={getUsername(watchingStreamOf)}
allParticipants={participants}
getUsername={getUsername}
getAvatarUrl={getAvatarUrl}
participantsCollapsed={participantsCollapsed}
onToggleCollapse={() => setParticipantsCollapsed(c => !c)}
onStopWatching={handleStopWatching}
streamingIdentities={streamingIdentities}
voiceUsers={voiceUsers}
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 */}
<div style={{
@@ -560,7 +928,6 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
onSelectSource={handleScreenShareSelect}
/>
)}
</div>
);
};

View File

@@ -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,
});
}