Files
SteelXR/src/pages/MeetingRoom.tsx
T

2040 lines
86 KiB
TypeScript

import React, { useEffect, useRef, useState, useMemo, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
}
class ModelErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("🚨 [ModelErrorBoundary] Falha de renderização/rede no modelo:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, Text, useGLTF, Billboard, Html } from '@react-three/drei';
import * as THREE from 'three';
import { supabase } from '@/integrations/supabase/client';
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import {
Users, MessageSquare, Mic, MicOff, Home, Send,
ArrowRight, ChevronLeft, ChevronRight, Share2, LogOut,
RotateCw, Award, ShieldAlert, Maximize2, Minimize2, Box,
Settings, Check
} from 'lucide-react';
// Interfaces tipadas para evitar any
interface AvatarPreset {
id: number;
name: string;
desc: string;
color: string;
url: string;
}
interface PresentationState {
rotation: number[];
scale: number;
sectionEnabled: boolean;
sectionAxis: 'x' | 'y' | 'z';
sectionLevel: number;
sectionInvert: boolean;
modelUrl?: string;
modelFileName?: string;
modelFileSize?: number;
}
interface PeerData {
username: string;
avatarId: number;
chairId: string;
role: string;
cargo: string;
empresa: string;
presence_ref: string;
allowedAvatars?: number[];
}
// Detector nativo de suporte ao WebGL
function isWebGLAvailable() {
try {
const canvas = document.createElement('canvas');
return !!(
window.WebGLRenderingContext &&
(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'))
);
} catch (e) {
return false;
}
}
// Hook de verificação de suporte a WebGL no cliente
function useWebGLSupport() {
const [supported, setSupported] = useState(true);
useEffect(() => {
setSupported(isWebGLAvailable());
}, []);
return supported;
}
// --- Configurações da Mesa de Reunião ---
const CHAIRS = [
{ id: '1', name: 'Assento 1', pos: [-1.2, 0.95, -1.45], rot: [0, 0, 0], lookAt: [0, 0.95, 0] },
{ id: '2', name: 'Assento 2', pos: [-0.4, 0.95, -1.45], rot: [0, 0, 0], lookAt: [0, 0.95, 0] },
{ id: '3', name: 'Assento 3', pos: [0.4, 0.95, -1.45], rot: [0, 0, 0], lookAt: [0, 0.95, 0] },
{ id: '4', name: 'Assento 4', pos: [1.2, 0.95, -1.45], rot: [0, 0, 0], lookAt: [0, 0.95, 0] },
{ id: '5', name: 'Assento 5', pos: [1.2, 0.95, 1.45], rot: [0, Math.PI, 0], lookAt: [0, 0.95, 0] },
{ id: '6', name: 'Assento 6', pos: [0.4, 0.95, 1.45], rot: [0, Math.PI, 0], lookAt: [0, 0.95, 0] },
{ id: '7', name: 'Assento 7', pos: [-0.4, 0.95, 1.45], rot: [0, Math.PI, 0], lookAt: [0, 0.95, 0] },
{ id: '8', name: 'Assento 8', pos: [-1.2, 0.95, 1.45], rot: [0, Math.PI, 0], lookAt: [0, 0.95, 0] },
];
const AVATAR_PRESETS: AvatarPreset[] = [
// Masculinos (1 a 10)
{ id: 1, name: 'Leo', desc: 'Negro, cabelo curto preto', url: '/avatars/avatar_1.png', color: '#ef4444' },
{ id: 2, name: 'Marcus', desc: 'Loiro, cabelo curto', url: '/avatars/avatar_2.png', color: '#b45309' },
{ id: 3, name: 'Arthur', desc: 'Ruivo com barba', url: '/avatars/avatar_3.png', color: '#eab308' },
{ id: 4, name: 'Jin', desc: 'Asiático, cabelo preto liso', url: '/avatars/avatar_4.png', color: '#3b82f6' },
{ id: 5, name: 'Mateo', desc: 'Castanho, cabelo cacheado', url: '/avatars/avatar_5.png', color: '#f97316' },
{ id: 6, name: 'Malik', desc: 'Negro, careca com barba', url: '/avatars/avatar_6.png', color: '#84cc16' },
{ id: 7, name: 'Oliver', desc: 'Cabelo grisalho com óculos', url: '/avatars/avatar_7.png', color: '#06b6d4' },
{ id: 8, name: 'Carlos', desc: 'Latino com bigode', url: '/avatars/avatar_8.png', color: '#6366f1' },
{ id: 9, name: 'Yuki', desc: 'Cabelo espetado azul escuro', url: '/avatars/avatar_9.png', color: '#a855f7' },
{ id: 10, name: 'Elijah', desc: 'Negro com dreadlocks', url: '/avatars/avatar_10.png', color: '#ec4899' },
// Femininos (11 a 20)
{ id: 11, name: 'Sophia', desc: 'Morena, cabelo longo castanho', url: '/avatars/avatar_11.png', color: '#10b981' },
{ id: 12, name: 'Aisha', desc: 'Negra com turbante/lenço', url: '/avatars/avatar_12.png', color: '#78350f' },
{ id: 13, name: 'Isabella', desc: 'Loira, cabelo longo ondulado', url: '/avatars/avatar_13.png', color: '#1e293b' },
{ id: 14, name: 'Chloe', desc: 'Ruiva, corte canal/bob', url: '/avatars/avatar_14.png', color: '#ca8a04' },
{ id: 15, name: 'Mei', desc: 'Asiática, cabelo com franja', url: '/avatars/avatar_15.png', color: '#f43f5e' },
{ id: 16, name: 'Elena', desc: 'Castanha, cabelo afro volumoso', url: '/avatars/avatar_16.png', color: '#14b8a6' },
{ id: 17, name: 'Zara', desc: 'Cabelo roxo moderno', url: '/avatars/avatar_17.png', color: '#0ea5e9' },
{ id: 18, name: 'Naomi', desc: 'Negra de óculos e rabo de cavalo', url: '/avatars/avatar_18.png', color: '#4f46e5' },
{ id: 19, name: 'Harper', desc: 'Corte pixie rosa', url: '/avatars/avatar_19.png', color: '#d946ef' },
{ id: 20, name: 'Amara', desc: 'Negra com tranças', url: '/avatars/avatar_20.png', color: '#f59e0b' }
];
const SKIN_COLORS = ['#fed7aa', '#fdba74', '#f59e0b', '#d97706', '#9a3412', '#ffedd5'];
// Classes Tailwind estáticas para evitar inline styles no list view dos avatares
const getAvatarBgClass = (avatarId: number): string => {
const classes = [
'bg-red-500/20 text-red-400',
'bg-amber-800/20 text-amber-500',
'bg-yellow-500/20 text-yellow-400',
'bg-blue-500/20 text-blue-400',
'bg-orange-500/20 text-orange-400',
'bg-lime-500/20 text-lime-400',
'bg-cyan-500/20 text-cyan-400',
'bg-indigo-500/20 text-indigo-400',
'bg-purple-500/20 text-purple-400',
'bg-pink-500/20 text-pink-400',
'bg-emerald-500/20 text-emerald-400',
'bg-amber-950/40 text-amber-600',
'bg-slate-700/20 text-slate-300',
'bg-yellow-600/20 text-yellow-500',
'bg-rose-500/20 text-rose-400',
'bg-teal-500/20 text-teal-400',
'bg-sky-500/20 text-sky-400',
'bg-indigo-600/20 text-indigo-500',
'bg-fuchsia-500/20 text-fuchsia-400',
'bg-amber-500/20 text-amber-400'
];
return classes[(avatarId - 1) % classes.length] || classes[0];
};
// --- Subcomponentes 3D ---
// ==========================================
// BACKUP DOS AVATARES PROCEDURAIS ORIGINAIS
// ==========================================
function MiiAvatarBackup({
avatarId,
username,
isTalking,
scale = 1,
isLastChatSender = false,
...props
}: {
avatarId: number;
username: string;
isTalking?: boolean;
scale?: number;
isLastChatSender?: boolean;
} & Record<string, unknown>) {
const groupRef = useRef<THREE.Group>(null);
const ringRef = useRef<THREE.Mesh>(null);
const headRef = useRef<THREE.Group>(null);
const lastSenderRef = useRef(isLastChatSender);
const pulseEndTimeRef = useRef(0);
useEffect(() => {
if (isLastChatSender && !lastSenderRef.current) {
pulseEndTimeRef.current = Date.now() + 1500;
}
lastSenderRef.current = isLastChatSender;
}, [isLastChatSender]);
const avatarUrl = useMemo(() => {
const preset = AVATAR_PRESETS.find((p) => p.id === avatarId);
return preset ? preset.url : `/avatars/avatar_${avatarId}.png`;
}, [avatarId]);
useFrame((state) => {
if (groupRef.current && props.position && Array.isArray(props.position) && props.position.length >= 2) {
groupRef.current.position.y = (props.position[1] as number) + Math.sin(state.clock.getElapsedTime() * 2 + avatarId) * 0.012;
}
if (headRef.current) {
if (isTalking) {
headRef.current.position.y = 0.16 + Math.abs(Math.sin(state.clock.getElapsedTime() * 12)) * 0.025;
headRef.current.rotation.z = Math.sin(state.clock.getElapsedTime() * 12) * 0.06;
} else {
headRef.current.position.y = THREE.MathUtils.lerp(headRef.current.position.y, 0.16, 0.15);
headRef.current.rotation.z = THREE.MathUtils.lerp(headRef.current.rotation.z, 0, 0.15);
}
}
if (ringRef.current) {
const now = Date.now();
const material = ringRef.current.material as THREE.MeshBasicMaterial;
if (isTalking) {
material.color.set('#3b82f6');
material.opacity = 0.6 + Math.sin(state.clock.getElapsedTime() * 10) * 0.3;
ringRef.current.scale.set(1, 1, 1);
} else if (isLastChatSender) {
material.color.set('#eab308');
const isPulsing = now < pulseEndTimeRef.current;
if (isPulsing) {
material.opacity = 0.5 + Math.sin(state.clock.getElapsedTime() * 25) * 0.4;
const s = 1.0 + Math.sin(state.clock.getElapsedTime() * 25) * 0.15;
ringRef.current.scale.set(s, s, 1);
} else {
material.opacity = 0.75;
ringRef.current.scale.set(1, 1, 1);
}
}
}
});
const positionVector = useMemo(() => {
if (props.position && Array.isArray(props.position)) {
return new THREE.Vector3(props.position[0] as number, props.position[1] as number, props.position[2] as number);
}
return new THREE.Vector3(0, 0, 0);
}, [props.position]);
const rotationEuler = useMemo(() => {
if (props.rotation && Array.isArray(props.rotation)) {
return new THREE.Euler(props.rotation[0] as number, props.rotation[1] as number, props.rotation[2] as number);
}
return new THREE.Euler(0, 0, 0);
}, [props.rotation]);
return (
<group ref={groupRef} position={positionVector} rotation={rotationEuler} scale={scale}>
<group position={[0, 0.38, 0]}>
<mesh position={[0, 0, -0.001]}>
<planeGeometry args={[0.44, 0.1]} />
<meshBasicMaterial color="#0f172a" transparent opacity={0.7} />
</mesh>
<Text fontSize={0.048} color="#ffffff" anchorX="center" anchorY="middle" fontWeight="bold">
{username}
</Text>
</group>
{(isTalking || isLastChatSender) && (
<mesh ref={ringRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]}>
<ringGeometry args={[0.065, 0.075, 32]} />
<meshBasicMaterial color="#3b82f6" transparent opacity={0.7} side={THREE.DoubleSide} />
</mesh>
)}
{/* Cabeça do Avatar: Toon Head do Dicebear em HTML Transformado em 3D Plano (olhando para a mesa) */}
<group ref={headRef} position={[0, 0.16, 0]}>
<Html transform center distanceFactor={1.2}>
<div className="w-16 h-16 flex items-center justify-center pointer-events-none select-none">
<img
src={avatarUrl}
alt={username}
className="w-full h-full object-contain drop-shadow-md"
onError={(e) => {
(e.currentTarget as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(username || 'U')}&background=475569&color=fff&size=128&rounded=true`;
}}
/>
</div>
</Html>
</group>
{/* Base cônica para dar uma sustentação 3D estilizada */}
<mesh position={[0, 0.03, 0]}>
<coneGeometry args={[0.045, 0.1, 32]} />
<meshStandardMaterial color="#475569" roughness={0.5} metalness={0.2} transparent opacity={0.8} />
</mesh>
</group>
);
}
// ==========================================
// CLASSES E COMPONENTES DE RESILIÊNCIA
// ==========================================
class AvatarErrorBoundary extends React.Component<{ fallback: React.ReactNode; children: React.ReactNode }, { hasError: boolean }> {
constructor(props: { fallback: React.ReactNode; children: React.ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error) {
console.warn("🚨 [AvatarErrorBoundary] Falha ao carregar o modelo GLB do avatar. Utilizando o avatar de fallback procedural.", error);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// O componente principal MiiAvatar agora é um wrapper resiliente com Suspense e ErrorBoundary
function MiiAvatar(props: {
avatarId: number;
username: string;
isTalking?: boolean;
scale?: number;
isLastChatSender?: boolean;
} & Record<string, unknown>) {
const preset = AVATAR_PRESETS.find((p) => p.id === props.avatarId);
const isPng = preset?.url.endsWith('.png');
if (isPng) {
return <MiiAvatarBackup {...props} />;
}
return (
<AvatarErrorBoundary fallback={<MiiAvatarBackup {...props} />}>
<Suspense fallback={<MiiAvatarBackup {...props} />}>
<RealMiiAvatar {...props} />
</Suspense>
</AvatarErrorBoundary>
);
}
// ==========================================
// NOVOS AVATARES REALISTAS (READY PLAYER ME)
// ==========================================
function RealMiiAvatar({
avatarId,
username,
isTalking,
scale = 1,
isLastChatSender = false,
...props
}: {
avatarId: number;
username: string;
isTalking?: boolean;
scale?: number;
isLastChatSender?: boolean;
} & Record<string, unknown>) {
const groupRef = useRef<THREE.Group>(null);
const ringRef = useRef<THREE.Mesh>(null);
const lastSenderRef = useRef(isLastChatSender);
const pulseEndTimeRef = useRef(0);
// Monitora quando este avatar se torna o último a escrever no chat
useEffect(() => {
if (isLastChatSender && !lastSenderRef.current) {
pulseEndTimeRef.current = Date.now() + 1500;
}
lastSenderRef.current = isLastChatSender;
}, [isLastChatSender]);
// Obtém o preset correto para pegar a URL do avatar GLB
const preset = useMemo(() => {
return AVATAR_PRESETS.find((p) => p.id === avatarId) || AVATAR_PRESETS[0];
}, [avatarId]);
// Carrega o modelo 3D GLB de forma assíncrona
const { scene } = useGLTF(preset.url);
const clone = useMemo(() => scene.clone(), [scene]);
// Configuração inicial do clone para sombra e materiais
useEffect(() => {
if (clone) {
clone.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.receiveShadow = true;
if (child.material) {
child.material.roughness = 0.8;
}
}
});
}
}, [clone]);
useFrame((state) => {
// Flutuação sutil de respiração/repouso
if (groupRef.current && props.position && Array.isArray(props.position) && props.position.length >= 2) {
groupRef.current.position.y = (props.position[1] as number) + Math.sin(state.clock.getElapsedTime() * 1.5 + avatarId) * 0.008 - 0.22;
}
// Animação de sincronização labial (lip-sync) nativa baseada em Morph Targets
if (clone) {
clone.traverse((child) => {
if (child instanceof THREE.SkinnedMesh && child.morphTargetInfluences) {
const dict = child.morphTargetDictionary;
if (dict) {
const jawOpenIdx = dict['jawOpen'] ?? dict['viseme_sil'] ?? dict['mouthOpen'];
const mouthSmileIdx = dict['mouthSmile'] ?? dict['mouthSmileLeft'];
if (jawOpenIdx !== undefined) {
if (isTalking) {
// Abre e fecha a boca no ritmo do clock
child.morphTargetInfluences[jawOpenIdx] = 0.15 + Math.abs(Math.sin(state.clock.getElapsedTime() * 18)) * 0.6;
} else {
child.morphTargetInfluences[jawOpenIdx] = 0;
}
}
if (mouthSmileIdx !== undefined) {
child.morphTargetInfluences[mouthSmileIdx] = 0.25;
}
}
}
});
}
// Animação do anel de fala / chat
if (ringRef.current) {
const now = Date.now();
const material = ringRef.current.material as THREE.MeshBasicMaterial;
if (isTalking) {
material.color.set('#3b82f6');
material.opacity = 0.6 + Math.sin(state.clock.getElapsedTime() * 10) * 0.3;
ringRef.current.scale.set(1, 1, 1);
} else if (isLastChatSender) {
material.color.set('#eab308');
const isPulsing = now < pulseEndTimeRef.current;
if (isPulsing) {
material.opacity = 0.5 + Math.sin(state.clock.getElapsedTime() * 25) * 0.4;
const s = 1.0 + Math.sin(state.clock.getElapsedTime() * 25) * 0.15;
ringRef.current.scale.set(s, s, 1);
} else {
material.opacity = 0.75;
ringRef.current.scale.set(1, 1, 1);
}
}
}
});
const positionVector = useMemo(() => {
if (props.position && Array.isArray(props.position)) {
return new THREE.Vector3(props.position[0] as number, (props.position[1] as number) - 0.22, props.position[2] as number);
}
return new THREE.Vector3(0, -0.22, 0);
}, [props.position]);
const rotationEuler = useMemo(() => {
if (props.rotation && Array.isArray(props.rotation)) {
return new THREE.Euler(props.rotation[0] as number, props.rotation[1] as number, props.rotation[2] as number);
}
return new THREE.Euler(0, 0, 0);
}, [props.rotation]);
return (
<group ref={groupRef} position={positionVector} rotation={rotationEuler} scale={scale}>
{/* Balão de Nome acima da cabeça */}
<group position={[0, 0.9, 0]}>
<mesh position={[0, 0, -0.001]}>
<planeGeometry args={[0.52, 0.11]} />
<meshBasicMaterial color="#0f172a" transparent opacity={0.8} />
</mesh>
<Text fontSize={0.052} color="#ffffff" anchorX="center" anchorY="middle" fontWeight="bold">
{username}
</Text>
</group>
{/* Modelo GLB Realista Clonado */}
<primitive object={clone} scale={0.42} position={[0, 0.02, 0]} />
{/* Indicador de Fala & Chat */}
{(isTalking || isLastChatSender) && (
<mesh
ref={ringRef}
rotation={[-Math.PI / 2, 0, 0]}
position={[0, 0.35, 0.02]}
>
<ringGeometry args={[0.075, 0.088, 32]} />
<meshBasicMaterial color="#3b82f6" transparent opacity={0.7} side={THREE.DoubleSide} />
</mesh>
)}
</group>
);
}
// Carregador e Escalador Inteligente da Maquete Holográfica
// Carregador e Escalador Inteligente da Maquete Holográfica
function HologramModel({ model, presentationState }: { model: SceneModel; presentationState: PresentationState }) {
const { scene } = useGLTF(model.url);
const clone = useMemo(() => scene.clone(), [scene]);
const presentationGroupRef = useRef<THREE.Group>(null);
const baseScaleRef = useRef<number>(1);
const centerRef = useRef<THREE.Vector3>(new THREE.Vector3());
// Calcula escala de aproximadamente 1 metro para caber na mesa (apenas UMA vez no carregamento)
useEffect(() => {
if (!clone) return;
// Reseta rotação e escala para obter o bounding box original limpo
const oldRot = clone.rotation.clone();
const oldScale = clone.scale.clone();
clone.rotation.set(0, 0, 0);
clone.scale.setScalar(1);
const box = new THREE.Box3().setFromObject(clone);
const size = new THREE.Vector3();
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim > 0) {
const targetSize = 0.95;
const scaleFactor = targetSize / maxDim;
baseScaleRef.current = scaleFactor;
const center = new THREE.Vector3();
box.getCenter(center);
centerRef.current.copy(center);
}
// Restaura
clone.rotation.copy(oldRot);
clone.scale.copy(oldScale);
}, [clone]);
// Aplica cor do modelo nos materiais do clone
useEffect(() => {
if (!clone || !model.color) return;
clone.traverse((child) => {
if (child instanceof THREE.Mesh) {
// Clone do material para evitar afetar cache global do R3F
if (Array.isArray(child.material)) {
child.material = child.material.map(m => m.clone());
} else {
child.material = child.material.clone();
}
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) {
mat.color.set(model.color);
mat.needsUpdate = true;
}
});
}
});
}, [clone, model.color]);
// Aplica transformações de Rotação e Escala compartilhadas no grupo externo de apresentação
useFrame(() => {
if (presentationGroupRef.current && presentationState) {
const { rotation, scale } = presentationState;
if (rotation) {
presentationGroupRef.current.rotation.x = rotation[0];
presentationGroupRef.current.rotation.y = rotation[1];
presentationGroupRef.current.rotation.z = rotation[2];
}
if (scale) {
presentationGroupRef.current.scale.setScalar(baseScaleRef.current * scale);
}
}
});
// Aplica Planos de Corte (clippingPlanes) nos materiais
useEffect(() => {
if (!clone || !presentationState) return;
const { sectionEnabled, sectionAxis, sectionLevel, sectionInvert } = presentationState;
if (!sectionEnabled) {
clone.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((mat) => {
if (mat) {
mat.clippingPlanes = [];
mat.needsUpdate = true;
}
});
}
});
return;
}
const normal = new THREE.Vector3();
if (sectionAxis === 'x') normal.set(sectionInvert ? -1 : 1, 0, 0);
else if (sectionAxis === 'y') normal.set(0, sectionInvert ? -1 : 1, 0);
else if (sectionAxis === 'z') normal.set(0, 0, sectionInvert ? -1 : 1);
// Ajusta a distância baseado no bounding box original e no nível do corte
const box = new THREE.Box3().setFromObject(clone);
const size = new THREE.Vector3();
box.getSize(size);
const minVal = sectionAxis === 'x' ? box.min.x : sectionAxis === 'y' ? box.min.y : box.min.z;
const maxVal = sectionAxis === 'x' ? box.max.x : sectionAxis === 'y' ? box.max.y : box.max.z;
const distance = minVal + (maxVal - minVal) * sectionLevel;
const plane = new THREE.Plane(normal, -distance);
clone.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((mat) => {
if (mat) {
mat.clippingPlanes = [plane];
mat.clipShadows = true;
mat.needsUpdate = true;
}
});
}
});
}, [clone, presentationState]);
// Translações, Rotações finas e Escala fina nativas da maquete vindas do Viewer
const fineTuning = model.fineTuning || { posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
const s = fineTuning.scale ?? 1;
// Quaternion de calibração nativo da maquete
const calQuatArr = model.calibrationQuat;
const calQuat = useMemo(() => {
if (!calQuatArr) return new THREE.Quaternion();
return new THREE.Quaternion(calQuatArr[0], calQuatArr[1], calQuatArr[2], calQuatArr[3]);
}, [calQuatArr]);
return (
// Grupo Externo de Apresentação Compartilhada
<group ref={presentationGroupRef}>
{/* Grupo de Translação Fina */}
<group position={[fineTuning.posX, fineTuning.posY, fineTuning.posZ]}>
{/* Grupo de Rotação Fina e Escala Fina */}
<group rotation={[rotXRad, rotYRad, rotZRad]} scale={[s, s, s]}>
{/* Grupo de Calibração */}
<group quaternion={calQuat}>
{/* Grupo de Centralização do Bounding Box (para girar em torno do seu centro geométrico) */}
<group position={[-centerRef.current.x, -centerRef.current.y, -centerRef.current.z]}>
<primitive object={clone} />
</group>
</group>
</group>
</group>
</group>
);
}
// Cenário da Sala de Reunião Clara
function ThreeMeetingRoomScene({ currentActiveModel, presentationState, isFocusMode }: { currentActiveModel: SceneModel | null; presentationState: PresentationState; isFocusMode: boolean }) {
const tableGeom = useMemo(() => {
// Geometria da mesa em U / Retangular de cantos arredondados
const shape = new THREE.Shape();
shape.moveTo(-1.6, -1.1);
shape.lineTo(1.6, -1.1);
shape.quadraticCurveTo(1.9, -1.1, 1.9, -0.8);
shape.lineTo(1.9, 0.8);
shape.quadraticCurveTo(1.9, 1.1, 1.6, 1.1);
shape.lineTo(-1.6, 1.1);
shape.quadraticCurveTo(-1.9, 1.1, -1.9, 0.8);
shape.lineTo(-1.9, -0.8);
shape.quadraticCurveTo(-1.9, -1.1, -1.6, -1.1);
const hole = new THREE.Path();
hole.absellipse(0, 0, 0.9, 0.45, 0, Math.PI * 2, true);
shape.holes.push(hole);
return new THREE.ExtrudeGeometry(shape, { depth: 0.06, bevelEnabled: true, bevelThickness: 0.01, bevelSize: 0.01, bevelSegments: 3 });
}, []);
return (
<group>
{/* Luzes da Sala / Foco */}
<ambientLight intensity={isFocusMode ? 0.7 : 0.8} />
<directionalLight position={isFocusMode ? [5, 5, 5] : [4, 6, 4]} intensity={1.5} castShadow={!isFocusMode} />
{isFocusMode && (
<color attach="background" args={['#090d16']} />
)}
{!isFocusMode && (
<>
{/* Piso Clara */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
<planeGeometry args={[12, 8]} />
<meshStandardMaterial color="#e2e8f0" roughness={0.4} metalness={0.1} />
</mesh>
{/* Tapete Central */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.001, 0]}>
<planeGeometry args={[4.8, 3.2]} />
<meshStandardMaterial color="#94a3b8" roughness={0.7} />
</mesh>
{/* Paredes Claras */}
<mesh position={[0, 2, -4]}>
<planeGeometry args={[12, 4]} />
<meshStandardMaterial color="#f1f5f9" roughness={0.9} />
</mesh>
{/* Janelas Panorâmicas */}
<mesh position={[-6, 2, 0]} rotation={[0, Math.PI / 2, 0]}>
<planeGeometry args={[8, 4]} />
<meshStandardMaterial color="#bae6fd" roughness={0.1} emissive="#bae6fd" emissiveIntensity={0.4} />
</mesh>
<mesh position={[6, 2, 0]} rotation={[0, -Math.PI / 2, 0]}>
<planeGeometry args={[8, 4]} />
<meshStandardMaterial color="#bae6fd" roughness={0.1} emissive="#bae6fd" emissiveIntensity={0.4} />
</mesh>
{/* Mesa Elíptica/Retangular com furo central (Madeira Mel Carvalho Clara) */}
<group position={[0, 0.8, 0]} rotation={[Math.PI / 2, 0, 0]}>
<mesh geometry={tableGeom} castShadow receiveShadow>
<meshStandardMaterial color="#d97706" roughness={0.2} metalness={0.1} />
</mesh>
</group>
{/* Pés da mesa (Cromados) */}
{[-1.3, 1.3].map((x) =>
[-0.7, 0.7].map((z) => (
<mesh key={`${x}-${z}`} position={[x, 0.4, z]} castShadow receiveShadow>
<cylinderGeometry args={[0.035, 0.035, 0.8]} />
<meshStandardMaterial color="#d4d4d8" metalness={0.9} roughness={0.1} />
</mesh>
))
)}
{/* Projetor de Holograma / Círculo centralizado desenhado na mesa */}
<mesh position={[0, 0.861, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.22, 0.25, 32]} />
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.6} />
</mesh>
</>
)}
{/* Modelo 3D da Maquete Holográfica */}
<group position={isFocusMode ? [0, 0, 0] : [0, 1.23, 0]}>
<ModelErrorBoundary fallback={
// Se falhar ao carregar o modelo (ex: blob local do host inacessível por convidados), renderiza o holograma geométrico
<group rotation={[0, Date.now() * 0.00012, 0]}>
<mesh position={[0, 0, 0]}>
<octahedronGeometry args={[0.22]} />
<meshStandardMaterial color="#f43f5e" transparent opacity={0.7} wireframe emissive="#f43f5e" emissiveIntensity={0.5} />
</mesh>
<mesh position={[0, -0.15, 0]}>
<boxGeometry args={[0.3, 0.02, 0.3]} />
<meshStandardMaterial color="#ef4444" transparent opacity={0.4} />
</mesh>
</group>
}>
<Suspense fallback={
<mesh>
<sphereGeometry args={[0.1, 16, 16]} />
<meshBasicMaterial color="#00f5ff" wireframe />
</mesh>
}>
{currentActiveModel ? (
<HologramModel model={currentActiveModel} presentationState={presentationState} />
) : (
// Holograma Padrão Geométrico se não houver maquete carregada
<group rotation={[0, Date.now() * 0.00012, 0]}>
<mesh position={[0, 0, 0]}>
<octahedronGeometry args={[0.22]} />
<meshStandardMaterial color="#0ea5e9" transparent opacity={0.7} wireframe emissive="#0ea5e9" emissiveIntensity={0.5} />
</mesh>
<mesh position={[0, 0, 0]} rotation={[0, Math.PI / 4, 0]}>
<boxGeometry args={[0.25, 0.04, 0.25]} />
<meshStandardMaterial color="#3b82f6" transparent opacity={0.4} emissive="#3b82f6" emissiveIntensity={0.2} />
</mesh>
</group>
)}
</Suspense>
</ModelErrorBoundary>
</group>
{/* Renderizar as Cadeiras Físicas da mesa - Ocultadas no Modo Foco */}
{!isFocusMode && CHAIRS.map((chair) => (
<group key={chair.id} position={[chair.pos[0], 0, chair.pos[2]]} rotation={[0, Math.atan2(chair.lookAt[0] - chair.pos[0], chair.lookAt[2] - chair.pos[2]), 0]}>
{/* Base giratória metálica */}
<mesh position={[0, 0.25, 0]}>
<cylinderGeometry args={[0.015, 0.015, 0.5]} />
<meshStandardMaterial color="#a1a1aa" metalness={0.9} roughness={0.1} />
</mesh>
{/* Pés base */}
<mesh position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0, 0.22, 4]} />
<meshStandardMaterial color="#71717a" metalness={0.85} roughness={0.2} />
</mesh>
{/* Assento */}
<mesh position={[0, 0.52, 0]}>
<boxGeometry args={[0.28, 0.05, 0.26]} />
<meshStandardMaterial color="#334155" roughness={0.7} />
</mesh>
{/* Encosto */}
<mesh position={[0, 0.78, -0.11]} rotation={[0.08, 0, 0]}>
<boxGeometry args={[0.26, 0.44, 0.04]} />
<meshStandardMaterial color="#334155" roughness={0.7} />
</mesh>
</group>
))}
{/* Iluminação Clara da Sala - Ocultada no Modo Foco */}
{!isFocusMode && (
<>
<hemisphereLight color="#ffffff" groundColor="#64748b" intensity={1.4} />
<directionalLight position={[6, 12, 6]} intensity={1.8} castShadow />
<directionalLight position={[-6, 8, -6]} intensity={0.5} />
<pointLight position={[0, 3.5, 0]} intensity={2.0} color="#0ea5e9" distance={8} />
</>
)}
</group>
);
}
// --- Componente Principal da Tela ---
export default function MeetingRoom() {
const isWebGLSupported = useWebGLSupport();
const { roomId } = useParams<{ roomId?: string }>();
const navigate = useNavigate();
const models = useModelStore((s) => s.models);
const activeModelId = useModelStore((s) => s.activeModelId);
const activeModel = useMemo(() => {
return models.find((m) => m.id === activeModelId) || null;
}, [models, activeModelId]);
// Estados do Lobby
const [inRoom, setInRoom] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [isFocusMode, setIsFocusMode] = useState(false);
const [name, setName] = useState(() => localStorage.getItem('tsxr_meeting_user_name') || '');
const [cargo, setCargo] = useState(() => localStorage.getItem('tsxr_meeting_user_cargo') || '');
const [empresa, setEmpresa] = useState(() => localStorage.getItem('tsxr_meeting_user_empresa') || '');
const [isPresenter, setIsPresenter] = useState(() => {
const saved = localStorage.getItem('tsxr_meeting_user_is_presenter');
if (saved !== null) return saved === 'true';
return !roomId;
});
const [selectedAvatar, setSelectedAvatar] = useState(() => Number(localStorage.getItem('tsxr_meeting_user_avatar')) || 1);
const [selectedChair, setSelectedChair] = useState(() => localStorage.getItem('tsxr_meeting_user_chair') || '1');
const [inputRoomId, setInputRoomId] = useState(roomId || '');
// Estados da Sala (Conexão Supabase)
const [peers, setPeers] = useState<Record<string, PeerData>>({});
// Lista de avatares permitidos (IDs de 1 a 20). Por padrão, todos estão ativos.
const [allowedAvatars, setAllowedAvatars] = useState<number[]>(() => {
return Array.from({ length: 20 }, (_, i) => i + 1);
});
const [isAvatarConfigOpen, setIsAvatarConfigOpen] = useState(false);
const updateAllowedAvatars = async (newAllowed: number[]) => {
setAllowedAvatars(newAllowed);
if (isPresenter && channelRef.current) {
console.log("⚙️ [MeetingRoom] Atualizando metadados de Presence com avatares permitidos:", newAllowed);
await channelRef.current.track({
username: name.substring(0, 10),
avatarId: selectedAvatar,
chairId: selectedChair,
role: isPresenter ? 'presenter' : 'guest',
cargo: cargo.trim() || 'Colaborador',
empresa: empresa.trim() || 'SteelXR Corp',
joinedAt: new Date().toISOString(),
allowedAvatars: newAllowed
});
}
};
const mergedPeers = useMemo(() => {
if (!inRoom) return peers;
// Se o usuário local já estiver no peers (via sync do Supabase), usamos o peers original
if (peers[selectedChair]) {
return peers;
}
// Caso contrário, injetamos o usuário local no dicionário temporário
return {
...peers,
[selectedChair]: {
presence_ref: 'local_user',
username: name,
avatarId: selectedAvatar,
chairId: selectedChair,
role: isPresenter ? 'presenter' : 'guest',
cargo: cargo || 'Apresentador',
empresa: empresa || 'SteelXR Corp',
joinedAt: new Date().toISOString()
} as unknown as PeerData
};
}, [peers, inRoom, selectedChair, name, selectedAvatar, isPresenter, cargo, empresa]);
const activeUsersCount = useMemo(() => Object.keys(mergedPeers).length, [mergedPeers]);
const [chatMessages, setChatMessages] = useState<{ id: string; sender: string; text: string; time: string }[]>([]);
const [newMessage, setNewMessage] = useState('');
const [lastChatSender, setLastChatSender] = useState<string | null>(null);
const [isMuted, setIsMuted] = useState(false);
const [isTalking, setIsTalking] = useState(false);
const [roomCode, setRoomCode] = useState(roomId || '');
const [activeTab, setActiveTab] = useState<'chat' | 'participants'>('participants');
// Estado compartilhado da Apresentação da maquete
const [presentationState, setPresentationState] = useState<PresentationState>({
rotation: [0, 0, 0],
scale: 1,
sectionEnabled: false,
sectionAxis: 'y',
sectionLevel: 0.0,
sectionInvert: false
});
// Carrega modificações da peça vindas do Viewer se houver
useEffect(() => {
const savedState = localStorage.getItem('tsxr_initial_meeting_state');
if (savedState) {
try {
const parsed = JSON.parse(savedState) as PresentationState;
setPresentationState(parsed);
console.log('🔄 [MeetingRoom] Estado inicial carregado do Viewer:', parsed);
} catch (err) {
console.error('Falha ao carregar estado inicial do Viewer', err);
}
localStorage.removeItem('tsxr_initial_meeting_state');
}
}, []);
const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
const chatScrollRef = useRef<HTMLDivElement>(null);
// Se o roomId mudar na url, atualiza o checkbox isPresenter e o inputRoomId
useEffect(() => {
setIsPresenter(!roomId);
setInputRoomId(roomId || '');
}, [roomId]);
// Simulação de detecção de áudio
useEffect(() => {
if (!inRoom || isMuted) {
setIsTalking(false);
return;
}
const interval = setInterval(() => {
setIsTalking(Math.random() > 0.7);
}, 1500);
return () => clearInterval(interval);
}, [inRoom, isMuted]);
useEffect(() => {
if (chatScrollRef.current) {
chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight;
}
}, [chatMessages]);
// --- ESCUTA DO LOBBY EM TEMPO REAL ---
// Conecta ao canal antes do lobby finalizar para ler assentos e avatares ocupados na sala digitada
useEffect(() => {
if (!inputRoomId || inputRoomId.trim().length < 3 || inRoom || isConnecting) {
setPeers({});
return;
}
const roomIdUpper = inputRoomId.trim().toUpperCase();
console.log(`🔌 [Lobby Watcher] Escutando sala: ${roomIdUpper}`);
const tempChannel = supabase.channel(`meeting_room_${roomIdUpper}`, {
config: {
presence: { key: 'lobby_watcher_' + Math.random().toString(36).substring(3, 8) }
}
});
tempChannel
.on('presence', { event: 'sync' }, () => {
const state = tempChannel.presenceState();
console.log("👥 [Lobby Watcher Sync] Estado de presença bruto:", state);
const formattedPeers: Record<string, PeerData> = {};
let remoteAllowed: number[] | null = null;
Object.keys(state).forEach((key) => {
if (key.startsWith('lobby_watcher_')) return;
const presences = state[key] as unknown as PeerData[];
if (presences && presences.length > 0) {
presences.forEach((presence) => {
if (presence && presence.chairId && presence.username) {
formattedPeers[presence.chairId] = {
...presence,
avatarId: Number(presence.avatarId) || 1,
cargo: presence.cargo || 'Colaborador',
empresa: presence.empresa || 'SteelXR Corp',
role: presence.role || 'guest'
};
if (presence.role === 'presenter' && Array.isArray(presence.allowedAvatars)) {
remoteAllowed = presence.allowedAvatars;
}
}
});
}
});
console.log("👥 [Lobby Watcher Sync] Peers formatados:", formattedPeers);
setPeers(formattedPeers);
if (remoteAllowed) {
console.log("⚙️ [Lobby Watcher Sync] Sincronizados avatares permitidos remotos:", remoteAllowed);
setAllowedAvatars(remoteAllowed);
}
})
.subscribe();
return () => {
console.log(`🔌 [Lobby Watcher] Desinscrevendo escuta passiva da sala: ${roomIdUpper}`);
tempChannel.unsubscribe();
supabase.removeChannel(tempChannel);
};
}, [inputRoomId, inRoom, isConnecting]);
// Envia as atualizações de apresentação via Broadcast para a sala inteira
const handlePresentationChange = (updatedFields: Partial<PresentationState>) => {
if (!isPresenter || !channelRef.current) return;
const store = useModelStore.getState();
const currentModel = store.models.find(m => m.id === store.activeModelId) || null;
const newState = {
...presentationState,
...updatedFields,
modelUrl: currentModel?.url,
modelFileName: currentModel?.fileName,
modelFileSize: currentModel?.fileSize
} as PresentationState;
setPresentationState(newState);
channelRef.current.send({
type: 'broadcast',
event: 'presentation_update',
payload: newState
});
};
// Inicializa o Supabase Presence & Broadcast para conexões oficiais
const connectToRoom = async (targetRoomId: string) => {
if (!name.trim()) {
toast.error('Digite seu nome primeiro');
return;
}
// Salva no localStorage para auto-preenchimento conveniente
localStorage.setItem('tsxr_meeting_user_name', name);
localStorage.setItem('tsxr_meeting_user_cargo', cargo);
localStorage.setItem('tsxr_meeting_user_empresa', empresa);
localStorage.setItem('tsxr_meeting_user_avatar', String(selectedAvatar));
localStorage.setItem('tsxr_meeting_user_chair', selectedChair);
localStorage.setItem('tsxr_meeting_user_is_presenter', String(isPresenter));
// Validar se o avatar já está em uso por outra pessoa
const avatarInUse = Object.values(peers).find((p) => p.avatarId === selectedAvatar);
if (avatarInUse) {
toast.error(`O avatar "${AVATAR_PRESETS[selectedAvatar - 1].name}" já está em uso por ${avatarInUse.username}. Escolha outro!`);
return;
}
// Validar se a cadeira está ocupada
if (Object.keys(peers).includes(selectedChair)) {
toast.error('O assento selecionado já está ocupado por outro participante. Por favor, selecione outro assento.');
return;
}
// Ativa o estado de conexão e aguarda 120ms para a escuta do lobby se desmontar totalmente
setIsConnecting(true);
await new Promise((resolve) => setTimeout(resolve, 120));
const finalRoomId = targetRoomId.trim().toUpperCase() || Math.random().toString(36).substring(3, 8).toUpperCase();
setRoomCode(finalRoomId);
// Evita o erro "cannot add presence callbacks ... after subscribe()" removendo canais antigos com o mesmo tópico
const oldChannels = supabase.getChannels().filter(c => c.topic === `realtime:meeting_room_${finalRoomId}`);
for (const old of oldChannels) {
console.log(`🔌 [MeetingRoom] Removendo canal antigo ativo para evitar conflitos: ${old.topic}`);
await supabase.removeChannel(old);
}
console.log(`🔌 [MeetingRoom] Conectando à sala: ${finalRoomId} como ${isPresenter ? 'Apresentador' : 'Convidado'}`);
const channel = supabase.channel(`meeting_room_${finalRoomId}`, {
config: {
broadcast: { self: true }
},
});
channelRef.current = channel;
// Sincroniza participantes online com tolerância a múltiplos formatos e falhas de conexão
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
console.log("👥 [Presence Sync] Estado de presença bruto da reunião:", state);
const formattedPeers: Record<string, PeerData> = {};
let remoteAllowed: number[] | null = null;
Object.keys(state).forEach((key) => {
if (key.startsWith('lobby_watcher_')) return;
const presences = state[key] as unknown as PeerData[];
if (presences && presences.length > 0) {
presences.forEach((presence) => {
if (presence && presence.chairId && presence.username) {
formattedPeers[presence.chairId] = {
...presence,
avatarId: Number(presence.avatarId) || 1,
cargo: presence.cargo || 'Colaborador',
empresa: presence.empresa || 'SteelXR Corp',
role: presence.role || 'guest'
};
if (presence.role === 'presenter' && Array.isArray(presence.allowedAvatars)) {
remoteAllowed = presence.allowedAvatars;
}
}
});
}
});
console.log("👥 [Presence Sync] Peers formatados:", formattedPeers);
setPeers(formattedPeers);
if (!isPresenter && remoteAllowed) {
console.log("⚙️ [Presence Sync] Sincronizando avatares permitidos remotos na sala conectada:", remoteAllowed);
setAllowedAvatars(remoteAllowed);
}
})
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
if (key.startsWith('lobby_watcher_')) return;
toast.info(`"${(newPresences[0] as unknown as PeerData)?.username}" sentou-se no assento ${key}`);
})
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
if (key.startsWith('lobby_watcher_')) return;
toast.info(`"${(leftPresences[0] as unknown as PeerData)?.username}" liberou o assento ${key}`);
});
// Escuta mensagens do Chat via Broadcast
channel.on('broadcast', { event: 'chat_msg' }, (payload) => {
setChatMessages((prev) => [
...prev,
{
id: Math.random().toString(),
sender: payload.payload.sender,
text: payload.payload.text,
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
},
]);
setLastChatSender(payload.payload.sender);
});
// Escuta atualizações de Apresentação da Maquete via Broadcast (apenas para convidados)
channel.on('broadcast', { event: 'presentation_update' }, (payload) => {
if (!isPresenter) {
const data = payload.payload as PresentationState;
setPresentationState(data);
// Se o apresentador propagar um modelo remoto que o convidado não tem ativo, carrega
if (data.modelUrl && data.modelFileName) {
const store = useModelStore.getState();
const hasModel = store.models.some(m => m.fileName === data.modelFileName);
if (!hasModel) {
console.log(`📥 [MeetingRoom] Carregando modelo remoto do apresentador: ${data.modelFileName}`);
useModelStore.getState().addModel({
fileName: data.modelFileName,
fileSize: data.modelFileSize || 0,
url: data.modelUrl
});
} else {
const existing = store.models.find(m => m.fileName === data.modelFileName);
if (existing && store.activeModelId !== existing.id) {
useModelStore.getState().setActiveModel(existing.id);
}
}
}
}
});
// Inscrever no WebSocket
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
// Envia as informações completas para o Presence do Supabase
await channel.track({
username: name.substring(0, 10),
avatarId: selectedAvatar,
chairId: selectedChair,
role: isPresenter ? 'presenter' : 'guest',
cargo: cargo.trim() || 'Colaborador',
empresa: empresa.trim() || 'SteelXR Corp',
joinedAt: new Date().toISOString(),
allowedAvatars: allowedAvatars
});
// Se for o apresentador, propaga o estado inicial de apresentação (vinda do Viewer) para todos
if (isPresenter) {
const store = useModelStore.getState();
const currentModel = store.models.find(m => m.id === store.activeModelId) || null;
channel.send({
type: 'broadcast',
event: 'presentation_update',
payload: {
...presentationState,
modelUrl: currentModel?.url,
modelFileName: currentModel?.fileName,
modelFileSize: currentModel?.fileSize
}
});
}
setInRoom(true);
setIsConnecting(false);
toast.success(`Entrou na sala de reunião: ${finalRoomId}`);
navigate(`/meeting/${finalRoomId}`, { replace: true });
} else if (status === 'CHANNEL_ERROR') {
setIsConnecting(false);
toast.error('Erro ao conectar ao canal de reuniões.');
}
});
};
// Enviar Mensagem de Chat
const handleSendMessage = () => {
if (!newMessage.trim() || !channelRef.current) return;
const senderName = name.substring(0, 10);
channelRef.current.send({
type: 'broadcast',
event: 'chat_msg',
payload: {
sender: senderName,
text: newMessage.trim(),
},
});
setChatMessages((prev) => [
...prev,
{
id: Math.random().toString(),
sender: senderName,
text: newMessage.trim(),
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
},
]);
setLastChatSender(senderName);
setNewMessage('');
};
// Sair da Sala de Reunião
const handleLeaveRoom = async () => {
if (channelRef.current) {
await supabase.removeChannel(channelRef.current);
}
setInRoom(false);
setIsConnecting(false);
setPeers({});
setChatMessages([]);
navigate('/');
};
const occupiedChairs = useMemo(() => {
return Object.keys(peers).filter(k => !k.startsWith('lobby_watcher_'));
}, [peers]);
// Checar se o avatar selecionado no lobby está em uso
const isAvatarInUse = useMemo(() => {
return Object.values(peers).find((p) => p.avatarId === selectedAvatar);
}, [peers, selectedAvatar]);
// Lista de avatares disponíveis para este usuário escolher
const availableAvatars = useMemo(() => {
if (isPresenter) {
return AVATAR_PRESETS.map((p) => p.id);
}
// Para convidados, filtra apenas os IDs permitidos pelo apresentador
return AVATAR_PRESETS.map((p) => p.id).filter((id) => allowedAvatars.includes(id));
}, [isPresenter, allowedAvatars]);
// Se o selectedAvatar atual não estiver na lista de permitidos, selecionamos o primeiro disponível
useEffect(() => {
if (availableAvatars.length > 0 && !availableAvatars.includes(selectedAvatar)) {
setSelectedAvatar(availableAvatars[0]);
}
}, [availableAvatars, selectedAvatar]);
const nextAvatar = () => {
if (availableAvatars.length === 0) return;
const currentIndex = availableAvatars.indexOf(selectedAvatar);
const nextIndex = (currentIndex + 1) % availableAvatars.length;
setSelectedAvatar(availableAvatars[nextIndex]);
};
const prevAvatar = () => {
if (availableAvatars.length === 0) return;
const currentIndex = availableAvatars.indexOf(selectedAvatar);
const prevIndex = (currentIndex - 1 + availableAvatars.length) % availableAvatars.length;
setSelectedAvatar(availableAvatars[prevIndex]);
};
// --- Renderização do Lobby ---
if (!inRoom) {
const currentAvatar = AVATAR_PRESETS[selectedAvatar - 1];
const canJoin = name.trim().length > 0 && !isAvatarInUse && !occupiedChairs.includes(selectedChair);
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background grid-industrial p-6">
<div className="absolute top-6 left-6 flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate('/')} className="text-muted-foreground hover:text-foreground">
<Home className="h-5 w-5" />
</Button>
<span className="font-mono text-xs uppercase tracking-widest text-muted-foreground">TrackSteelXR</span>
</div>
<div className="w-full max-w-4xl rounded-2xl border border-border bg-card/65 p-6 md:p-10 shadow-2xl backdrop-blur-md animate-in fade-in zoom-in-95 duration-200">
<div className="mb-6 text-center">
<h2 className="text-2xl font-bold tracking-tight text-foreground md:text-3xl">
Lobby de <span className="text-primary">Reunião Virtual</span>
</h2>
<p className="mt-1 text-xs font-mono uppercase tracking-wider text-muted-foreground">
Configure suas credenciais e escolha seu avatar
</p>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
{/* Seletor de Avatar (Lado Esquerdo - 5 colunas) */}
<div className="flex flex-col items-center justify-center rounded-xl border bg-muted/30 p-5 md:col-span-5 relative">
<span className="absolute top-3 left-4 font-mono text-[9px] uppercase tracking-widest text-muted-foreground">Visual Avatar</span>
{/* Enquadramento centralizado perfeito (Camera apontando e fov adequado) */}
<div className="h-40 w-full flex items-center justify-center relative bg-slate-900/60 rounded-lg overflow-hidden border border-border">
{isWebGLSupported ? (
<Canvas camera={{ position: [0, 0.11, 0.28], fov: 42 }}>
<ambientLight intensity={0.8} />
<directionalLight position={[2, 3, 2]} intensity={1.5} />
<pointLight position={[0, 0.15, 0.2]} intensity={1} color={currentAvatar.color} />
<MiiAvatar avatarId={selectedAvatar} username={name || 'Você'} position={[0, -0.04, 0]} isTalking={true} scale={0.6} />
</Canvas>
) : (
<div className="flex flex-col items-center justify-center h-full w-full bg-slate-950/80 p-4 text-center">
<ShieldAlert className="h-7 w-7 text-amber-500 animate-pulse mb-1" />
<span className="font-mono text-[9px] font-bold text-amber-500 uppercase tracking-wider">WebGL Desativado</span>
<span className="text-[8px] text-slate-400 mt-1 max-w-[180px] leading-relaxed">
Aceleração por hardware inativa. O avatar 3D não pôde ser pré-visualizado.
</span>
</div>
)}
{/* Aviso se o avatar estiver em uso na sala */}
{isAvatarInUse && (
<div className="absolute inset-0 bg-black/75 backdrop-blur-sm flex flex-col items-center justify-center text-center p-3 animate-in fade-in duration-200">
<ShieldAlert className="h-7 w-7 text-destructive animate-bounce mb-1" />
<span className="font-mono text-[10px] font-bold text-destructive uppercase tracking-wider">Avatar em Uso</span>
<span className="text-[9px] text-slate-300 mt-1 max-w-xs px-2 leading-snug">
Este visual está ocupado por <strong className="text-primary uppercase block truncate">{isAvatarInUse.username}</strong>
</span>
</div>
)}
<Button variant="ghost" size="icon" className="absolute left-2 h-8 w-8 rounded-full border bg-background/50 hover:bg-background z-10" onClick={prevAvatar}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="absolute right-2 h-8 w-8 rounded-full border bg-background/50 hover:bg-background z-10" onClick={nextAvatar}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mt-3 text-center w-full px-2">
<span className="font-mono text-xs font-bold uppercase tracking-wider text-primary">
{currentAvatar.name}
</span>
<p className="text-[9px] text-muted-foreground mt-0.5">{currentAvatar.desc}</p>
{isPresenter && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsAvatarConfigOpen(true)}
className="mt-3 w-full font-mono text-[9px] uppercase tracking-wider gap-1 border-primary/40 hover:bg-primary/10 text-primary-foreground bg-primary/5 h-8"
>
<Settings className="h-3.5 w-3.5" />
Gerenciar Avatares ({allowedAvatars.length} ativos)
</Button>
)}
</div>
{/* Formulário de Identificação: Nome, Cargo e Empresa agrupados sequencialmente na esquerda */}
<div className="mt-4 w-full space-y-3">
<div className="space-y-1">
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground block">Seu Nome (Máx 10 letras)</label>
<Input
type="text"
maxLength={10}
placeholder="Nome do usuário"
value={name}
onChange={(e) => setName(e.target.value)}
className="font-semibold text-center uppercase font-mono tracking-wider text-xs h-9"
/>
</div>
<div className="space-y-1">
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground block">Cargo / Função</label>
<Input
type="text"
placeholder="Ex: Eng. Civil"
value={cargo}
onChange={(e) => setCargo(e.target.value)}
className="text-xs h-9 text-center"
/>
</div>
<div className="space-y-1">
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground block">Empresa</label>
<Input
type="text"
placeholder="Ex: Steel Co."
value={empresa}
onChange={(e) => setEmpresa(e.target.value)}
className="text-xs h-9 text-center"
/>
</div>
</div>
</div>
{/* Seleção do Assento, Host e Conexão (Lado Direito - 7 colunas) */}
<div className="flex flex-col justify-between md:col-span-7 space-y-5">
{/* Seleção do Assento (Exibe quem está ocupando em tempo real) */}
<div className="space-y-2">
<span className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">Selecione seu Assento na Mesa</span>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{CHAIRS.map((chair) => {
const occupier = Object.values(peers).find((p) => p.chairId === chair.id);
const isOccupied = !!occupier;
const isSelected = selectedChair === chair.id;
return (
<Button
key={chair.id}
variant={isSelected ? 'default' : 'outline'}
className={`h-12 flex-col p-1 gap-0.5 text-[9px] font-mono ${
isOccupied ? 'border-destructive/40 text-destructive bg-destructive/10 cursor-not-allowed hover:bg-destructive/10' : ''
}`}
disabled={isOccupied}
onClick={() => setSelectedChair(chair.id)}
>
<span className="font-semibold">{chair.name}</span>
<span className={`text-[8px] truncate max-w-full font-bold uppercase ${
isOccupied ? 'text-destructive' : isSelected ? 'text-primary-foreground' : 'text-muted-foreground'
}`}>
{isOccupied ? occupier.username : isSelected ? 'Escolhido' : 'Livre'}
</span>
</Button>
);
})}
</div>
</div>
{/* Papel e Código de Conexão */}
<div className="rounded-xl border bg-muted/20 p-4 space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="presenter-mode"
checked={isPresenter}
onCheckedChange={(checked) => setIsPresenter(!!checked)}
/>
<label
htmlFor="presenter-mode"
className="text-xs font-mono font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-foreground cursor-pointer"
>
Entrar como Apresentador (Host) da Sala
</label>
</div>
<div className="space-y-1.5">
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">Código da Sala (Vazio para criar nova)</label>
<Input
type="text"
placeholder="Ex: TS-X9F"
value={inputRoomId}
onChange={(e) => setInputRoomId(e.target.value.toUpperCase())}
className="font-mono tracking-widest font-bold text-center text-xs h-9"
/>
</div>
<div className="flex gap-2.5 pt-1">
<Button variant="outline" className="flex-1 font-mono text-xs h-10" onClick={() => navigate('/')}>
Cancelar
</Button>
<Button
className="flex-1 glow-primary font-mono text-xs h-10 gap-2"
disabled={!canJoin}
onClick={() => connectToRoom(inputRoomId)}
>
Acessar Reunião <ArrowRight className="h-4 w-4" />
</Button>
</div>
{isAvatarInUse && (
<p className="text-[10px] text-destructive font-mono text-center">
* Escolha outro avatar para liberar o acesso.
</p>
)}
</div>
</div>
</div>
</div>
{/* Modal de Configuração de Avatares Permitidos (Apenas para o Apresentador) */}
{isAvatarConfigOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="w-full max-w-3xl rounded-xl border border-primary/30 bg-slate-900/90 p-6 md:p-8 shadow-2xl backdrop-blur-md flex flex-col max-h-[85vh] animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-start border-b border-slate-800 pb-4 mb-4">
<div>
<h3 className="text-lg font-bold text-primary font-mono uppercase tracking-wider">Avatares da Reunião</h3>
<p className="text-[10px] text-muted-foreground font-mono mt-0.5 uppercase tracking-wide">
Selecione entre 4 e 10 avatares que estarão disponíveis para a reunião
</p>
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-xs px-2 py-0.5 rounded-full bg-slate-800 border text-slate-300">
{allowedAvatars.length} Selecionados
</span>
</div>
</div>
{/* Grid dos Avatares para Seleção */}
<div className="flex-1 overflow-y-auto pr-1 py-1 grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-3">
{AVATAR_PRESETS.map((preset) => {
const isSelected = allowedAvatars.includes(preset.id);
const isMale = preset.id <= 10;
return (
<div
key={preset.id}
onClick={() => {
if (isSelected) {
// Se desmarcar, verifica se fica com menos de 4
if (allowedAvatars.length <= 4) {
toast.warning('A reunião deve conter pelo menos 4 avatares disponíveis.');
return;
}
updateAllowedAvatars(allowedAvatars.filter(id => id !== preset.id));
} else {
// Se marcar, verifica se excede 10
if (allowedAvatars.length >= 10) {
toast.warning('Você pode selecionar no máximo 10 avatares.');
return;
}
updateAllowedAvatars([...allowedAvatars, preset.id]);
}
}}
className={`relative flex flex-col items-center justify-center p-3 rounded-lg border-2 cursor-pointer transition-all duration-200 hover:scale-105 select-none ${
isSelected
? 'border-primary bg-primary/10 shadow-[0_0_10px_rgba(59,130,246,0.3)]'
: 'border-slate-800 bg-slate-950/40 hover:border-slate-700 hover:bg-slate-950/70'
}`}
>
{/* Badge Sexo */}
<span className={`absolute top-1 right-1.5 font-mono text-[7px] px-1 rounded uppercase font-bold ${
isMale ? 'bg-blue-500/20 text-blue-400' : 'bg-pink-500/20 text-pink-400'
}`}>
{isMale ? 'M' : 'F'}
</span>
{/* Miniatura da Imagem Local */}
<div className="w-14 h-14 bg-slate-900/60 rounded-full border overflow-hidden flex items-center justify-center mb-2">
<img
src={preset.url}
alt={preset.name}
className="w-12 h-12 object-contain"
/>
</div>
<span className="font-mono text-[10px] font-bold text-slate-200">{preset.name}</span>
<span className="text-[8px] text-slate-400 text-center mt-0.5 line-clamp-1 leading-snug">{preset.desc}</span>
{/* Check Icon */}
{isSelected && (
<div className="absolute -bottom-1.5 -right-1 bg-primary text-primary-foreground rounded-full p-0.5 shadow-md">
<Check className="h-3 w-3" />
</div>
)}
</div>
);
})}
</div>
{/* Botão de Fechar / Salvar */}
<div className="border-t border-slate-800 pt-4 mt-4 flex justify-end gap-3">
<Button
type="button"
variant="default"
onClick={() => {
if (allowedAvatars.length < 4 || allowedAvatars.length > 10) {
toast.error('Selecione entre 4 e 10 avatares antes de continuar.');
return;
}
setIsAvatarConfigOpen(false);
toast.success('Configuração de avatares atualizada com sucesso!');
}}
className="font-mono text-xs glow-primary px-6 animate-pulse"
>
Salvar Escolhas
</Button>
</div>
</div>
</div>
)}
</div>
);
}
// --- Renderização da Sala de Reunião Ativa ---
return (
<div className="flex h-screen overflow-hidden bg-slate-950 text-white relative animate-in fade-in duration-350">
{/* 3D Canvas - Sala Clara */}
<div className="flex-1 h-full relative">
{isWebGLSupported ? (
<Canvas camera={{ position: [0, 2.5, 2.8], fov: 45 }} shadows gl={{ localClippingEnabled: true }}>
<ThreeMeetingRoomScene currentActiveModel={activeModel} presentationState={presentationState} isFocusMode={isFocusMode} />
{/* Renderizar avatares de TODOS na sala - Ocultados se modo foco */}
{!isFocusMode && Object.keys(mergedPeers).map((chairKey) => {
const peer = mergedPeers[chairKey];
const chairConf = CHAIRS.find((c) => c.id === peer.chairId);
if (!chairConf) return null;
const isLocalUser = peer.chairId === selectedChair;
return (
<MiiAvatar
key={peer.presence_ref || 'local'}
avatarId={peer.avatarId}
username={isLocalUser ? `${peer.username} (Você)` : peer.username}
position={chairConf.pos}
rotation={chairConf.rot}
isTalking={isLocalUser ? isTalking : (isTalking && Math.random() > 0.5)}
isLastChatSender={peer.username === lastChatSender}
scale={4.8}
/>
);
})}
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.05}
minDistance={0.5}
maxDistance={8}
maxPolarAngle={Math.PI / 2 - 0.05}
target={isFocusMode ? [0, 0, 0] : [0, 0.95, 0]}
/>
</Canvas>
) : (
<div className="flex-1 h-full flex flex-col items-center justify-center p-8 bg-slate-900 border border-slate-800 rounded-xl m-4 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 opacity-95 z-0" />
<div className="relative z-10 max-w-md text-center space-y-4">
<div className="mx-auto w-16 h-16 rounded-full bg-amber-500/10 border border-amber-500/30 flex items-center justify-center animate-pulse">
<ShieldAlert className="h-8 w-8 text-amber-500" />
</div>
<div className="space-y-2">
<h3 className="text-xl font-bold tracking-tight text-slate-100 font-mono uppercase">WebGL Requerido</h3>
<p className="text-xs text-slate-400 leading-relaxed">
Aceleração por hardware (WebGL) está desativada ou não é suportada por seu navegador/dispositivo atual.
</p>
</div>
<div className="bg-slate-950/80 rounded-lg p-4 text-left border border-slate-800 text-[11px] font-mono text-slate-400 space-y-2">
<span className="text-amber-500 font-bold uppercase block text-[10px] tracking-wider mb-1">Como resolver no Chrome:</span>
<p>1. em <code className="text-primary">chrome://settings/system</code></p>
<p>2. Ative <code className="text-primary">"Usar aceleração de hardware quando disponível"</code></p>
<p>3. Reinicie o navegador e recarregue esta página.</p>
</div>
<div className="text-[10px] text-muted-foreground italic">
* Nota: O chat lateral, lista de participantes e chamadas continuam funcionando normalmente.
</div>
</div>
</div>
)}
{/* Painel do Apresentador (Controles de Manipulação Sincronizados) */}
{isPresenter && (
<div className="absolute left-4 bottom-16 w-80 rounded-xl border border-primary/30 bg-slate-900/95 p-4 shadow-2xl backdrop-blur z-20 space-y-4 pointer-events-auto">
<div className="flex items-center justify-between border-b border-slate-800 pb-2">
<span className="font-mono text-xs font-bold text-primary flex items-center gap-1.5">
<Award className="h-4 w-4 animate-pulse" /> Painel do Apresentador
</span>
<span className="font-mono text-[8px] bg-emerald-500/25 text-emerald-400 px-1.5 py-0.5 rounded uppercase font-semibold">Sincronizado</span>
</div>
{/* Rotação Y */}
<div className="space-y-1">
<div className="flex justify-between text-[10px] font-mono text-muted-foreground">
<span>Giro da Peça (Horizontal)</span>
<span>{Math.round((presentationState.rotation[1] * 180) / Math.PI)}°</span>
</div>
<input
type="range"
min={-Math.PI}
max={Math.PI}
step={0.05}
title="Giro Horizontal"
value={presentationState.rotation[1]}
onChange={(e) => handlePresentationChange({ rotation: [0, parseFloat(e.target.value), 0] })}
className="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
{/* Escala */}
<div className="space-y-1">
<div className="flex justify-between text-[10px] font-mono text-muted-foreground">
<span>Escala (Zoom da Peça)</span>
<span>{presentationState.scale.toFixed(1)}x</span>
</div>
<input
type="range"
min={0.3}
max={3.0}
step={0.1}
title="Zoom da Peça"
value={presentationState.scale}
onChange={(e) => handlePresentationChange({ scale: parseFloat(e.target.value) })}
className="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
{/* Plano de Corte */}
<div className="space-y-2 pt-1 border-t border-slate-800">
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] text-muted-foreground">Corte de Seção (Clipping)</span>
<input
type="checkbox"
id="section-cuts-enable"
title="Habilitar Plano de Corte"
checked={presentationState.sectionEnabled}
onChange={(e) => handlePresentationChange({ sectionEnabled: e.target.checked })}
className="rounded border-slate-800 bg-slate-950 accent-primary cursor-pointer h-3.5 w-3.5"
/>
</div>
{presentationState.sectionEnabled && (
<div className="space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-150">
{/* Seletor Eixo */}
<div className="flex gap-2">
{(['x', 'y', 'z'] as const).map((ax) => (
<Button
key={ax}
size="sm"
variant={presentationState.sectionAxis === ax ? 'default' : 'outline'}
className="flex-1 h-7 text-[9px] font-mono uppercase"
onClick={() => handlePresentationChange({ sectionAxis: ax })}
>
Eixo {ax}
</Button>
))}
</div>
{/* Slider de Nível */}
<div className="space-y-1">
<div className="flex justify-between text-[9px] font-mono text-muted-foreground">
<span>Posição do Corte</span>
<span>{(presentationState.sectionLevel * 100).toFixed(0)}%</span>
</div>
<input
type="range"
min={-0.5}
max={0.5}
step={0.01}
title="Nível de Corte"
value={presentationState.sectionLevel}
onChange={(e) => handlePresentationChange({ sectionLevel: parseFloat(e.target.value) })}
className="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
{/* Inverter Corte */}
<div className="flex items-center justify-between">
<label htmlFor="invert-section" className="text-[9px] font-mono text-muted-foreground cursor-pointer">
Inverter Direção do Corte
</label>
<input
type="checkbox"
id="invert-section"
title="Inverter Plano de Corte"
checked={presentationState.sectionInvert}
onChange={(e) => handlePresentationChange({ sectionInvert: e.target.checked })}
className="rounded border-slate-800 bg-slate-950 accent-primary cursor-pointer h-3 w-3"
/>
</div>
</div>
)}
</div>
{/* Botão de Reset */}
<Button
variant="outline"
size="sm"
className="w-full h-8 font-mono text-[9px] gap-1.5"
onClick={() => handlePresentationChange({
rotation: [0, 0, 0],
scale: 1,
sectionEnabled: false,
sectionAxis: 'y',
sectionLevel: 0.0,
sectionInvert: false
})}
>
<RotateCw className="h-3 w-3" /> Resetar Apresentação
</Button>
{/* Botão de Apresentação no Viewer */}
<Button
variant="default"
size="sm"
className="w-full h-9 font-mono text-[10px] gap-1.5 bg-sky-500 hover:bg-sky-600 text-slate-950 font-bold glow-primary"
onClick={() => navigate(`/viewer?room=${roomCode}`)}
>
<Box className="h-3.5 w-3.5" /> Manipular Peça no Viewer
</Button>
</div>
)}
{/* HUD Superior */}
<div className="absolute top-4 left-4 right-4 pointer-events-none flex justify-between items-start z-10">
<div className="pointer-events-auto flex items-center gap-2 rounded-lg border border-border bg-slate-900/90 backdrop-blur-md px-3.5 py-2 shadow-2xl">
<Users className="h-4 w-4 text-primary" />
<div className="flex flex-col">
<span className="font-mono text-[9px] text-muted-foreground uppercase">Reunião Conectada</span>
<span className="font-mono text-xs font-semibold text-foreground">
Online: {activeUsersCount}/8
</span>
</div>
</div>
<div className="pointer-events-auto flex items-center gap-3">
{/* Código da sala */}
<div className="flex items-center gap-1.5 rounded-lg border border-border bg-slate-900/90 backdrop-blur-md px-3 py-1.5 shadow-2xl">
<span className="font-mono text-[10px] text-muted-foreground">CÓDIGO:</span>
<span className="font-mono text-xs font-bold text-primary tracking-widest">{roomCode}</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => {
navigator.clipboard.writeText(window.location.href);
toast.success('Link de convite copiado!');
}}
title="Copiar link do convite"
>
<Share2 className="h-3 w-3" />
</Button>
</div>
{/* Sair */}
<Button variant="destructive" size="sm" className="font-mono text-[10px] gap-1.5 h-8 px-3" onClick={handleLeaveRoom}>
<LogOut className="h-3.5 w-3.5" /> Sair
</Button>
</div>
</div>
{/* HUD Inferior - Controles de Áudio & Modo Foco */}
<div className="absolute bottom-4 left-4 pointer-events-none z-10 flex gap-2">
<Button
variant={isMuted ? 'destructive' : 'default'}
size="icon"
className="pointer-events-auto h-10 w-10 rounded-xl shadow-2xl border"
onClick={() => setIsMuted(!isMuted)}
title={isMuted ? 'Ativar microfone' : 'Mutar microfone'}
>
{isMuted ? <MicOff className="h-4.5 w-4.5" /> : <Mic className="h-4.5 w-4.5" />}
</Button>
<Button
variant={isFocusMode ? 'secondary' : 'outline'}
size="icon"
className={`pointer-events-auto h-10 w-10 rounded-xl shadow-2xl border ${
isFocusMode
? 'bg-sky-500 hover:bg-sky-600 text-slate-950 border-sky-400'
: 'bg-slate-900/90 text-white border-slate-700 hover:bg-slate-800'
}`}
onClick={() => setIsFocusMode(!isFocusMode)}
title={isFocusMode ? 'Voltar para Sala Virtual (Minimizar)' : 'Focar na Peça (Tela Cheia)'}
>
{isFocusMode ? <Minimize2 className="h-4.5 w-4.5" /> : <Maximize2 className="h-4.5 w-4.5" />}
</Button>
<div className="pointer-events-auto rounded-xl border bg-slate-900/90 backdrop-blur-md px-4 py-2 flex flex-col justify-center shadow-2xl">
<span className="font-mono text-[8px] text-muted-foreground uppercase">
{isPresenter ? 'Apresentação Ativa' : 'Observador'}
</span>
<span className="text-[10px] text-foreground font-mono">
{isPresenter
? 'Você está transmitindo os cortes e rotações da maquete.'
: 'Aguarde as rotações e planos de corte efetuados pelo Host.'}
</span>
</div>
</div>
</div>
{/* Painel Lateral Direito (Abas Chat / Participantes) */}
<div className="w-80 border-l border-slate-800 bg-slate-900/95 flex flex-col h-full z-10 shadow-2xl">
{/* Seletor de Abas */}
<div className="flex border-b border-slate-800 bg-slate-950/40">
<Button
variant="ghost"
className={`flex-1 rounded-none border-b-2 font-mono text-[10px] uppercase h-12 gap-1.5 ${
activeTab === 'participants' ? 'border-primary text-primary bg-slate-900/40' : 'border-transparent text-muted-foreground'
}`}
onClick={() => setActiveTab('participants')}
>
<Users className="h-3.5 w-3.5" /> Membros ({activeUsersCount})
</Button>
<Button
variant="ghost"
className={`flex-1 rounded-none border-b-2 font-mono text-[10px] uppercase h-12 gap-1.5 ${
activeTab === 'chat' ? 'border-primary text-primary bg-slate-900/40' : 'border-transparent text-muted-foreground'
}`}
onClick={() => setActiveTab('chat')}
>
<MessageSquare className="h-3.5 w-3.5" /> Chat
</Button>
</div>
{/* ABA PARTICIPANTES */}
{activeTab === 'participants' && (
<div className="flex-1 overflow-y-auto p-4 space-y-3 scrollbar-thin">
<span className="font-mono text-[9px] text-muted-foreground uppercase tracking-widest block mb-2">Quem está na sala</span>
<div className="space-y-2.5">
{Object.keys(mergedPeers).map((chairKey) => {
const peer = mergedPeers[chairKey];
const isHost = peer.role === 'presenter';
const isMe = peer.chairId === selectedChair;
return (
<div key={peer.presence_ref || 'local'} className="rounded-lg border border-slate-800 bg-slate-950/40 p-3 flex items-start gap-3 relative overflow-hidden">
{/* Indicador da cor do avatar do Mii via classe estática Tailwind */}
<div className={`h-8 w-8 rounded-full border border-slate-800 flex items-center justify-center font-bold text-xs ${getAvatarBgClass(peer.avatarId)}`}>
{peer.username.substring(0, 2).toUpperCase()}
</div>
{/* Detalhes do Usuário */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-mono text-xs font-bold text-slate-100 truncate block">
{peer.username} {isMe && '(Você)'}
</span>
{isHost && (
<span className="text-[8px] font-mono bg-primary/20 text-primary px-1.5 py-0.5 rounded uppercase font-semibold">
Host
</span>
)}
</div>
{/* Cargo e Empresa */}
<span className="font-mono text-[9.5px] text-muted-foreground block truncate mt-0.5 uppercase">
💼 {peer.cargo}
</span>
<span className="font-mono text-[9px] text-primary/75 block truncate uppercase">
🏢 {peer.empresa}
</span>
</div>
<div className="absolute top-2 right-2.5 font-mono text-[8px] text-slate-600">
Chair {peer.chairId}
</div>
</div>
);
})}
</div>
</div>
)}
{/* ABA CHAT */}
{activeTab === 'chat' && (
<>
<div ref={chatScrollRef} className="flex-1 overflow-y-auto p-4 space-y-3 scrollbar-thin">
{chatMessages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-slate-500 text-center px-4 space-y-2">
<MessageSquare className="h-8 w-8 opacity-30 text-primary" />
<p className="text-[10px] font-mono leading-relaxed">
Nenhuma mensagem enviada. Seja o primeiro a digitar no chat da reunião!
</p>
</div>
) : (
chatMessages.map((msg) => (
<div key={msg.id} className="space-y-0.5">
<div className="flex items-baseline justify-between">
<span className="font-mono text-[10px] font-bold text-primary uppercase">{msg.sender}</span>
<span className="text-[8px] text-slate-500 font-mono">{msg.time}</span>
</div>
<p className="text-xs bg-slate-800/60 border border-slate-700/30 rounded-lg p-2 leading-relaxed text-slate-300">
{msg.text}
</p>
</div>
))
)}
</div>
{/* Input do Chat */}
<div className="p-3 border-t border-slate-800 bg-slate-900 flex gap-2">
<Input
type="text"
placeholder="Digite sua mensagem..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSendMessage();
}}
className="flex-1 text-xs border-slate-800 bg-slate-950/65"
/>
<Button variant="default" size="icon" className="h-9 w-9 bg-primary" onClick={handleSendMessage}>
<Send className="h-3.5 w-3.5" />
</Button>
</div>
</>
)}
</div>
</div>
);
}