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
+82 -44
View File
@@ -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 (
<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'
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}
@@ -90,42 +108,54 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
autoPlay
playsInline
muted={isLocal}
className={`w-full h-full ${large ? 'object-contain' : 'object-cover'}`}
style={{
imageRendering: 'crisp-edges',
WebkitFontSmoothing: 'antialiased'
} as any}
className={`w-full h-full ${large || isScreenShare ? 'object-contain bg-black' : 'object-cover'}`}
/>
) : (
<div className="flex flex-col items-center justify-center gap-2">
<Avatar
src={null}
name={participant.username}
size={large ? 100 : 80}
/>
<div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-[#2b2d31]">
<div className="relative">
<Avatar
src={null}
name={participant.username}
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>
)}
{/* 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 gap-1.5">
<span className={`font-medium text-white ${large ? 'text-base' : 'text-sm'}`}>{participant.username}</span>
<div className="flex items-center gap-1.5 min-w-0">
<span
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
>
{participant.username}
</span>
{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 className="flex items-center gap-1">
<div className="flex items-center gap-1 flex-shrink-0">
{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">
<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" />
</svg>
</div>
)}
{participant.isScreenSharing && (
<div className="w-5 h-5 bg-discord-blurple/80 rounded-full flex items-center justify-center">
{participant.isScreenSharing && !isScreenShare && (
<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">
<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>
@@ -138,7 +168,7 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
{/* Per-participant volume menu (right-click) */}
{volumeMenu && !isLocal && (
<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 }}
onClick={(e) => e.stopPropagation()}
>
@@ -146,7 +176,13 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
User Volume
</div>
<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" />
</svg>
<input
@@ -154,7 +190,9 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
min="0"
max="200"
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"
/>
<span className="text-xs text-discord-text-secondary min-w-[32px] text-right">