Spaces:
Running
Running
| import { useEffect, useRef, useState, useCallback, useMemo } from 'react' | |
| import { useNavigate, useLocation } from 'react-router-dom' | |
| import { buildCustoms, waitForIceGathering, PARTICIPANTS, RECORDING_CONFIG, WEBHOOK_URL, VX_SERVER } from '../lib/webrtc' | |
| import { decodeSessionConfig } from '../lib/sessionConfig' | |
| import { COMPANIES } from '../lib/companies' | |
| const DEFAULT_KEY = import.meta.env.VITE_FLOW_API_KEY || '' | |
| export default function Room({ sessionId, state }) { | |
| const navigate = useNavigate() | |
| const location = useLocation() | |
| // Config resolution: state (dev shortcut) OR URL ?cfg= param (normal interviewee flow) | |
| const config = useMemo(() => { | |
| if (state?.name || state?.candidateName) return state | |
| const encoded = new URLSearchParams(location.search).get('cfg') | |
| return encoded ? decodeSessionConfig(encoded) : null | |
| }, [state, location.search]) | |
| const name = config?.candidateName || config?.name || '' | |
| const companyId = typeof config?.company === 'string' ? config.company : config?.company?.id | |
| const companyObj = companyId ? (COMPANIES[companyId] || config?.company) : config?.company | |
| const apiKey = config?.apiKey || DEFAULT_KEY | |
| const server = config?.server || VX_SERVER | |
| const voiceSettings = config?.voiceSettings || null | |
| // Interviewee opened via URL → show join screen before connecting | |
| const isUrlFlow = !state?.name && !state?.candidateName | |
| const [started, setStarted] = useState(!isUrlFlow) | |
| const watchUrl = `${window.location.origin}${window.location.pathname}#/watch/${sessionId}` | |
| const pcRef = useRef(null) | |
| const streamRef = useRef(null) | |
| const audioCtxRef = useRef(null) | |
| const userVideoRef = useRef(null) | |
| const aiVideoRef = useRef(null) | |
| const aiAudioRef = useRef(null) | |
| const chatEndRef = useRef(null) | |
| const [connState, setConnState] = useState('connecting') | |
| const [aiState, setAiState] = useState('waiting') | |
| const [userSpeaking, setUserSpeaking] = useState(false) | |
| const [aiSpeaking, setAiSpeaking] = useState(false) | |
| const [micMuted, setMicMuted] = useState(false) | |
| const [hasUserVid, setHasUserVid] = useState(false) | |
| const [hasAiVid, setHasAiVid] = useState(false) | |
| const [elapsed, setElapsed] = useState(0) | |
| const [chatMessages, setChatMessages] = useState([]) | |
| const [copied, setCopied] = useState(false) | |
| // Timer — only runs once started | |
| useEffect(() => { | |
| if (!started) return | |
| const t = setInterval(() => setElapsed(s => s + 1), 1000) | |
| return () => clearInterval(t) | |
| }, [started]) | |
| useEffect(() => { | |
| chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) | |
| }, [chatMessages]) | |
| // SSE — subscribe once we have a session | |
| useEffect(() => { | |
| if (!started) return | |
| const es = new EventSource(`${WEBHOOK_URL}/events?session=${sessionId}`) | |
| es.onmessage = e => { | |
| try { | |
| const data = JSON.parse(e.data) | |
| const responses = data.responses || [] | |
| const msgs = [] | |
| // User transcript — non-streaming entry with out.user_input | |
| const userEntry = responses.find(r => !r.streaming && r.out?.user_input) | |
| if (userEntry) { | |
| const text = userEntry.out.user_input | |
| .replace(/<turn-visual-context>[\s\S]*?<\/turn-visual-context>/g, '') | |
| .replace(/<non-verbal-elements>[\s\S]*?<\/non-verbal-elements>/g, '') | |
| .trim() | |
| if (text) msgs.push({ role: 'user', text }) | |
| } | |
| // AI text — main LLM only (prev_llm is audio filler, not transcript) | |
| const aiRaw = responses | |
| .filter(r => r.streaming && r.chunk && r.node === 'llm') | |
| .map(r => r.chunk) | |
| .join('') | |
| const aiText = aiRaw | |
| .split(/<\|speak\|>/) | |
| .map(seg => seg.replace(/<\|hangup\|>\S*/g, '').trim()) | |
| .filter(Boolean) | |
| .join(' ') | |
| .trim() | |
| if (aiText) msgs.push({ role: 'ai', text: aiText }) | |
| if (msgs.length) setChatMessages(prev => [...prev, ...msgs]) | |
| } catch {} | |
| } | |
| return () => es.close() | |
| }, [sessionId, started]) | |
| // VAD | |
| const startVAD = useCallback((analyser, threshold, onSpeak, onSilence) => { | |
| const buf = new Uint8Array(analyser.fftSize) | |
| let speakFrames = 0, silentFrames = 0 | |
| const tick = () => { | |
| analyser.getByteTimeDomainData(buf) | |
| let sum = 0 | |
| for (let i = 0; i < buf.length; i++) { const v = (buf[i] - 128) / 128; sum += v * v } | |
| const rms = Math.sqrt(sum / buf.length) * 100 | |
| if (rms > threshold) { | |
| speakFrames++; silentFrames = 0 | |
| if (speakFrames > 3) onSpeak() | |
| } else { | |
| silentFrames++; speakFrames = 0 | |
| if (silentFrames > 8) onSilence() | |
| } | |
| requestAnimationFrame(tick) | |
| } | |
| tick() | |
| }, []) | |
| const setupUserVAD = useCallback((stream) => { | |
| const ctx = audioCtxRef.current | |
| const src = ctx.createMediaStreamSource(stream) | |
| const an = ctx.createAnalyser(); an.fftSize = 512; an.smoothingTimeConstant = 0.7 | |
| src.connect(an) | |
| startVAD(an, 12, () => setUserSpeaking(true), () => setUserSpeaking(false)) | |
| }, [startVAD]) | |
| const setupAIVAD = useCallback((stream) => { | |
| if (!audioCtxRef.current) audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)() | |
| const ctx = audioCtxRef.current | |
| const src = ctx.createMediaStreamSource(stream) | |
| const an = ctx.createAnalyser(); an.fftSize = 512; an.smoothingTimeConstant = 0.7 | |
| src.connect(an) | |
| startVAD(an, 10, | |
| () => { setAiSpeaking(true); setAiState('speaking') }, | |
| () => { setAiSpeaking(false); setAiState('listening') } | |
| ) | |
| }, [startVAD]) | |
| // WebRTC bootstrap — only runs when started | |
| useEffect(() => { | |
| if (!started || !name || !companyObj) return | |
| let cancelled = false | |
| let reconnectAttempts = 0 | |
| let reconnectTimer = null | |
| const MAX_RECONNECTS = 5 | |
| async function connect(stream) { | |
| if (cancelled) return | |
| let iceServers = [] | |
| try { | |
| const r = await fetch(`${server}/rtc/ice-servers`, { signal: AbortSignal.timeout(3000) }) | |
| if (r.ok) { const d = await r.json(); iceServers = d.ice_servers || [] } | |
| } catch {} | |
| const pc = new RTCPeerConnection({ iceServers }) | |
| pcRef.current = pc | |
| stream.getTracks().forEach(t => pc.addTrack(t, stream)) | |
| pc.ontrack = e => { | |
| if (e.track.kind === 'audio') { | |
| aiAudioRef.current.srcObject = e.streams[0] | |
| setupAIVAD(e.streams[0]) | |
| } else if (e.track.kind === 'video') { | |
| aiVideoRef.current.srcObject = e.streams[0] | |
| setHasAiVid(true) | |
| } | |
| } | |
| pc.onconnectionstatechange = () => { | |
| const st = pc.connectionState | |
| if (st === 'connected') { reconnectAttempts = 0; setConnState('connected') } | |
| if (st === 'failed' || st === 'disconnected') { | |
| pc.close() | |
| if (!cancelled && reconnectAttempts < MAX_RECONNECTS) { | |
| reconnectAttempts++ | |
| setConnState('connecting') | |
| reconnectTimer = setTimeout(() => connect(stream), Math.min(1000 * reconnectAttempts, 5000)) | |
| } else if (!cancelled) { | |
| setConnState('failed') | |
| } | |
| } | |
| } | |
| const offer = await pc.createOffer() | |
| await pc.setLocalDescription(offer) | |
| await waitForIceGathering(pc) | |
| const customs = buildCustoms(companyId, name, voiceSettings) | |
| let resp | |
| try { | |
| resp = await fetch(`${server}/rtc/offer/audio`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', 'api_key': apiKey }, | |
| body: JSON.stringify({ | |
| sdp: pc.localDescription.sdp, | |
| type: pc.localDescription.type, | |
| participants: PARTICIPANTS, | |
| customs, | |
| session_id: sessionId, | |
| ...RECORDING_CONFIG, | |
| }), | |
| }) | |
| } catch { | |
| if (!cancelled && reconnectAttempts < MAX_RECONNECTS) { | |
| reconnectAttempts++ | |
| setConnState('connecting') | |
| reconnectTimer = setTimeout(() => connect(stream), Math.min(1000 * reconnectAttempts, 5000)) | |
| } else { | |
| setConnState('failed') | |
| } | |
| return | |
| } | |
| if (!resp.ok) { setConnState('failed'); return } | |
| const answer = await resp.json() | |
| await pc.setRemoteDescription(new RTCSessionDescription(answer)) | |
| } | |
| async function start() { | |
| let stream | |
| try { | |
| stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }) | |
| } catch { | |
| try { stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }) } | |
| catch { setConnState('failed'); return } | |
| } | |
| if (cancelled) { stream.getTracks().forEach(t => t.stop()); return } | |
| streamRef.current = stream | |
| if (stream.getVideoTracks().length) { | |
| userVideoRef.current.srcObject = stream | |
| setHasUserVid(true) | |
| } | |
| audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)() | |
| setupUserVAD(stream) | |
| connect(stream) | |
| } | |
| start() | |
| return () => { | |
| cancelled = true | |
| clearTimeout(reconnectTimer) | |
| cleanup() | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [started]) | |
| function cleanup() { | |
| streamRef.current?.getTracks().forEach(t => t.stop()) | |
| pcRef.current?.close() | |
| pcRef.current = null | |
| audioCtxRef.current?.close() | |
| audioCtxRef.current = null | |
| } | |
| function toggleMic() { | |
| if (!streamRef.current) return | |
| const next = !micMuted | |
| setMicMuted(next) | |
| streamRef.current.getAudioTracks().forEach(t => { t.enabled = !next }) | |
| if (next) setUserSpeaking(false) | |
| } | |
| function leave() { | |
| cleanup() | |
| navigate('/') | |
| } | |
| function copyLink() { | |
| navigator.clipboard.writeText(watchUrl).then(() => { | |
| setCopied(true) | |
| setTimeout(() => setCopied(false), 2000) | |
| }) | |
| } | |
| const fmtTime = s => { | |
| const m = Math.floor(s / 60).toString().padStart(2, '0') | |
| const ss = (s % 60).toString().padStart(2, '0') | |
| return `${m}:${ss}` | |
| } | |
| // ── Invalid link ───────────────────────────────────────────────────────────── | |
| if (!config || !companyObj || !name) { | |
| return ( | |
| <div style={j.page}> | |
| <div style={j.inner}> | |
| <p style={j.errorTitle}>Invalid interview link.</p> | |
| <button style={j.backBtn} onClick={() => navigate('/')}>← home</button> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ── Interviewee join screen ─────────────────────────────────────────────────── | |
| if (!started) { | |
| return ( | |
| <div style={j.page}> | |
| <div style={{ ...j.glow, background: `radial-gradient(ellipse 60% 50% at 30% 50%, ${companyObj.accent}08 0%, transparent 70%)` }} /> | |
| <div style={j.inner}> | |
| <div style={{ ...j.iconBox, background: `${companyObj.accent}18`, color: companyObj.accent }}> | |
| {companyObj.icon} | |
| </div> | |
| <p style={{ ...j.pill, borderColor: `${companyObj.accent}30`, color: companyObj.accent }}> | |
| Mock interview | |
| </p> | |
| <h1 style={j.title}> | |
| Ready when<br /> | |
| <span style={{ color: companyObj.accent }}>you are.</span> | |
| </h1> | |
| <div style={j.metaRow}> | |
| <div style={j.metaItem}> | |
| <span style={j.metaLabel}>Company</span> | |
| <span style={j.metaValue}>{companyObj.name}</span> | |
| </div> | |
| <div style={j.metaDivider} /> | |
| <div style={j.metaItem}> | |
| <span style={j.metaLabel}>Candidate</span> | |
| <span style={j.metaValue}>{name}</span> | |
| </div> | |
| </div> | |
| <p style={j.hint}> | |
| The AI interviewer will open the session. This is a practice run — speak naturally and take your time. | |
| Your microphone and camera will be requested. | |
| </p> | |
| <button | |
| style={{ ...j.beginBtn, background: companyObj.accent }} | |
| onClick={() => setStarted(true)} | |
| > | |
| Begin interview → | |
| </button> | |
| <p style={j.fine}>Everything stays within this session.</p> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ── Interview room ──────────────────────────────────────────────────────────── | |
| const statusLabel = { connecting: 'Connecting…', connected: 'Connected', failed: 'Connection lost' } | |
| const aiStateLabel = { waiting: 'Waiting', listening: 'Listening', thinking: 'Thinking…', speaking: 'Speaking' } | |
| return ( | |
| <div style={s.root}> | |
| {/* topbar */} | |
| <header style={s.topbar}> | |
| <div style={s.topLeft}> | |
| <span style={s.sessionLabel}>Mock Interview</span> | |
| <span style={{ ...s.companyChip, color: companyObj.accent, borderColor: `${companyObj.accent}40` }}> | |
| {companyObj.name} | |
| </span> | |
| </div> | |
| <div style={s.topRight}> | |
| <span style={s.timer}>{fmtTime(elapsed)}</span> | |
| <span style={{ ...s.connBadge, ...(connState === 'connected' ? s.connGreen : connState === 'failed' ? s.connRed : s.connYellow) }}> | |
| {connState} | |
| </span> | |
| </div> | |
| </header> | |
| {/* body: stage + chat */} | |
| <div style={s.body}> | |
| {/* stage */} | |
| <main style={s.stage}> | |
| <div style={s.stageGlow1} /> | |
| <div style={s.stageGlow2} /> | |
| {/* user card */} | |
| <div style={s.participantCol}> | |
| <div style={{ ...s.avatarWrap, ...(hasUserVid ? s.videoWrap : {}) }}> | |
| {userSpeaking && !micMuted && !hasUserVid && ( | |
| <> | |
| <div style={{ ...s.ring, borderColor: 'var(--user-ring)', animation: 'pulse-ring 1.4s ease-out infinite' }} /> | |
| <div style={{ ...s.ringOuter, borderColor: 'var(--user-ring)', animation: 'pulse-ring 1.4s ease-out .35s infinite', opacity: .4 }} /> | |
| </> | |
| )} | |
| <div style={{ ...s.avatar, ...(hasUserVid ? s.videoAvatar : {}), borderColor: userSpeaking && !micMuted ? 'var(--user-ring)' : 'var(--border2)' }}> | |
| <video ref={userVideoRef} autoPlay playsInline muted style={{ ...s.selfVideo, display: hasUserVid ? 'block' : 'none' }} /> | |
| {!hasUserVid && (userSpeaking && !micMuted | |
| ? <MicBars color="var(--user-ring)" /> | |
| : <span style={s.avatarIcon}>👤</span> | |
| )} | |
| </div> | |
| </div> | |
| <span style={s.participantName}>{name}</span> | |
| <span style={s.participantRole}>candidate</span> | |
| </div> | |
| <div style={s.divider} /> | |
| {/* AI card */} | |
| <div style={s.participantCol}> | |
| <div style={{ ...s.avatarWrap, ...(hasAiVid ? s.videoWrap : {}) }}> | |
| {aiSpeaking && !hasAiVid && ( | |
| <> | |
| <div style={{ ...s.ring, borderColor: 'var(--ai-ring)', animation: 'pulse-ring 1.4s ease-out infinite' }} /> | |
| <div style={{ ...s.ringOuter, borderColor: 'var(--ai-ring)', animation: 'pulse-ring 1.4s ease-out .35s infinite', opacity: .4 }} /> | |
| </> | |
| )} | |
| <div style={{ ...s.avatar, ...(hasAiVid ? s.videoAvatar : {}), borderColor: aiSpeaking ? 'var(--ai-ring)' : 'var(--border2)' }}> | |
| <video ref={aiVideoRef} autoPlay playsInline muted style={{ ...s.aiVideo, display: hasAiVid ? 'block' : 'none' }} /> | |
| {!hasAiVid && (aiSpeaking | |
| ? <WaveBars color="var(--ai-ring)" /> | |
| : <span style={s.avatarIcon}>◈</span> | |
| )} | |
| </div> | |
| </div> | |
| <span style={s.participantName}>{companyObj.name} Interviewer</span> | |
| <span style={s.participantRole}>AI</span> | |
| </div> | |
| </main> | |
| {/* live chat panel */} | |
| <aside style={s.chatPanel}> | |
| <div style={s.observerBar}> | |
| <div style={s.observerBarLeft}> | |
| <span style={s.observerLabel}>Observer link</span> | |
| <span style={s.observerUrl}>{watchUrl}</span> | |
| </div> | |
| <button | |
| style={{ ...s.copyBtn, ...(copied ? s.copyBtnDone : {}) }} | |
| onClick={copyLink} | |
| > | |
| {copied ? 'Copied!' : 'Copy'} | |
| </button> | |
| </div> | |
| <div style={s.chatHeader}> | |
| <span style={s.chatHeaderLabel}>Live Transcript</span> | |
| {chatMessages.length > 0 && ( | |
| <span style={s.chatCount}>{chatMessages.length}</span> | |
| )} | |
| </div> | |
| <div style={s.chatScroll}> | |
| {chatMessages.length === 0 | |
| ? <p style={s.chatEmpty}>Conversation will appear here…</p> | |
| : chatMessages.map((msg, i) => ( | |
| <div key={i} style={{ ...s.chatMsg, ...(msg.role === 'user' ? s.chatMsgUser : s.chatMsgAi) }}> | |
| <span style={{ ...s.chatRole, color: msg.role === 'user' ? 'var(--user-ring)' : 'var(--ai-ring)' }}> | |
| {msg.role === 'user' ? name : 'Interviewer'} | |
| </span> | |
| <div style={{ ...s.chatBubble, ...(msg.role === 'user' ? s.chatBubbleUser : s.chatBubbleAi) }}> | |
| {msg.text} | |
| </div> | |
| </div> | |
| )) | |
| } | |
| <div ref={chatEndRef} /> | |
| </div> | |
| </aside> | |
| </div> | |
| {/* status row */} | |
| <div style={s.statusRow}> | |
| <div style={{ ...s.dot, background: connState === 'connected' ? 'var(--accent)' : connState === 'failed' ? 'var(--danger)' : '#f0c060', animation: connState === 'connecting' ? 'blink .8s step-end infinite' : 'none' }} /> | |
| <span style={s.statusText}>{statusLabel[connState]}</span> | |
| </div> | |
| {/* controls */} | |
| <footer style={s.controls}> | |
| <button style={{ ...s.ctrlBtn, ...(micMuted ? s.ctrlMuted : {}) }} onClick={toggleMic} title={micMuted ? 'Unmute' : 'Mute'}> | |
| {micMuted ? <MicOffIcon /> : <MicIcon />} | |
| </button> | |
| <div style={{ ...s.aiChip, ...(aiState === 'speaking' ? s.aiChipSpeak : aiState === 'thinking' ? s.aiChipThink : aiState === 'listening' ? s.aiChipListen : {}) }}> | |
| <div style={{ ...s.aiChipDot, background: aiState === 'speaking' ? 'var(--ai-ring)' : aiState === 'thinking' ? '#f0c060' : aiState === 'listening' ? 'var(--accent)' : 'var(--muted)', animation: aiState === 'thinking' ? 'blink .5s step-end infinite' : 'none' }} /> | |
| <span>{aiStateLabel[aiState]}</span> | |
| </div> | |
| <button style={{ ...s.ctrlBtn, ...s.ctrlLeave }} onClick={leave} title="Leave"> | |
| <LeaveIcon /> | |
| </button> | |
| </footer> | |
| <audio ref={aiAudioRef} autoPlay /> | |
| </div> | |
| ) | |
| } | |
| // ── Sub-components ──────────────────────────────────────────────────────────── | |
| function WaveBars({ color }) { | |
| const heights = [14, 28, 20, 34, 18] | |
| return ( | |
| <div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 36 }}> | |
| {heights.map((h, i) => ( | |
| <div key={i} style={{ width: 4, minHeight: 4, borderRadius: 2, background: color, animation: `wave-bounce 0.8s ease-in-out ${i * 0.1}s infinite`, '--h': `${h}px` }} /> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| function MicBars({ color }) { | |
| const heights = [12, 26, 18, 30, 14] | |
| return ( | |
| <div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 36 }}> | |
| {heights.map((h, i) => ( | |
| <div key={i} style={{ width: 4, minHeight: 4, borderRadius: 2, background: color, animation: `wave-bounce 0.6s ease-in-out ${i * 0.08}s infinite`, '--h': `${h}px` }} /> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| function MicIcon() { | |
| return ( | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M12 2a3 3 0 0 1 3 3v7a3 3 0 0 1-6 0V5a3 3 0 0 1 3-3z"/> | |
| <path d="M19 10v2a7 7 0 0 1-14 0v-2"/> | |
| <line x1="12" y1="19" x2="12" y2="22"/> | |
| <line x1="8" y1="22" x2="16" y2="22"/> | |
| </svg> | |
| ) | |
| } | |
| function MicOffIcon() { | |
| return ( | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> | |
| <line x1="1" y1="1" x2="23" y2="23"/> | |
| <path d="M9 9v3a3 3 0 0 0 5.12 2.12"/> | |
| <path d="M15 9.34V5a3 3 0 0 0-5.94-.6"/> | |
| <path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/> | |
| <line x1="12" y1="19" x2="12" y2="22"/> | |
| <line x1="8" y1="22" x2="16" y2="22"/> | |
| </svg> | |
| ) | |
| } | |
| function LeaveIcon() { | |
| return ( | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/> | |
| <polyline points="16 17 21 12 16 7"/> | |
| <line x1="21" y1="12" x2="9" y2="12"/> | |
| </svg> | |
| ) | |
| } | |
| // ── Styles ──────────────────────────────────────────────────────────────────── | |
| // Join screen styles | |
| const j = { | |
| page: { | |
| minHeight: '100vh', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| padding: '48px 20px', | |
| position: 'relative', | |
| overflow: 'hidden', | |
| }, | |
| glow: { position: 'fixed', inset: 0, pointerEvents: 'none' }, | |
| inner: { | |
| width: '100%', | |
| maxWidth: 420, | |
| display: 'flex', | |
| flexDirection: 'column', | |
| position: 'relative', | |
| zIndex: 1, | |
| }, | |
| iconBox: { | |
| width: 56, | |
| height: 56, | |
| borderRadius: 14, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| fontSize: 24, | |
| marginBottom: 20, | |
| }, | |
| pill: { | |
| display: 'inline-block', | |
| alignSelf: 'flex-start', | |
| fontFamily: 'var(--mono)', | |
| fontSize: 9, | |
| letterSpacing: '.2em', | |
| textTransform: 'uppercase', | |
| border: '1px solid', | |
| padding: '4px 10px', | |
| borderRadius: 20, | |
| marginBottom: 20, | |
| }, | |
| title: { | |
| fontSize: 'clamp(32px, 6vw, 50px)', | |
| fontWeight: 800, | |
| lineHeight: .95, | |
| letterSpacing: '-.03em', | |
| marginBottom: 28, | |
| }, | |
| metaRow: { | |
| display: 'flex', | |
| alignItems: 'stretch', | |
| gap: 0, | |
| background: 'var(--surface)', | |
| border: '1px solid var(--border2)', | |
| borderRadius: 10, | |
| overflow: 'hidden', | |
| marginBottom: 28, | |
| }, | |
| metaItem: { | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: 4, | |
| padding: '14px 18px', | |
| flex: 1, | |
| }, | |
| metaDivider: { | |
| width: 1, | |
| background: 'var(--border2)', | |
| flexShrink: 0, | |
| }, | |
| metaLabel: { | |
| fontFamily: 'var(--mono)', | |
| fontSize: 8, | |
| letterSpacing: '.2em', | |
| textTransform: 'uppercase', | |
| color: 'var(--muted)', | |
| }, | |
| metaValue: { | |
| fontFamily: 'var(--sans)', | |
| fontSize: 14, | |
| fontWeight: 700, | |
| color: 'var(--text)', | |
| }, | |
| hint: { | |
| fontFamily: 'var(--mono)', | |
| fontSize: 11, | |
| color: 'var(--muted)', | |
| lineHeight: 1.7, | |
| letterSpacing: '.04em', | |
| marginBottom: 32, | |
| }, | |
| beginBtn: { | |
| color: '#0a0a0a', | |
| border: 'none', | |
| fontFamily: 'var(--sans)', | |
| fontSize: 15, | |
| fontWeight: 700, | |
| letterSpacing: '.06em', | |
| padding: '18px 28px', | |
| borderRadius: 8, | |
| cursor: 'pointer', | |
| marginBottom: 16, | |
| }, | |
| fine: { | |
| fontFamily: 'var(--mono)', | |
| fontSize: 10, | |
| color: 'var(--muted)', | |
| letterSpacing: '.1em', | |
| textAlign: 'center', | |
| }, | |
| errorTitle: { | |
| fontFamily: 'var(--mono)', | |
| fontSize: 13, | |
| color: 'var(--muted)', | |
| marginBottom: 16, | |
| }, | |
| backBtn: { | |
| fontFamily: 'var(--mono)', | |
| fontSize: 11, | |
| color: 'var(--muted)', | |
| background: 'none', | |
| border: 'none', | |
| cursor: 'pointer', | |
| letterSpacing: '.08em', | |
| padding: 0, | |
| }, | |
| } | |
| // Room styles | |
| const s = { | |
| root: { height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }, | |
| topbar: { | |
| display: 'flex', alignItems: 'center', justifyContent: 'space-between', | |
| padding: '0 28px', height: 56, borderBottom: '1px solid var(--border)', flexShrink: 0, | |
| }, | |
| topLeft: { display: 'flex', alignItems: 'center', gap: 12 }, | |
| topRight: { display: 'flex', alignItems: 'center', gap: 12 }, | |
| sessionLabel: { fontFamily: 'var(--mono)', fontSize: 11, letterSpacing: '.18em', color: 'var(--muted)', textTransform: 'uppercase' }, | |
| companyChip: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.14em', textTransform: 'uppercase', padding: '3px 9px', borderRadius: 20, border: '1px solid' }, | |
| timer: { fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--muted)', letterSpacing: '.1em' }, | |
| connBadge: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.12em', textTransform: 'uppercase', padding: '3px 9px', borderRadius: 20, border: '1px solid var(--border2)', color: 'var(--muted)' }, | |
| connGreen: { color: 'var(--accent)', borderColor: 'var(--accent)' }, | |
| connRed: { color: 'var(--danger)', borderColor: 'var(--danger)' }, | |
| connYellow:{ color: '#f0c060', borderColor: '#f0c06040' }, | |
| body: { flex: 1, display: 'flex', overflow: 'hidden' }, | |
| stage: { | |
| flex: 1, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| gap: 48, | |
| position: 'relative', | |
| overflow: 'hidden', | |
| }, | |
| stageGlow1: { position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 50% 40% at 30% 60%, rgba(200,240,96,.04) 0%, transparent 70%)', pointerEvents: 'none' }, | |
| stageGlow2: { position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 40% 40% at 70% 40%, rgba(96,160,240,.03) 0%, transparent 70%)', pointerEvents: 'none' }, | |
| participantCol: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, position: 'relative', zIndex: 1 }, | |
| avatarWrap: { position: 'relative', width: 120, height: 120 }, | |
| videoWrap: { width: 160, height: 120 }, | |
| ring: { position: 'absolute', inset: -8, borderRadius: '50%', border: '2px solid', opacity: .6 }, | |
| ringOuter: { position: 'absolute', inset: -16, borderRadius: '50%', border: '1px solid' }, | |
| avatar: { | |
| width: '100%', height: '100%', | |
| borderRadius: '50%', | |
| border: '2px solid', | |
| background: 'var(--surface)', | |
| display: 'flex', alignItems: 'center', justifyContent: 'center', | |
| overflow: 'hidden', | |
| transition: 'border-color .2s', | |
| }, | |
| videoAvatar: { borderRadius: 12 }, | |
| avatarIcon: { fontSize: 32 }, | |
| selfVideo: { width: '100%', height: '100%', objectFit: 'cover' }, | |
| aiVideo: { width: '100%', height: '100%', objectFit: 'cover' }, | |
| participantName: { fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 600, color: 'var(--text)' }, | |
| participantRole: { fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '.2em', textTransform: 'uppercase', color: 'var(--muted)' }, | |
| divider: { width: 1, height: 80, background: 'var(--border)' }, | |
| chatPanel: { | |
| width: 300, | |
| flexShrink: 0, | |
| borderLeft: '1px solid var(--border)', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| overflow: 'hidden', | |
| background: 'var(--surface)', | |
| }, | |
| observerBar: { | |
| display: 'flex', alignItems: 'center', justifyContent: 'space-between', | |
| padding: '10px 14px', | |
| borderBottom: '1px solid var(--border)', | |
| gap: 8, | |
| flexShrink: 0, | |
| }, | |
| observerBarLeft: { display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 }, | |
| observerLabel: { fontFamily: 'var(--mono)', fontSize: 8, letterSpacing: '.18em', textTransform: 'uppercase', color: 'var(--muted)' }, | |
| observerUrl: { fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, | |
| copyBtn: { fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '.1em', padding: '4px 10px', borderRadius: 5, border: '1px solid var(--border2)', background: 'transparent', color: 'var(--text)', cursor: 'pointer', flexShrink: 0, transition: 'border-color .15s, color .15s' }, | |
| copyBtnDone: { color: 'var(--accent)', borderColor: 'var(--accent)' }, | |
| chatHeader: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderBottom: '1px solid var(--border)', flexShrink: 0 }, | |
| chatHeaderLabel: { fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '.18em', textTransform: 'uppercase', color: 'var(--muted)' }, | |
| chatCount: { fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--muted)', background: 'var(--border2)', padding: '1px 7px', borderRadius: 10 }, | |
| chatScroll: { flex: 1, overflowY: 'auto', padding: '14px', display: 'flex', flexDirection: 'column', gap: 12 }, | |
| chatEmpty: { fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--muted)', letterSpacing: '.08em', textAlign: 'center', paddingTop: 24 }, | |
| chatMsg: { display: 'flex', flexDirection: 'column', gap: 4 }, | |
| chatMsgUser: { alignItems: 'flex-end' }, | |
| chatMsgAi: { alignItems: 'flex-start' }, | |
| chatRole: { fontFamily: 'var(--mono)', fontSize: 8, letterSpacing: '.18em', textTransform: 'uppercase' }, | |
| chatBubble: { fontSize: 12, lineHeight: 1.6, padding: '8px 11px', borderRadius: 10, wordBreak: 'break-word', maxWidth: '90%' }, | |
| chatBubbleUser: { background: 'rgba(200,240,96,.07)', border: '1px solid rgba(200,240,96,.15)', color: 'var(--text)', borderBottomRightRadius: 3 }, | |
| chatBubbleAi: { background: 'var(--bg)', border: '1px solid var(--border2)', color: 'var(--text)', borderBottomLeftRadius: 3 }, | |
| statusRow: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 28px', borderTop: '1px solid var(--border)', flexShrink: 0, background: 'var(--surface)' }, | |
| dot: { width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }, | |
| statusText: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.1em', color: 'var(--muted)' }, | |
| controls: { | |
| display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 20, | |
| padding: '14px 28px', borderTop: '1px solid var(--border)', flexShrink: 0, | |
| }, | |
| ctrlBtn: { width: 44, height: 44, borderRadius: '50%', border: '1px solid var(--border2)', background: 'var(--surface)', color: 'var(--text)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'border-color .15s, background .15s' }, | |
| ctrlMuted: { background: 'var(--danger)', borderColor: 'var(--danger)', color: '#fff' }, | |
| ctrlLeave: { background: 'rgba(255,80,80,.1)', borderColor: 'rgba(255,80,80,.3)', color: 'var(--danger)' }, | |
| aiChip: { display: 'flex', alignItems: 'center', gap: 7, fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.12em', textTransform: 'uppercase', color: 'var(--muted)', padding: '8px 14px', borderRadius: 20, border: '1px solid var(--border2)', background: 'var(--surface)', transition: 'border-color .2s, color .2s' }, | |
| aiChipDot: { width: 6, height: 6, borderRadius: '50%', flexShrink: 0, transition: 'background .2s' }, | |
| aiChipSpeak: { color: 'var(--ai-ring)', borderColor: 'var(--ai-ring)' }, | |
| aiChipThink: { color: '#f0c060', borderColor: '#f0c06040' }, | |
| aiChipListen: { color: 'var(--accent)', borderColor: 'rgba(200,240,96,.3)' }, | |
| } | |