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 (
|
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" />
|
||||||
|
|||||||
@@ -93,3 +93,4 @@ export function TitleBarUpdateIcon() {
|
|||||||
</button>
|
</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 { 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user