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
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -93,3 +93,4 @@ export function TitleBarUpdateIcon() {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
// Persistence for SS track if getTracks is flaky
|
||||
const lastSSPub = useRef(null);
|
||||
|
||||
// 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;
|
||||
function findTrackPubs(participant) {
|
||||
let cameraPub = null;
|
||||
let otherVideoPub = null;
|
||||
let screenSharePub = 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 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') {
|
||||
otherVideoPub = pub;
|
||||
} else if (pub.kind === 'video' && !screenSharePub) {
|
||||
screenSharePub = 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;
|
||||
}
|
||||
}
|
||||
|
||||
if (screenSharePub) {
|
||||
lastSSPub.current = screenSharePub;
|
||||
// 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 */ }
|
||||
}
|
||||
|
||||
// Prioritize Screen share
|
||||
let track = null;
|
||||
if (screenSharePub) {
|
||||
if (screenSharePub.track) {
|
||||
track = screenSharePub.track;
|
||||
}
|
||||
return { cameraPub, screenSharePub };
|
||||
}
|
||||
|
||||
if (!track && cameraPub && cameraPub.track) {
|
||||
track = cameraPub.track;
|
||||
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);
|
||||
}
|
||||
|
||||
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,12 +798,32 @@ 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 */}
|
||||
{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',
|
||||
@@ -483,10 +833,28 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
||||
overflowY: 'auto',
|
||||
alignContent: 'start'
|
||||
}}>
|
||||
{participants.map(p => (
|
||||
<ParticipantTile key={p.identity} participant={p} username={getUsername(p.identity)} />
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user