import React, { useEffect, useState, useRef, useCallback } from "react"; import { useStore } from "../state/store"; import { useNavigate } from "react-router-dom"; import "./Orbit.css"; const API_BASE = process.env.REACT_APP_API_BASE || ""; export function Orbit() { const { setUi } = useStore(); const navigate = useNavigate(); const [isActive, setIsActive] = useState(false); const [position, setPosition] = useState({ x: 100, y: 100 }); const [size, setSize] = useState(30); const synthRef = useRef(window.speechSynthesis); const recognitionRef = useRef(null); const sleepTimeoutRef = useRef(null); const draggingRef = useRef(false); const resizingRef = useRef(false); const offsetRef = useRef({ x: 0, y: 0 }); const audioRef = useRef(null); // Gesture refs const lastTapRef = useRef(0); const longPressTimerRef = useRef(null); const isLongPressRef = useRef(false); const disableSleepRef = useRef(false); // 🎨 Orb visual state const setOrbStyle = useCallback((state) => { const root = document.documentElement; const styles = { dormant: { "--orb-core": "rgba(100,100,255,0.3)", "--orb-glow": "rgba(80,80,220,0.2)", "--orb-ray": "transparent", "--orb-scale": "0.8", "--orb-opacity": "0.4", }, wake: { "--orb-core": "rgba(120,120,255,0.8)", "--orb-glow": "rgba(100,100,255,0.5)", "--orb-ray": "transparent", "--orb-scale": "1", "--orb-opacity": "1", }, listening: { "--orb-core": "rgba(0,230,255,0.9)", "--orb-glow": "rgba(0,200,255,0.6)", "--orb-ray": "transparent", "--orb-scale": "1.1", "--orb-opacity": "1", }, thinking: { "--orb-core": "rgba(255,180,0,0.8)", "--orb-glow": "rgba(255,160,0,0.5)", "--orb-ray": "transparent", "--orb-scale": "1.05", "--orb-opacity": "1", }, speaking: { "--orb-core": "rgba(255,241,118,0.9)", "--orb-glow": "rgba(255,225,100,0.5)", "--orb-ray": "rgba(255,241,118,0.3)", "--orb-scale": "1", "--orb-opacity": "1", }, error: { "--orb-core": "rgba(255,80,80,0.8)", "--orb-glow": "rgba(220,60,60,0.5)", "--orb-ray": "transparent", "--orb-scale": "1", "--orb-opacity": "1", }, warning: { "--orb-core": "rgba(255,165,0,0.8)", "--orb-glow": "rgba(255,140,0,0.5)", "--orb-ray": "transparent", "--orb-scale": "1", "--orb-opacity": "1", }, }; const selected = styles[state] || styles["dormant"]; Object.keys(selected).forEach((key) => root.style.setProperty(key, selected[key])); }, []); // πŸ“΄ Stop & sleep const stopAll = useCallback(() => { if (synthRef.current.speaking) synthRef.current.cancel(); if (recognitionRef.current) { try { recognitionRef.current.stop(); } catch {} } setUi((u) => ({ ...u, orbit: "dormant" })); setOrbStyle("dormant"); setIsActive(false); if (sleepTimeoutRef.current) clearTimeout(sleepTimeoutRef.current); }, [setUi, setOrbStyle]); // πŸ—£ Speak const speak = useCallback((text, after) => { if (!text) return; setOrbStyle("speaking"); setUi((u) => ({ ...u, orbit: "speaking" })); const utter = new SpeechSynthesisUtterance(text); utter.onend = () => { if (isActive) { setOrbStyle("listening"); setUi((u) => ({ ...u, orbit: "listening" })); if (!disableSleepRef.current) resetSleepTimer(); } else { setOrbStyle("dormant"); } if (after) after(); }; utter.onerror = () => setOrbStyle("error"); synthRef.current.speak(utter); }, [isActive, setUi, setOrbStyle]); // ⏲ Sleep timer const resetSleepTimer = useCallback(() => { if (disableSleepRef.current) return; if (sleepTimeoutRef.current) clearTimeout(sleepTimeoutRef.current); sleepTimeoutRef.current = setTimeout(() => { speak("Going to sleep", stopAll); }, 15000); }, [speak, stopAll]); // πŸ’¬ Send text to backend chat const queryBackend = async (message) => { try { setOrbStyle("thinking"); setUi((u) => ({ ...u, orbit: "thinking" })); const token = localStorage.getItem("token"); const formData = new FormData(); formData.append("text", message); const res = await fetch(`${API_BASE}/v1/chat`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body: formData, }); const data = await res.json(); const reply = data.reply || "I don’t have an answer right now."; speak(reply); } catch (err) { console.error("Backend error:", err); speak("Sorry, I couldn’t reach the knowledge base."); setOrbStyle("warning"); setUi((u) => ({ ...u, orbit: "warning" })); } }; // πŸŽ™ Speech Recognition useEffect(() => { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) return console.warn("SpeechRecognition not supported"); recognitionRef.current = new SR(); recognitionRef.current.continuous = true; recognitionRef.current.interimResults = false; recognitionRef.current.lang = "en-US"; recognitionRef.current.onresult = (e) => { const transcript = e.results[e.results.length - 1][0].transcript.trim(); console.log("Heard:", transcript); if (!isActive) return; if (transcript.toLowerCase().includes("go to sleep")) { speak("Going to sleep", stopAll); return; } queryBackend(transcript); }; recognitionRef.current.onend = () => recognitionRef.current.start(); recognitionRef.current.start(); return () => recognitionRef.current && recognitionRef.current.stop(); }, [isActive, stopAll, speak]); // πŸ‘† Tap/longpress handlers const handlePointerDown = () => { const now = Date.now(); if (now - lastTapRef.current < 300) { // Double tap β†’ go to chat navigate("/chat"); } lastTapRef.current = now; longPressTimerRef.current = setTimeout(() => { isLongPressRef.current = true; disableSleepRef.current = true; setIsActive(true); setOrbStyle("wake"); setUi((u) => ({ ...u, orbit: "listening" })); if (audioRef.current) audioRef.current.play().catch(() => {}); speak("Long press mode active. I'm listening continuously."); }, 600); }; const handlePointerUp = () => { if (longPressTimerRef.current) clearTimeout(longPressTimerRef.current); if (isLongPressRef.current) { isLongPressRef.current = false; disableSleepRef.current = false; resetSleepTimer(); speak("Long press released. Sleep timer re-enabled."); } }; // πŸ–± Drag + resize const handleMouseDown = (e, mode) => { if (mode === "drag") draggingRef.current = true; if (mode === "resize") resizingRef.current = true; offsetRef.current = { x: e.clientX, y: e.clientY }; }; const handleMouseMove = (e) => { if (draggingRef.current) { setPosition((pos) => { const newPos = { x: pos.x + (e.clientX - offsetRef.current.x), y: pos.y + (e.clientY - offsetRef.current.y) }; offsetRef.current = { x: e.clientX, y: e.clientY }; return newPos; }); } if (resizingRef.current) { setSize((s) => Math.max(80, s + (e.clientX - offsetRef.current.x))); offsetRef.current = { x: e.clientX, y: e.clientY }; } }; const handleMouseUp = () => { draggingRef.current = false; resizingRef.current = false; }; useEffect(() => { window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; }, []); // πŸ’Ύ Save/load state useEffect(() => { const saved = JSON.parse(localStorage.getItem("orbit_state") || "{}"); if (saved.x && saved.y) setPosition({ x: saved.x, y: saved.y }); if (saved.size) setSize(saved.size); }, []); useEffect(() => { localStorage.setItem("orbit_state", JSON.stringify({ ...position, size })); }, [position, size]); return (