diff --git a/packages/web/src/components/voice/PictureInPicture.js b/packages/web/src/components/voice/PictureInPicture.js index 7b9fd027..91e47d35 100644 --- a/packages/web/src/components/voice/PictureInPicture.js +++ b/packages/web/src/components/voice/PictureInPicture.js @@ -93,6 +93,7 @@ export function PictureInPicture() { return 'Call'; }, [currentVoiceChannelId, channels]); // Video track attachment + // shouldShow in deps ensures re-run when PiP becomes visible (videoRef was null before) useEffect(() => { const videoEl = videoRef.current; if (!videoEl) @@ -103,7 +104,7 @@ export function PictureInPicture() { else { videoEl.srcObject = null; } - }, [selectedStream?.track]); + }, [selectedStream?.track, shouldShow]); // Initialize position to bottom-right useEffect(() => { if (shouldShow && position.x === -1) { diff --git a/packages/web/src/components/voice/PictureInPicture.tsx b/packages/web/src/components/voice/PictureInPicture.tsx index 56160b12..ad2cb7ef 100644 --- a/packages/web/src/components/voice/PictureInPicture.tsx +++ b/packages/web/src/components/voice/PictureInPicture.tsx @@ -118,6 +118,7 @@ export function PictureInPicture() { }, [currentVoiceChannelId, channels]); // Video track attachment + // shouldShow in deps ensures re-run when PiP becomes visible (videoRef was null before) useEffect(() => { const videoEl = videoRef.current; if (!videoEl) return; @@ -126,7 +127,7 @@ export function PictureInPicture() { } else { videoEl.srcObject = null; } - }, [selectedStream?.track]); + }, [selectedStream?.track, shouldShow]); // Initialize position to bottom-right useEffect(() => { diff --git a/packages/web/src/components/voice/VoiceGrid.js b/packages/web/src/components/voice/VoiceGrid.js index c337283d..ae7f1d51 100644 --- a/packages/web/src/components/voice/VoiceGrid.js +++ b/packages/web/src/components/voice/VoiceGrid.js @@ -1,19 +1,37 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useEffect, useRef } from 'react'; import { VoiceUser } from './VoiceUser'; import { useVoiceStore } from '../../stores/voiceStore'; export function VoiceGrid({ participants }) { const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId); const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant); + const prevScreenSharerRef = useRef(null); + // Auto-focus when someone starts screen sharing + useEffect(() => { + const screenSharer = participants.find((p) => p.screenTrack?.readyState === 'live'); + const screenSharerId = screenSharer?.identity ?? null; + if (screenSharerId && screenSharerId !== prevScreenSharerRef.current) { + // New screen share started — auto-focus + setFocusedParticipant(screenSharerId); + } + else if (!screenSharerId && prevScreenSharerRef.current) { + // Screen share ended — unfocus if we were focused on the sharer + if (focusedParticipantId === prevScreenSharerRef.current) { + setFocusedParticipant(null); + } + } + prevScreenSharerRef.current = screenSharerId; + }, [participants, focusedParticipantId, setFocusedParticipant]); if (participants.length === 0) { - return (_jsx("div", { className: "flex-1 flex items-center justify-center text-discord-text-muted", children: _jsx("p", { children: "No one is in this voice channel" }) })); + return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsxs("div", { className: "text-center", children: [_jsx("svg", { width: "48", height: "48", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted/40 mx-auto mb-3", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("p", { className: "text-discord-text-muted text-sm", children: "Waiting for others to join..." })] }) })); } const focusedParticipant = focusedParticipantId - ? participants.find(p => p.identity === focusedParticipantId) + ? participants.find((p) => p.identity === focusedParticipantId) : null; // Focus mode: one large tile + sidebar strip if (focusedParticipant) { - const otherParticipants = participants.filter(p => p.identity !== focusedParticipantId); - return (_jsxs("div", { className: "flex-1 flex overflow-hidden", children: [_jsx("div", { className: "flex-1 p-2", onDoubleClick: () => setFocusedParticipant(null), children: _jsx(VoiceUser, { participant: focusedParticipant, large: true }) }), otherParticipants.length > 0 && (_jsx("div", { className: "w-[200px] flex-shrink-0 overflow-y-auto p-2 space-y-2", children: otherParticipants.map((p) => (_jsx("div", { onClick: () => setFocusedParticipant(p.identity), className: "cursor-pointer", children: _jsx(VoiceUser, { participant: p }) }, p.identity))) }))] })); + const otherParticipants = participants.filter((p) => p.identity !== focusedParticipantId); + return (_jsxs("div", { className: "flex-1 flex overflow-hidden", children: [_jsxs("div", { className: "flex-1 p-2 relative", children: [_jsx(VoiceUser, { participant: focusedParticipant, large: true }), _jsxs("button", { onClick: () => setFocusedParticipant(null), className: "absolute top-4 right-4 z-10 px-3 py-1.5 bg-black/60 hover:bg-black/80 rounded-lg flex items-center gap-2 text-white/70 hover:text-white transition-colors", title: "Back to grid view", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" }) }), _jsx("span", { className: "text-xs font-medium", children: "Grid" })] })] }), otherParticipants.length > 0 && (_jsx("div", { className: "w-[200px] flex-shrink-0 overflow-y-auto p-2 space-y-2 bg-[#111214]/50", children: otherParticipants.map((p) => (_jsx("div", { onClick: () => setFocusedParticipant(p.identity), className: "cursor-pointer hover:opacity-80 transition-opacity", children: _jsx(VoiceUser, { participant: p }) }, p.identity))) }))] })); } // Default grid mode const gridClass = (() => { @@ -27,5 +45,5 @@ export function VoiceGrid({ participants }) { return 'grid-cols-3'; return 'grid-cols-4'; })(); - return (_jsx("div", { className: "flex-1 p-4 overflow-auto", children: _jsx("div", { className: `grid ${gridClass} gap-2 h-full`, children: participants.map((p) => (_jsx("div", { onClick: () => setFocusedParticipant(p.identity), className: "cursor-pointer", children: _jsx(VoiceUser, { participant: p }) }, p.identity))) }) })); + return (_jsx("div", { className: "flex-1 p-3 overflow-auto flex items-center", children: _jsx("div", { className: `grid ${gridClass} gap-2 w-full`, children: participants.map((p) => (_jsx("div", { onClick: () => setFocusedParticipant(p.identity), className: "cursor-pointer hover:opacity-90 transition-opacity", children: _jsx(VoiceUser, { participant: p }) }, p.identity))) }) })); } diff --git a/packages/web/src/components/voice/VoiceGrid.tsx b/packages/web/src/components/voice/VoiceGrid.tsx index 75065aef..26825209 100644 --- a/packages/web/src/components/voice/VoiceGrid.tsx +++ b/packages/web/src/components/voice/VoiceGrid.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { VoiceUser } from './VoiceUser'; import { useVoiceStore } from '../../stores/voiceStore'; import type { ParticipantInfo } from '../../hooks/useLiveKit'; @@ -10,40 +10,83 @@ interface VoiceGridProps { export function VoiceGrid({ participants }: VoiceGridProps) { const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId); const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant); + const prevScreenSharerRef = useRef(null); + + // Auto-focus when someone starts screen sharing + useEffect(() => { + const screenSharer = participants.find( + (p) => p.screenTrack?.readyState === 'live', + ); + const screenSharerId = screenSharer?.identity ?? null; + + if (screenSharerId && screenSharerId !== prevScreenSharerRef.current) { + // New screen share started — auto-focus + setFocusedParticipant(screenSharerId); + } else if (!screenSharerId && prevScreenSharerRef.current) { + // Screen share ended — unfocus if we were focused on the sharer + if (focusedParticipantId === prevScreenSharerRef.current) { + setFocusedParticipant(null); + } + } + prevScreenSharerRef.current = screenSharerId; + }, [participants, focusedParticipantId, setFocusedParticipant]); if (participants.length === 0) { return ( -
-

No one is in this voice channel

+
+
+ + + +

+ Waiting for others to join... +

+
); } const focusedParticipant = focusedParticipantId - ? participants.find(p => p.identity === focusedParticipantId) + ? participants.find((p) => p.identity === focusedParticipantId) : null; // Focus mode: one large tile + sidebar strip if (focusedParticipant) { - const otherParticipants = participants.filter(p => p.identity !== focusedParticipantId); + const otherParticipants = participants.filter( + (p) => p.identity !== focusedParticipantId, + ); return (
{/* Main focused view */} -
setFocusedParticipant(null)} - > +
+ {/* Back to grid button */} +
{/* Side strip of other participants */} {otherParticipants.length > 0 && ( -
+
{otherParticipants.map((p) => (
setFocusedParticipant(p.identity)} - className="cursor-pointer" + className="cursor-pointer hover:opacity-80 transition-opacity" >
@@ -64,13 +107,13 @@ export function VoiceGrid({ participants }: VoiceGridProps) { })(); return ( -
-
+
+
{participants.map((p) => (
setFocusedParticipant(p.identity)} - className="cursor-pointer" + className="cursor-pointer hover:opacity-90 transition-opacity" >
diff --git a/packages/web/src/components/voice/VoiceUser.js b/packages/web/src/components/voice/VoiceUser.js index 2b4a58c3..127f901d 100644 --- a/packages/web/src/components/voice/VoiceUser.js +++ b/packages/web/src/components/voice/VoiceUser.js @@ -8,26 +8,42 @@ export function VoiceUser({ participant, large }) { const isDeafened = useVoiceStore((s) => s.isDeafened); const outputVolume = useVoiceStore((s) => s.outputVolume); const participantVolumes = useVoiceStore((s) => s.participantVolumes); + const [, forceUpdate] = useState(0); const perUserVolume = participantVolumes.get(participant.userId) ?? 100; + const isLocal = participant.isLocal; + // Determine active video track — prioritize screen share, check readyState + const liveScreen = participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null; + const liveCamera = participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null; + const activeVideoTrack = liveScreen ?? liveCamera; + const hasVideo = activeVideoTrack !== null; + const isScreenShare = liveScreen !== null; + // Listen for track 'ended' events to force re-render when a stream stops + useEffect(() => { + const tracks = [participant.videoTrack, participant.screenTrack].filter((t) => t !== null); + if (tracks.length === 0) + return; + const onEnded = () => forceUpdate((n) => n + 1); + tracks.forEach((t) => t.addEventListener('ended', onEnded)); + return () => tracks.forEach((t) => t.removeEventListener('ended', onEnded)); + }, [participant.videoTrack, participant.screenTrack]); + // Attach video track useEffect(() => { const videoEl = videoRef.current; if (!videoEl) return; - const track = participant.videoTrack ?? participant.screenTrack; - if (track) { - const stream = new MediaStream([track]); - videoEl.srcObject = stream; + if (activeVideoTrack) { + videoEl.srcObject = new MediaStream([activeVideoTrack]); } else { videoEl.srcObject = null; } - }, [participant.videoTrack, participant.screenTrack]); + }, [activeVideoTrack]); + // Attach audio track useEffect(() => { const audioEl = audioRef.current; if (!audioEl || !participant.audioTrack) return; - const stream = new MediaStream([participant.audioTrack]); - audioEl.srcObject = stream; + audioEl.srcObject = new MediaStream([participant.audioTrack]); }, [participant.audioTrack]); // Apply volume: combine outputVolume and per-participant volume, or mute if deafened useEffect(() => { @@ -36,26 +52,23 @@ export function VoiceUser({ participant, large }) { return; if (isDeafened) { audioEl.volume = 0; + audioEl.muted = true; } else { - // Both are 0-200 scale with 100 = default. Combine as fractions. const combined = (outputVolume / 100) * (perUserVolume / 100); audioEl.volume = Math.min(Math.max(combined, 0), 1); + audioEl.muted = false; } - audioEl.muted = isDeafened; }, [isDeafened, outputVolume, perUserVolume]); - const hasVideo = !!(participant.videoTrack || participant.screenTrack); - const isLocal = participant.isLocal; // Volume context menu const [volumeMenu, setVolumeMenu] = useState(null); const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume); const handleContextMenu = useCallback((e) => { if (isLocal) - return; // No volume control for self + return; e.preventDefault(); setVolumeMenu({ x: e.clientX, y: e.clientY }); }, [isLocal]); - // Close volume menu on click outside useEffect(() => { if (!volumeMenu) return; @@ -63,8 +76,7 @@ export function VoiceUser({ participant, large }) { window.addEventListener('click', close); return () => window.removeEventListener('click', close); }, [volumeMenu]); - return (_jsxs("div", { className: `relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center transition-all ${participant.isSpeaking ? 'ring-[3px] ring-discord-green' : 'ring-1 ring-transparent'} ${large ? 'h-full' : ''}`, style: large ? undefined : { aspectRatio: '16/9', minHeight: '140px' }, onContextMenu: handleContextMenu, children: [!isLocal && _jsx("audio", { ref: audioRef, autoPlay: true }), hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: `w-full h-full ${large ? 'object-contain' : 'object-cover'}`, style: { - imageRendering: 'crisp-edges', - WebkitFontSmoothing: 'antialiased' - } })) : (_jsx("div", { className: "flex flex-col items-center justify-center gap-2", children: _jsx(Avatar, { src: null, name: participant.username, size: large ? 100 : 80 }) })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: `font-medium text-white ${large ? 'text-base' : 'text-sm'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/50 font-medium", children: "(you)" }))] }), _jsxs("div", { className: "flex items-center gap-1", children: [participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })), participant.isScreenSharing && (_jsx("div", { className: "w-5 h-5 bg-discord-blurple/80 rounded-full flex items-center justify-center", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: _jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20Z" }) }) }))] })] }) }), volumeMenu && !isLocal && (_jsxs("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px]", style: { left: volumeMenu.x, top: volumeMenu.y }, onClick: (e) => e.stopPropagation(), children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider", children: "User Volume" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M3 9v6h4l5 5V4L7 9H3z" }) }), _jsx("input", { type: "range", min: "0", max: "200", value: perUserVolume, onChange: (e) => setParticipantVolume(participant.userId, parseInt(e.target.value)), className: "flex-1 accent-discord-blurple h-1" }), _jsxs("span", { className: "text-xs text-discord-text-secondary min-w-[32px] text-right", children: [perUserVolume, "%"] })] })] }))] })); + return (_jsxs("div", { className: `relative bg-[#1e1f22] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${participant.isSpeaking + ? 'ring-[3px] ring-discord-green shadow-[0_0_12px_rgba(35,165,90,0.25)]' + : 'ring-1 ring-white/[0.06] hover:ring-white/10'} ${large ? 'h-full' : ''}`, style: large ? undefined : { aspectRatio: '16/9', minHeight: '140px' }, onContextMenu: handleContextMenu, children: [!isLocal && _jsx("audio", { ref: audioRef, autoPlay: true }), hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: `w-full h-full ${large || isScreenShare ? 'object-contain bg-black' : 'object-cover'}` })) : (_jsx("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#2b2d31]", children: _jsxs("div", { className: "relative", children: [_jsx(Avatar, { src: null, name: participant.username, size: large ? 100 : 64 }), participant.isSpeaking && (_jsx("div", { className: "absolute -inset-1.5 rounded-full ring-[3px] ring-discord-green animate-pulse" }))] }) })), isScreenShare && hasVideo && (_jsx("div", { className: "absolute top-2 left-2 px-1.5 py-0.5 bg-discord-red rounded text-[11px] font-bold text-white uppercase tracking-wide", children: "LIVE" })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_jsx("span", { className: `font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/40 font-medium", children: "(you)" }))] }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })), participant.isScreenSharing && !isScreenShare && (_jsx("div", { className: "w-5 h-5 bg-discord-blurple/90 rounded-full flex items-center justify-center", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: _jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20Z" }) }) }))] })] }) }), volumeMenu && !isLocal && (_jsxs("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px] border border-white/[0.06]", style: { left: volumeMenu.x, top: volumeMenu.y }, onClick: (e) => e.stopPropagation(), children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider", children: "User Volume" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M3 9v6h4l5 5V4L7 9H3z" }) }), _jsx("input", { type: "range", min: "0", max: "200", value: perUserVolume, onChange: (e) => setParticipantVolume(participant.userId, parseInt(e.target.value)), className: "flex-1 accent-discord-blurple h-1" }), _jsxs("span", { className: "text-xs text-discord-text-secondary min-w-[32px] text-right", children: [perUserVolume, "%"] })] })] }))] })); } diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx index 6f4282a5..b6c80de3 100644 --- a/packages/web/src/components/voice/VoiceUser.tsx +++ b/packages/web/src/components/voice/VoiceUser.tsx @@ -14,28 +14,45 @@ export function VoiceUser({ participant, large }: VoiceUserProps) { const isDeafened = useVoiceStore((s) => s.isDeafened); const outputVolume = useVoiceStore((s) => s.outputVolume); const participantVolumes = useVoiceStore((s) => s.participantVolumes); + const [, forceUpdate] = useState(0); const perUserVolume = participantVolumes.get(participant.userId) ?? 100; + const isLocal = participant.isLocal; + // Determine active video track — prioritize screen share, check readyState + const liveScreen = participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null; + const liveCamera = participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null; + const activeVideoTrack = liveScreen ?? liveCamera; + const hasVideo = activeVideoTrack !== null; + const isScreenShare = liveScreen !== null; + + // Listen for track 'ended' events to force re-render when a stream stops + useEffect(() => { + const tracks = [participant.videoTrack, participant.screenTrack].filter( + (t): t is MediaStreamTrack => t !== null, + ); + if (tracks.length === 0) return; + const onEnded = () => forceUpdate((n) => n + 1); + tracks.forEach((t) => t.addEventListener('ended', onEnded)); + return () => tracks.forEach((t) => t.removeEventListener('ended', onEnded)); + }, [participant.videoTrack, participant.screenTrack]); + + // Attach video track useEffect(() => { const videoEl = videoRef.current; if (!videoEl) return; - - const track = participant.videoTrack ?? participant.screenTrack; - if (track) { - const stream = new MediaStream([track]); - videoEl.srcObject = stream; + if (activeVideoTrack) { + videoEl.srcObject = new MediaStream([activeVideoTrack]); } else { videoEl.srcObject = null; } - }, [participant.videoTrack, participant.screenTrack]); + }, [activeVideoTrack]); + // Attach audio track useEffect(() => { const audioEl = audioRef.current; if (!audioEl || !participant.audioTrack) return; - - const stream = new MediaStream([participant.audioTrack]); - audioEl.srcObject = stream; + audioEl.srcObject = new MediaStream([participant.audioTrack]); }, [participant.audioTrack]); // Apply volume: combine outputVolume and per-participant volume, or mute if deafened @@ -44,28 +61,27 @@ export function VoiceUser({ participant, large }: VoiceUserProps) { if (!audioEl) return; if (isDeafened) { audioEl.volume = 0; + audioEl.muted = true; } else { - // Both are 0-200 scale with 100 = default. Combine as fractions. const combined = (outputVolume / 100) * (perUserVolume / 100); audioEl.volume = Math.min(Math.max(combined, 0), 1); + audioEl.muted = false; } - audioEl.muted = isDeafened; }, [isDeafened, outputVolume, perUserVolume]); - const hasVideo = !!(participant.videoTrack || participant.screenTrack); - const isLocal = participant.isLocal; - // Volume context menu const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null); const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume); - const handleContextMenu = useCallback((e: React.MouseEvent) => { - if (isLocal) return; // No volume control for self - e.preventDefault(); - setVolumeMenu({ x: e.clientX, y: e.clientY }); - }, [isLocal]); + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + if (isLocal) return; + e.preventDefault(); + setVolumeMenu({ x: e.clientX, y: e.clientY }); + }, + [isLocal], + ); - // Close volume menu on click outside useEffect(() => { if (!volumeMenu) return; const close = () => setVolumeMenu(null); @@ -75,8 +91,10 @@ export function VoiceUser({ participant, large }: VoiceUserProps) { return (
) : ( -
- +
+
+ + {participant.isSpeaking && ( +
+ )} +
+
+ )} + + {/* LIVE badge for screen shares */} + {isScreenShare && hasVideo && ( +
+ LIVE
)} {/* Bottom overlay */} -
+
-
- {participant.username} +
+ + {participant.username} + {isLocal && ( - (you) + (you) )}
-
+
{participant.isMuted && ( -
+
)} - {participant.isScreenSharing && ( -
+ {participant.isScreenSharing && !isScreenShare && ( +
@@ -138,7 +168,7 @@ export function VoiceUser({ participant, large }: VoiceUserProps) { {/* Per-participant volume menu (right-click) */} {volumeMenu && !isLocal && (
e.stopPropagation()} > @@ -146,7 +176,13 @@ export function VoiceUser({ participant, large }: VoiceUserProps) { User Volume
- + diff --git a/packages/web/src/hooks/useLiveKit.js b/packages/web/src/hooks/useLiveKit.js index 9c4d1c05..4431cc0d 100644 --- a/packages/web/src/hooks/useLiveKit.js +++ b/packages/web/src/hooks/useLiveKit.js @@ -81,12 +81,15 @@ export function useLiveKit() { const track = pub.track; if (!track) return; + const mt = track.mediaStreamTrack; + if (!mt || mt.readyState !== 'live') + return; if (pub.source === Track.Source.Microphone) - audioTrack = track.mediaStreamTrack; + audioTrack = mt; else if (pub.source === Track.Source.Camera) - videoTrack = track.mediaStreamTrack; + videoTrack = mt; else if (pub.source === Track.Source.ScreenShare) - screenTrack = track.mediaStreamTrack; + screenTrack = mt; }); allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: !p.isMicrophoneEnabled, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack }); }; @@ -122,6 +125,9 @@ export function useLiveKit() { newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate); newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate); newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate); + newRoom.on(RoomEvent.TrackMuted, guardedUpdate); + newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate); + newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate); newRoom.on(RoomEvent.ConnectionStateChanged, (state) => { if (roomRef.current === newRoom) { const connected = state === ConnectionState.Connected; @@ -192,6 +198,9 @@ export function useLiveKit() { newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate); newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate); newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate); + newRoom.on(RoomEvent.TrackMuted, guardedUpdate); + newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate); + newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate); newRoom.on(RoomEvent.ConnectionStateChanged, (state) => { if (roomRef.current === newRoom) { const connected = state === ConnectionState.Connected; diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index 8aa8f556..613375cc 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -115,9 +115,11 @@ export function useLiveKit() { p.trackPublications.forEach((pub) => { const track = pub.track; if (!track) return; - if (pub.source === Track.Source.Microphone) audioTrack = track.mediaStreamTrack; - else if (pub.source === Track.Source.Camera) videoTrack = track.mediaStreamTrack; - else if (pub.source === Track.Source.ScreenShare) screenTrack = track.mediaStreamTrack; + const mt = track.mediaStreamTrack; + if (!mt || mt.readyState !== 'live') return; + if (pub.source === Track.Source.Microphone) audioTrack = mt; + else if (pub.source === Track.Source.Camera) videoTrack = mt; + else if (pub.source === Track.Source.ScreenShare) screenTrack = mt; }); allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: !p.isMicrophoneEnabled, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack }); }; @@ -145,6 +147,9 @@ export function useLiveKit() { newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate); newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate); newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate); + newRoom.on(RoomEvent.TrackMuted, guardedUpdate); + newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate); + newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate); newRoom.on(RoomEvent.ConnectionStateChanged, (state) => { if (roomRef.current === newRoom) { const connected = state === ConnectionState.Connected; @@ -184,6 +189,9 @@ export function useLiveKit() { newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate); newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate); newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate); + newRoom.on(RoomEvent.TrackMuted, guardedUpdate); + newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate); + newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate); newRoom.on(RoomEvent.ConnectionStateChanged, (state) => { if (roomRef.current === newRoom) { const connected = state === ConnectionState.Connected;