Permission changes now take effect immediately without requiring disconnect/reconnect. Modeled as "permission mute" parallel to server mute — server recomputes SPEAK for all voice participants on role/override changes and broadcasts state via WebSocket. Includes amber UI indicators and mic toggle blocking.
198 lines
8.3 KiB
TypeScript
198 lines
8.3 KiB
TypeScript
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
|
import { Avatar } from '../ui/Avatar';
|
|
import { useVoiceStore } from '../../stores/voiceStore';
|
|
import { VoiceUserContextMenu } from './VoiceUserContextMenu';
|
|
import { useSpaceStore } from '../../stores/spaceStore';
|
|
import type { UserTile } from '../../hooks/useLiveKit';
|
|
|
|
interface VoiceUserProps {
|
|
tile: UserTile;
|
|
large?: boolean;
|
|
}
|
|
|
|
export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
const { participant } = tile;
|
|
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
|
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
|
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
|
|
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
|
|
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
|
|
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
|
|
const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras);
|
|
const participantMutes = useVoiceStore((s) => s.participantMutes);
|
|
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
|
|
|
|
const [, forceUpdate] = useState(0);
|
|
|
|
const isLocal = participant.isLocal;
|
|
const avatarUserId = participant.homeUserId ?? participant.userId;
|
|
|
|
// --- VIDEO & UI ---
|
|
|
|
const activeVideoTrack = tile.videoTrack;
|
|
const hasVideo = activeVideoTrack !== null;
|
|
|
|
// Force re-render when tracks end/mute
|
|
useEffect(() => {
|
|
if (!tile.videoTrack) return;
|
|
const onEnded = () => forceUpdate((n) => n + 1);
|
|
tile.videoTrack.addEventListener('ended', onEnded);
|
|
return () => tile.videoTrack?.removeEventListener('ended', onEnded);
|
|
}, [tile.videoTrack]);
|
|
|
|
// Attach Video — use LiveKit's track.attach() to register the element
|
|
// with the adaptive stream observer (enables SFU layer switching by viewport size)
|
|
useEffect(() => {
|
|
const videoEl = videoRef.current;
|
|
if (!videoEl) return;
|
|
const lkTrack = tile.lkVideoTrack;
|
|
if (lkTrack) {
|
|
lkTrack.attach(videoEl);
|
|
return () => { lkTrack.detach(videoEl); };
|
|
} else {
|
|
videoEl.srcObject = null;
|
|
}
|
|
}, [tile.lkVideoTrack]);
|
|
|
|
// Context Menu
|
|
const [volumeMenu, setVolumeMenu] = useState<{
|
|
x: number;
|
|
y: number;
|
|
} | null>(null);
|
|
|
|
const handleContextMenu = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (isLocal) return;
|
|
e.preventDefault();
|
|
setVolumeMenu({ x: e.clientX, y: e.clientY });
|
|
},
|
|
[isLocal],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={`relative bg-surface-base rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${
|
|
isSpeaking
|
|
? 'ring-[3px] ring-status-online shadow-[0_0_12px_rgba(134,239,172,0.25)]'
|
|
: 'ring-1 ring-white/[0.06] hover:ring-white/10'
|
|
} h-full w-full`}
|
|
onContextMenu={handleContextMenu}
|
|
>
|
|
{hasVideo ? (
|
|
<video
|
|
ref={videoRef}
|
|
autoPlay
|
|
playsInline
|
|
muted={isLocal}
|
|
className={`w-full h-full ${large ? 'object-contain bg-black' : 'object-cover'}`}
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-surface-channel">
|
|
<div className="relative">
|
|
<Avatar
|
|
src={null}
|
|
name={participant.username}
|
|
size={large ? 100 : 64}
|
|
userId={avatarUserId}
|
|
/>
|
|
{isSpeaking && (
|
|
<div className="absolute -inset-1.5 rounded-full ring-[3px] ring-status-online animate-pulse" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<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 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/40 font-medium">
|
|
(you)
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{(() => {
|
|
const isServerMutedUser = spaceId ? serverMutedUserIds.has(`${spaceId}:${participant.userId}`) : false;
|
|
const isServerDeafenedUser = spaceId ? serverDeafenedUserIds.has(`${spaceId}:${participant.userId}`) : false;
|
|
const isPermissionMutedUser = spaceId ? permissionMutedUserIds.has(`${spaceId}:${participant.userId}`) : false;
|
|
const effectivelyMuted = participant.isMuted || isServerMutedUser || isServerDeafenedUser || isPermissionMutedUser;
|
|
const effectivelyDeafened = (isLocal ? isDeafened : participant.isDeafened) || isServerDeafenedUser;
|
|
return (
|
|
<>
|
|
{effectivelyMuted && (
|
|
<div className={`w-5 h-5 ${(isServerMutedUser || isServerDeafenedUser || isPermissionMutedUser) ? 'bg-accent-amber/90' : 'bg-accent-rose/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>
|
|
)}
|
|
{effectivelyDeafened && (
|
|
<div className={`w-5 h-5 ${isServerDeafenedUser ? 'bg-accent-amber/90' : 'bg-accent-rose/90'} rounded-full flex items-center justify-center`}>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
|
|
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
|
|
<line
|
|
x1="3"
|
|
y1="3"
|
|
x2="21"
|
|
y2="21"
|
|
stroke="white"
|
|
strokeWidth="2"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
)}
|
|
{!isLocal && participant.isCameraOn && (
|
|
<div className="w-5 h-5 bg-white/20 rounded-full flex items-center justify-center">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
|
|
<path d="M17 10.5V7c0-.55-.45-1-1-1H2c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h14c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
|
|
{unwatchedCameras.has(participant.userId) && (
|
|
<line x1="1" y1="1" x2="23" y2="23" stroke="white" strokeWidth="2" strokeLinecap="round" />
|
|
)}
|
|
</svg>
|
|
</div>
|
|
)}
|
|
{!isLocal && participantMutes.get(participant.userId) && (
|
|
<div className="w-5 h-5 bg-white/20 rounded-full flex items-center justify-center" title="Locally Muted">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
|
|
<path d="M3 9v6h4l5 5V4L7 9H3z" />
|
|
<line x1="17" y1="7" x2="23" y2="13" stroke="white" strokeWidth="2" strokeLinecap="round" />
|
|
<line x1="23" y1="7" x2="17" y2="13" stroke="white" strokeWidth="2" strokeLinecap="round" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{volumeMenu && currentVoiceChannelId && (
|
|
<VoiceUserContextMenu
|
|
targetUserId={participant.userId}
|
|
channelId={currentVoiceChannelId}
|
|
position={volumeMenu}
|
|
onClose={() => setVolumeMenu(null)}
|
|
isLocal={isLocal}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|