feat: Voice UI overhaul — fix black tiles, focus mode, track lifecycle

- Fix black screen when streams end by checking track readyState === 'live'
- Add track 'ended' event listeners in VoiceUser for immediate fallback to avatar
- Add visible "Grid" button to exit focus mode (replaces undiscoverable double-click)
- Auto-focus screen sharers, auto-unfocus when they stop sharing
- Add LIVE badge, speaking glow shadow, screen share object-contain
- Add TrackMuted/TrackUnmuted/ActiveSpeakersChanged Room events
- Fix PiP video attachment with shouldShow dependency
This commit is contained in:
Jannis Braun
2026-02-19 05:45:31 +01:00
parent 2e309bf959
commit cd48261f54
8 changed files with 218 additions and 88 deletions
@@ -93,6 +93,7 @@ export function PictureInPicture() {
return 'Call'; return 'Call';
}, [currentVoiceChannelId, channels]); }, [currentVoiceChannelId, channels]);
// Video track attachment // Video track attachment
// shouldShow in deps ensures re-run when PiP becomes visible (videoRef was null before)
useEffect(() => { useEffect(() => {
const videoEl = videoRef.current; const videoEl = videoRef.current;
if (!videoEl) if (!videoEl)
@@ -103,7 +104,7 @@ export function PictureInPicture() {
else { else {
videoEl.srcObject = null; videoEl.srcObject = null;
} }
}, [selectedStream?.track]); }, [selectedStream?.track, shouldShow]);
// Initialize position to bottom-right // Initialize position to bottom-right
useEffect(() => { useEffect(() => {
if (shouldShow && position.x === -1) { if (shouldShow && position.x === -1) {
@@ -118,6 +118,7 @@ export function PictureInPicture() {
}, [currentVoiceChannelId, channels]); }, [currentVoiceChannelId, channels]);
// Video track attachment // Video track attachment
// shouldShow in deps ensures re-run when PiP becomes visible (videoRef was null before)
useEffect(() => { useEffect(() => {
const videoEl = videoRef.current; const videoEl = videoRef.current;
if (!videoEl) return; if (!videoEl) return;
@@ -126,7 +127,7 @@ export function PictureInPicture() {
} else { } else {
videoEl.srcObject = null; videoEl.srcObject = null;
} }
}, [selectedStream?.track]); }, [selectedStream?.track, shouldShow]);
// Initialize position to bottom-right // Initialize position to bottom-right
useEffect(() => { useEffect(() => {
+23 -5
View File
@@ -1,19 +1,37 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useRef } from 'react';
import { VoiceUser } from './VoiceUser'; import { VoiceUser } from './VoiceUser';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
export function VoiceGrid({ participants }) { export function VoiceGrid({ participants }) {
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId); const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant); 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) { 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 const focusedParticipant = focusedParticipantId
? participants.find(p => p.identity === focusedParticipantId) ? participants.find((p) => p.identity === focusedParticipantId)
: null; : null;
// Focus mode: one large tile + sidebar strip // Focus mode: one large tile + sidebar strip
if (focusedParticipant) { if (focusedParticipant) {
const otherParticipants = participants.filter(p => p.identity !== focusedParticipantId); 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))) }))] })); 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 // Default grid mode
const gridClass = (() => { const gridClass = (() => {
@@ -27,5 +45,5 @@ export function VoiceGrid({ participants }) {
return 'grid-cols-3'; return 'grid-cols-3';
return 'grid-cols-4'; 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))) }) }));
} }
+57 -14
View File
@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useRef } from 'react';
import { VoiceUser } from './VoiceUser'; import { VoiceUser } from './VoiceUser';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import type { ParticipantInfo } from '../../hooks/useLiveKit'; import type { ParticipantInfo } from '../../hooks/useLiveKit';
@@ -10,40 +10,83 @@ interface VoiceGridProps {
export function VoiceGrid({ participants }: VoiceGridProps) { export function VoiceGrid({ participants }: VoiceGridProps) {
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId); const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant); const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
const prevScreenSharerRef = useRef<string | null>(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) { if (participants.length === 0) {
return ( return (
<div className="flex-1 flex items-center justify-center text-discord-text-muted"> <div className="flex-1 flex items-center justify-center">
<p>No one is in this voice channel</p> <div className="text-center">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="currentColor"
className="text-discord-text-muted/40 mx-auto mb-3"
>
<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" />
</svg>
<p className="text-discord-text-muted text-sm">
Waiting for others to join...
</p>
</div>
</div> </div>
); );
} }
const focusedParticipant = focusedParticipantId const focusedParticipant = focusedParticipantId
? participants.find(p => p.identity === focusedParticipantId) ? participants.find((p) => p.identity === focusedParticipantId)
: null; : null;
// Focus mode: one large tile + sidebar strip // Focus mode: one large tile + sidebar strip
if (focusedParticipant) { if (focusedParticipant) {
const otherParticipants = participants.filter(p => p.identity !== focusedParticipantId); const otherParticipants = participants.filter(
(p) => p.identity !== focusedParticipantId,
);
return ( return (
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
{/* Main focused view */} {/* Main focused view */}
<div <div className="flex-1 p-2 relative">
className="flex-1 p-2"
onDoubleClick={() => setFocusedParticipant(null)}
>
<VoiceUser participant={focusedParticipant} large /> <VoiceUser participant={focusedParticipant} large />
{/* Back to grid button */}
<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"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" />
</svg>
<span className="text-xs font-medium">Grid</span>
</button>
</div> </div>
{/* Side strip of other participants */} {/* Side strip of other participants */}
{otherParticipants.length > 0 && ( {otherParticipants.length > 0 && (
<div className="w-[200px] flex-shrink-0 overflow-y-auto p-2 space-y-2"> <div className="w-[200px] flex-shrink-0 overflow-y-auto p-2 space-y-2 bg-[#111214]/50">
{otherParticipants.map((p) => ( {otherParticipants.map((p) => (
<div <div
key={p.identity} key={p.identity}
onClick={() => setFocusedParticipant(p.identity)} onClick={() => setFocusedParticipant(p.identity)}
className="cursor-pointer" className="cursor-pointer hover:opacity-80 transition-opacity"
> >
<VoiceUser participant={p} /> <VoiceUser participant={p} />
</div> </div>
@@ -64,13 +107,13 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
})(); })();
return ( return (
<div className="flex-1 p-4 overflow-auto"> <div className="flex-1 p-3 overflow-auto flex items-center">
<div className={`grid ${gridClass} gap-2 h-full`}> <div className={`grid ${gridClass} gap-2 w-full`}>
{participants.map((p) => ( {participants.map((p) => (
<div <div
key={p.identity} key={p.identity}
onClick={() => setFocusedParticipant(p.identity)} onClick={() => setFocusedParticipant(p.identity)}
className="cursor-pointer" className="cursor-pointer hover:opacity-90 transition-opacity"
> >
<VoiceUser participant={p} /> <VoiceUser participant={p} />
</div> </div>
+29 -17
View File
@@ -8,26 +8,42 @@ export function VoiceUser({ participant, large }) {
const isDeafened = useVoiceStore((s) => s.isDeafened); const isDeafened = useVoiceStore((s) => s.isDeafened);
const outputVolume = useVoiceStore((s) => s.outputVolume); const outputVolume = useVoiceStore((s) => s.outputVolume);
const participantVolumes = useVoiceStore((s) => s.participantVolumes); const participantVolumes = useVoiceStore((s) => s.participantVolumes);
const [, forceUpdate] = useState(0);
const perUserVolume = participantVolumes.get(participant.userId) ?? 100; 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(() => { useEffect(() => {
const videoEl = videoRef.current; const videoEl = videoRef.current;
if (!videoEl) if (!videoEl)
return; return;
const track = participant.videoTrack ?? participant.screenTrack; if (activeVideoTrack) {
if (track) { videoEl.srcObject = new MediaStream([activeVideoTrack]);
const stream = new MediaStream([track]);
videoEl.srcObject = stream;
} }
else { else {
videoEl.srcObject = null; videoEl.srcObject = null;
} }
}, [participant.videoTrack, participant.screenTrack]); }, [activeVideoTrack]);
// Attach audio track
useEffect(() => { useEffect(() => {
const audioEl = audioRef.current; const audioEl = audioRef.current;
if (!audioEl || !participant.audioTrack) if (!audioEl || !participant.audioTrack)
return; return;
const stream = new MediaStream([participant.audioTrack]); audioEl.srcObject = new MediaStream([participant.audioTrack]);
audioEl.srcObject = stream;
}, [participant.audioTrack]); }, [participant.audioTrack]);
// Apply volume: combine outputVolume and per-participant volume, or mute if deafened // Apply volume: combine outputVolume and per-participant volume, or mute if deafened
useEffect(() => { useEffect(() => {
@@ -36,26 +52,23 @@ export function VoiceUser({ participant, large }) {
return; return;
if (isDeafened) { if (isDeafened) {
audioEl.volume = 0; audioEl.volume = 0;
audioEl.muted = true;
} }
else { else {
// Both are 0-200 scale with 100 = default. Combine as fractions.
const combined = (outputVolume / 100) * (perUserVolume / 100); const combined = (outputVolume / 100) * (perUserVolume / 100);
audioEl.volume = Math.min(Math.max(combined, 0), 1); audioEl.volume = Math.min(Math.max(combined, 0), 1);
audioEl.muted = false;
} }
audioEl.muted = isDeafened;
}, [isDeafened, outputVolume, perUserVolume]); }, [isDeafened, outputVolume, perUserVolume]);
const hasVideo = !!(participant.videoTrack || participant.screenTrack);
const isLocal = participant.isLocal;
// Volume context menu // Volume context menu
const [volumeMenu, setVolumeMenu] = useState(null); const [volumeMenu, setVolumeMenu] = useState(null);
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume); const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
const handleContextMenu = useCallback((e) => { const handleContextMenu = useCallback((e) => {
if (isLocal) if (isLocal)
return; // No volume control for self return;
e.preventDefault(); e.preventDefault();
setVolumeMenu({ x: e.clientX, y: e.clientY }); setVolumeMenu({ x: e.clientX, y: e.clientY });
}, [isLocal]); }, [isLocal]);
// Close volume menu on click outside
useEffect(() => { useEffect(() => {
if (!volumeMenu) if (!volumeMenu)
return; return;
@@ -63,8 +76,7 @@ export function VoiceUser({ participant, large }) {
window.addEventListener('click', close); window.addEventListener('click', close);
return () => window.removeEventListener('click', close); return () => window.removeEventListener('click', close);
}, [volumeMenu]); }, [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: { return (_jsxs("div", { className: `relative bg-[#1e1f22] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${participant.isSpeaking
imageRendering: 'crisp-edges', ? 'ring-[3px] ring-discord-green shadow-[0_0_12px_rgba(35,165,90,0.25)]'
WebkitFontSmoothing: 'antialiased' : '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, "%"] })] })] }))] }));
} })) : (_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, "%"] })] })] }))] }));
} }
+76 -38
View File
@@ -14,28 +14,45 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
const isDeafened = useVoiceStore((s) => s.isDeafened); const isDeafened = useVoiceStore((s) => s.isDeafened);
const outputVolume = useVoiceStore((s) => s.outputVolume); const outputVolume = useVoiceStore((s) => s.outputVolume);
const participantVolumes = useVoiceStore((s) => s.participantVolumes); const participantVolumes = useVoiceStore((s) => s.participantVolumes);
const [, forceUpdate] = useState(0);
const perUserVolume = participantVolumes.get(participant.userId) ?? 100; 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(() => { useEffect(() => {
const videoEl = videoRef.current; const videoEl = videoRef.current;
if (!videoEl) return; if (!videoEl) return;
if (activeVideoTrack) {
const track = participant.videoTrack ?? participant.screenTrack; videoEl.srcObject = new MediaStream([activeVideoTrack]);
if (track) {
const stream = new MediaStream([track]);
videoEl.srcObject = stream;
} else { } else {
videoEl.srcObject = null; videoEl.srcObject = null;
} }
}, [participant.videoTrack, participant.screenTrack]); }, [activeVideoTrack]);
// Attach audio track
useEffect(() => { useEffect(() => {
const audioEl = audioRef.current; const audioEl = audioRef.current;
if (!audioEl || !participant.audioTrack) return; if (!audioEl || !participant.audioTrack) return;
audioEl.srcObject = new MediaStream([participant.audioTrack]);
const stream = new MediaStream([participant.audioTrack]);
audioEl.srcObject = stream;
}, [participant.audioTrack]); }, [participant.audioTrack]);
// Apply volume: combine outputVolume and per-participant volume, or mute if deafened // 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 (!audioEl) return;
if (isDeafened) { if (isDeafened) {
audioEl.volume = 0; audioEl.volume = 0;
audioEl.muted = true;
} else { } else {
// Both are 0-200 scale with 100 = default. Combine as fractions.
const combined = (outputVolume / 100) * (perUserVolume / 100); const combined = (outputVolume / 100) * (perUserVolume / 100);
audioEl.volume = Math.min(Math.max(combined, 0), 1); audioEl.volume = Math.min(Math.max(combined, 0), 1);
audioEl.muted = false;
} }
audioEl.muted = isDeafened;
}, [isDeafened, outputVolume, perUserVolume]); }, [isDeafened, outputVolume, perUserVolume]);
const hasVideo = !!(participant.videoTrack || participant.screenTrack);
const isLocal = participant.isLocal;
// Volume context menu // Volume context menu
const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null); const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null);
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume); const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
const handleContextMenu = useCallback((e: React.MouseEvent) => { const handleContextMenu = useCallback(
if (isLocal) return; // No volume control for self (e: React.MouseEvent) => {
if (isLocal) return;
e.preventDefault(); e.preventDefault();
setVolumeMenu({ x: e.clientX, y: e.clientY }); setVolumeMenu({ x: e.clientX, y: e.clientY });
}, [isLocal]); },
[isLocal],
);
// Close volume menu on click outside
useEffect(() => { useEffect(() => {
if (!volumeMenu) return; if (!volumeMenu) return;
const close = () => setVolumeMenu(null); const close = () => setVolumeMenu(null);
@@ -75,8 +91,10 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
return ( return (
<div <div
className={`relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center transition-all ${ 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' : 'ring-1 ring-transparent' 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' : ''}`} } ${large ? 'h-full' : ''}`}
style={large ? undefined : { aspectRatio: '16/9', minHeight: '140px' }} style={large ? undefined : { aspectRatio: '16/9', minHeight: '140px' }}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
@@ -90,42 +108,54 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
autoPlay autoPlay
playsInline playsInline
muted={isLocal} muted={isLocal}
className={`w-full h-full ${large ? 'object-contain' : 'object-cover'}`} className={`w-full h-full ${large || isScreenShare ? 'object-contain bg-black' : 'object-cover'}`}
style={{
imageRendering: 'crisp-edges',
WebkitFontSmoothing: 'antialiased'
} as any}
/> />
) : ( ) : (
<div className="flex flex-col items-center justify-center gap-2"> <div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-[#2b2d31]">
<div className="relative">
<Avatar <Avatar
src={null} src={null}
name={participant.username} name={participant.username}
size={large ? 100 : 80} size={large ? 100 : 64}
/> />
{participant.isSpeaking && (
<div className="absolute -inset-1.5 rounded-full ring-[3px] ring-discord-green animate-pulse" />
)}
</div>
</div>
)}
{/* LIVE badge for screen shares */}
{isScreenShare && hasVideo && (
<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">
LIVE
</div> </div>
)} )}
{/* Bottom overlay */} {/* Bottom overlay */}
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent"> <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">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 min-w-0">
<span className={`font-medium text-white ${large ? 'text-base' : 'text-sm'}`}>{participant.username}</span> <span
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
>
{participant.username}
</span>
{isLocal && ( {isLocal && (
<span className="text-[10px] text-white/50 font-medium">(you)</span> <span className="text-[10px] text-white/40 font-medium">(you)</span>
)} )}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 flex-shrink-0">
{participant.isMuted && ( {participant.isMuted && (
<div className="w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center"> <div className="w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="white"> <svg width="12" height="12" viewBox="0 0 24 24" fill="white">
<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" /> <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" />
<line x1="3" y1="3" x2="21" y2="21" stroke="white" strokeWidth="2" /> <line x1="3" y1="3" x2="21" y2="21" stroke="white" strokeWidth="2" />
</svg> </svg>
</div> </div>
)} )}
{participant.isScreenSharing && ( {participant.isScreenSharing && !isScreenShare && (
<div className="w-5 h-5 bg-discord-blurple/80 rounded-full flex items-center justify-center"> <div className="w-5 h-5 bg-discord-blurple/90 rounded-full flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="white"> <svg width="12" height="12" viewBox="0 0 24 24" fill="white">
<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" /> <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" />
</svg> </svg>
@@ -138,7 +168,7 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
{/* Per-participant volume menu (right-click) */} {/* Per-participant volume menu (right-click) */}
{volumeMenu && !isLocal && ( {volumeMenu && !isLocal && (
<div <div
className="fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px]" 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 }} style={{ left: volumeMenu.x, top: volumeMenu.y }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -146,7 +176,13 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
User Volume User Volume
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0"> <svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
className="text-discord-text-muted flex-shrink-0"
>
<path d="M3 9v6h4l5 5V4L7 9H3z" /> <path d="M3 9v6h4l5 5V4L7 9H3z" />
</svg> </svg>
<input <input
@@ -154,7 +190,9 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
min="0" min="0"
max="200" max="200"
value={perUserVolume} value={perUserVolume}
onChange={(e) => setParticipantVolume(participant.userId, parseInt(e.target.value))} onChange={(e) =>
setParticipantVolume(participant.userId, parseInt(e.target.value))
}
className="flex-1 accent-discord-blurple h-1" className="flex-1 accent-discord-blurple h-1"
/> />
<span className="text-xs text-discord-text-secondary min-w-[32px] text-right"> <span className="text-xs text-discord-text-secondary min-w-[32px] text-right">
+12 -3
View File
@@ -81,12 +81,15 @@ export function useLiveKit() {
const track = pub.track; const track = pub.track;
if (!track) if (!track)
return; return;
const mt = track.mediaStreamTrack;
if (!mt || mt.readyState !== 'live')
return;
if (pub.source === Track.Source.Microphone) if (pub.source === Track.Source.Microphone)
audioTrack = track.mediaStreamTrack; audioTrack = mt;
else if (pub.source === Track.Source.Camera) else if (pub.source === Track.Source.Camera)
videoTrack = track.mediaStreamTrack; videoTrack = mt;
else if (pub.source === Track.Source.ScreenShare) 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 }); 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.TrackUnsubscribed, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate); newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackUnpublished, 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) => { newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) { if (roomRef.current === newRoom) {
const connected = state === ConnectionState.Connected; const connected = state === ConnectionState.Connected;
@@ -192,6 +198,9 @@ export function useLiveKit() {
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate); newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate); newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackUnpublished, 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) => { newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) { if (roomRef.current === newRoom) {
const connected = state === ConnectionState.Connected; const connected = state === ConnectionState.Connected;
+11 -3
View File
@@ -115,9 +115,11 @@ export function useLiveKit() {
p.trackPublications.forEach((pub) => { p.trackPublications.forEach((pub) => {
const track = pub.track; const track = pub.track;
if (!track) return; if (!track) return;
if (pub.source === Track.Source.Microphone) audioTrack = track.mediaStreamTrack; const mt = track.mediaStreamTrack;
else if (pub.source === Track.Source.Camera) videoTrack = track.mediaStreamTrack; if (!mt || mt.readyState !== 'live') return;
else if (pub.source === Track.Source.ScreenShare) screenTrack = track.mediaStreamTrack; 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 }); 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.TrackUnsubscribed, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate); newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackUnpublished, 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) => { newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) { if (roomRef.current === newRoom) {
const connected = state === ConnectionState.Connected; const connected = state === ConnectionState.Connected;
@@ -184,6 +189,9 @@ export function useLiveKit() {
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate); newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate); newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackUnpublished, 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) => { newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) { if (roomRef.current === newRoom) {
const connected = state === ConnectionState.Connected; const connected = state === ConnectionState.Connected;