Princeaka's picture
Upload 44 files
0ff9b81 verified
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 (
<div
className="orbit-wrapper"
style={{ left: position.x, top: position.y, width: size, height: size }}
>
<audio ref={audioRef} src="/orbit.wav" preload="auto" />
<div
className="orbit"
onMouseDown={(e) => handleMouseDown(e, "drag")}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
title="Orbit Assistant"
/>
<div
className="orbit-resize-handle"
onMouseDown={(e) => handleMouseDown(e, "resize")}
/>
</div>
);
}