import { Camera, CameraOff, Eye, EyeOff, Mic, ShieldCheck, Square, Users, Video, Volume2 } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { Button, ErrorNote, Logo, Spinner, Waveform } from "../components/ui"; import { api, type Session } from "../lib/api"; import { useI18n } from "../lib/i18n"; import { fmtClock, speak, stopSpeaking, useRecorder, type Recording } from "../lib/media"; import { useVision } from "../lib/vision"; type Next = { done: boolean; idx?: number; total: number; question?: string; kind?: string; is_followup?: boolean; answer_seconds?: number }; type Phase = "loading" | "asking" | "ready" | "recording" | "review_short" | "saving" | "waiting_followup" | "done" | "finishing"; export default function InterviewRoom() { const { id } = useParams(); const { t } = useI18n(); const nav = useNavigate(); const rec = useRecorder(36); const presence = useVision(rec.level); const videoRef = useRef(null); const [session, setSession] = useState(null); const [next, setNext] = useState(null); const [phase, setPhase] = useState("loading"); const [error, setError] = useState(null); const [saved, setSaved] = useState(0); const pending = useRef(null); const autoStop = useRef(0); const needsCamera = !!(session?.context as any)?.require_camera || !!(session?.context as any)?.record_video; const recordVideo = !!(session?.context as any)?.record_video; const proctored = session?.mode === "job" && !!(session?.context as any)?.require_camera; const ask = useCallback((n: Next) => { setNext(n); if (n.done || !n.question) return; setPhase("asking"); speak(n.question, () => setPhase((p) => (p === "asking" ? "ready" : p))); }, []); const loadNext = useCallback(async () => { const n = await api.get(`/sessions/${id}/next`); if (n.done) { setNext(n); setPhase("waiting_followup"); // give the last answer's analysis a moment to propose a follow-up question for (let i = 0; i < 8; i++) { await new Promise((r) => setTimeout(r, 3000)); const m = await api.get(`/sessions/${id}/next`); if (!m.done) { ask(m); return; } } setPhase("done"); } else ask(n); }, [id, ask]); useEffect(() => { api.get(`/sessions/${id}`).then((s) => { setSession(s); if (s.status !== "in_progress") { nav(`/app/report/${id}`, { replace: true }); return; } setSaved(s.answers?.length ?? 0); loadNext().catch((e) => setError(e.message)); }).catch((e) => setError(e.message)); return () => stopSpeaking(); }, [id, nav, loadNext]); const upload = useCallback(async (r: Recording) => { if (!next || next.idx == null) return; setPhase("saving"); const form = new FormData(); form.append("idx", String(next.idx)); form.append("file", r.blob, `answer${r.ext}`); const p = presence.end(); if (p.samples > 0 || p.events.length) form.append("presence", JSON.stringify(p)); try { await api.upload(`/sessions/${id}/answers`, form); setSaved((x) => x + 1); await loadNext(); } catch (e: any) { setError(e.message); setPhase("ready"); } }, [next, id, presence, loadNext]); const startAnswer = async () => { stopSpeaking(); setError(null); if (needsCamera && !presence.ready) { setError(t("cam_required_body")); return; } if (proctored && !document.fullscreenElement) await document.documentElement.requestFullscreen?.().catch(() => undefined); try { await rec.start({ video: recordVideo && presence.enabled ? presence.stream.current : null }); presence.begin({ fullscreen: proctored && !!document.fullscreenElement }); setPhase("recording"); const limit = (next?.answer_seconds ?? session?.context.answer_seconds ?? 120) * 1000; window.clearTimeout(autoStop.current); autoStop.current = window.setTimeout(() => stopAnswer(), limit); } catch { setError(t("mic_error")); } }; const stopAnswer = async () => { window.clearTimeout(autoStop.current); const r = await rec.stop(); if (r.seconds < 3) { pending.current = r; setPhase("review_short"); return; } upload(r); }; const finish = async () => { setPhase("finishing"); stopSpeaking(); presence.disable(); if (document.fullscreenElement) document.exitFullscreen?.().catch(() => undefined); try { await api.post(`/sessions/${id}/finish`); nav(`/app/report/${id}`, { replace: true }); } catch (e: any) { setError(e.message); setPhase("done"); } }; if (!session && !error) return ; const isJob = session?.mode === "job"; const live = presence.live; const limit = next?.answer_seconds ?? session?.context.answer_seconds ?? 120; const total = next?.total ?? session?.context.plan.length ?? 0; const qNumber = (next?.idx ?? 0) + 1; return (
{total > 0 && (
{saved}/{total}
)}
{error &&
{error}
} {phase === "finishing" || phase === "done" || phase === "waiting_followup" ? (

{t("interview_done")}

{t("answers_saved", { n: saved })}

) : ( <> {/* interviewer */}

{next?.is_followup ? t("followup") : t("q_of", { n: qNumber, total })}

{next?.question && ( )}

{phase === "loading" ? "…" : next?.question}

{phase === "asking" && }
{/* candidate */}
{phase === "review_short" ? (

{t("too_short")}

) : phase === "saving" ? ( ) : (
{phase === "recording" ? ( <>

{t("recording")} {fmtClock(rec.seconds)} · {t("time_left", { t: fmtClock(limit - rec.seconds) })}

) : ( <>

{t("get_ready")}

{needsCamera && !presence.ready &&

{t("cam_required_body")}

} )}
)}
)}
{/* camera */}
); }