230 lines
6.8 KiB
JavaScript
230 lines
6.8 KiB
JavaScript
// Control UI -> POST /api/state
|
|
// Also subscribes to /events to show current state/connection.
|
|
|
|
(() => {
|
|
const EMOTIONS = [
|
|
{ id: "neutral", label: "Neutral", icon: "•" },
|
|
{ id: "happy", label: "Happy", icon: "😊" },
|
|
{ id: "sad", label: "Sad", icon: "☹️" },
|
|
{ id: "angry", label: "Angry", icon: "😠" },
|
|
{ id: "sleepy", label: "Sleepy", icon: "😴" },
|
|
{ id: "surprised", label: "Surprised", icon: "😲" },
|
|
{ id: "excited", label: "Excited", icon: "⚡" },
|
|
];
|
|
|
|
const clamp = (n,a,b) => Math.max(a, Math.min(b, n));
|
|
const clamp01 = (v) => clamp(Number(v) || 0, 0, 1);
|
|
|
|
const ui = {
|
|
grid: document.getElementById("emotionGrid"),
|
|
conn: document.getElementById("conn"),
|
|
current: document.getElementById("current"),
|
|
speaking: document.getElementById("speaking"),
|
|
eyesMoving: document.getElementById("eyesMoving"),
|
|
intensity: document.getElementById("intensity"),
|
|
intensityVal: document.getElementById("intensityVal"),
|
|
lookpad: document.getElementById("lookpad"),
|
|
lookdot: document.getElementById("lookdot"),
|
|
center: document.getElementById("center"),
|
|
stareOff: document.getElementById("stareOff"),
|
|
};
|
|
|
|
const state = {
|
|
emotion: "neutral",
|
|
intensity: 0.85,
|
|
speaking: false,
|
|
eyesMoving: true,
|
|
look: { x: 0, y: 0 },
|
|
// stare/lock is represented by sending/omitting look.
|
|
stare: false,
|
|
};
|
|
|
|
function renderEmotionButtons() {
|
|
ui.grid.innerHTML = "";
|
|
for (const e of EMOTIONS) {
|
|
const b = document.createElement("button");
|
|
b.className = "emobtn";
|
|
b.dataset.emotion = e.id;
|
|
b.innerHTML = `
|
|
<div class="icon">${e.icon}</div>
|
|
<div>
|
|
<div style="font-weight:700">${e.label}</div>
|
|
<div style="opacity:.6;font-size:12px">${e.id}</div>
|
|
</div>
|
|
`;
|
|
b.addEventListener("click", () => {
|
|
state.emotion = e.id;
|
|
setActiveEmotion();
|
|
sendState({ emotion: state.emotion });
|
|
});
|
|
ui.grid.appendChild(b);
|
|
}
|
|
setActiveEmotion();
|
|
}
|
|
|
|
function setActiveEmotion() {
|
|
for (const btn of ui.grid.querySelectorAll(".emobtn")) {
|
|
btn.classList.toggle("active", btn.dataset.emotion === state.emotion);
|
|
}
|
|
ui.current.textContent = state.emotion;
|
|
}
|
|
|
|
async function sendState(partial) {
|
|
// Build payload. If stare=false -> do NOT send look (so face can drift).
|
|
const payload = {
|
|
emotion: state.emotion,
|
|
intensity: state.intensity,
|
|
speaking: state.speaking,
|
|
eyesMoving: state.eyesMoving,
|
|
...partial,
|
|
};
|
|
|
|
if (state.stare) payload.look = { x: state.look.x, y: state.look.y };
|
|
else delete payload.look;
|
|
|
|
try {
|
|
await fetch("/api/state", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
} catch (_) {
|
|
// ignore; SSE status will show disconnected
|
|
}
|
|
}
|
|
|
|
// intensity
|
|
ui.intensity.addEventListener("input", () => {
|
|
state.intensity = clamp01(ui.intensity.value);
|
|
ui.intensityVal.textContent = state.intensity.toFixed(2);
|
|
sendState({ intensity: state.intensity });
|
|
});
|
|
|
|
// toggles
|
|
ui.speaking.addEventListener("change", () => {
|
|
state.speaking = !!ui.speaking.checked;
|
|
sendState({ speaking: state.speaking });
|
|
});
|
|
|
|
ui.eyesMoving.addEventListener("change", () => {
|
|
state.eyesMoving = !!ui.eyesMoving.checked;
|
|
sendState({ eyesMoving: state.eyesMoving });
|
|
});
|
|
|
|
// Look pad (touch/mouse)
|
|
function setDotFromLook() {
|
|
const r = ui.lookpad.getBoundingClientRect();
|
|
const px = (state.look.x + 1) / 2 * r.width;
|
|
const py = (state.look.y + 1) / 2 * r.height;
|
|
ui.lookdot.style.left = `${px}px`;
|
|
ui.lookdot.style.top = `${py}px`;
|
|
ui.lookdot.style.transform = "translate(-50%,-50%)";
|
|
}
|
|
|
|
function setLookFromEvent(ev) {
|
|
const r = ui.lookpad.getBoundingClientRect();
|
|
const x = clamp((ev.clientX - r.left) / r.width, 0, 1);
|
|
const y = clamp((ev.clientY - r.top) / r.height, 0, 1);
|
|
|
|
state.look.x = (x * 2) - 1;
|
|
state.look.y = (y * 2) - 1;
|
|
state.stare = true; // touching pad implies stare
|
|
setDotFromLook();
|
|
sendState({}); // will include look because stare=true
|
|
}
|
|
|
|
let pointerDown = false;
|
|
ui.lookpad.addEventListener("pointerdown", (ev) => {
|
|
pointerDown = true;
|
|
ui.lookpad.setPointerCapture(ev.pointerId);
|
|
setLookFromEvent(ev);
|
|
});
|
|
ui.lookpad.addEventListener("pointermove", (ev) => {
|
|
if (!pointerDown) return;
|
|
setLookFromEvent(ev);
|
|
});
|
|
ui.lookpad.addEventListener("pointerup", () => { pointerDown = false; });
|
|
|
|
// dblclick / double tap center
|
|
let lastTap = 0;
|
|
ui.lookpad.addEventListener("pointerdown", () => {
|
|
const now = Date.now();
|
|
if (now - lastTap < 280) {
|
|
state.look = { x: 0, y: 0 };
|
|
state.stare = true;
|
|
setDotFromLook();
|
|
sendState({});
|
|
}
|
|
lastTap = now;
|
|
});
|
|
|
|
ui.center.addEventListener("click", () => {
|
|
state.look = { x: 0, y: 0 };
|
|
state.stare = true;
|
|
setDotFromLook();
|
|
sendState({});
|
|
});
|
|
|
|
ui.stareOff.addEventListener("click", () => {
|
|
state.stare = false; // omit look in next send
|
|
sendState({});
|
|
});
|
|
|
|
// SSE subscribe to reflect current state + connection
|
|
(function connectSSE(){
|
|
let es;
|
|
function open(){
|
|
ui.conn.textContent = "connecting…";
|
|
es = new EventSource("/events");
|
|
|
|
es.onopen = () => ui.conn.textContent = "online";
|
|
|
|
const apply = (msg) => {
|
|
if (!msg || typeof msg !== "object") return;
|
|
|
|
if (typeof msg.emotion === "string") state.emotion = msg.emotion;
|
|
if (msg.intensity !== undefined) state.intensity = clamp01(msg.intensity);
|
|
if (typeof msg.speaking === "boolean") state.speaking = msg.speaking;
|
|
if (typeof msg.eyesMoving === "boolean") state.eyesMoving = msg.eyesMoving;
|
|
|
|
if (msg.look && typeof msg.look === "object") {
|
|
state.look = {
|
|
x: clamp(Number(msg.look.x ?? 0), -1, 1),
|
|
y: clamp(Number(msg.look.y ?? 0), -1, 1),
|
|
};
|
|
state.stare = true;
|
|
}
|
|
|
|
ui.intensity.value = String(state.intensity);
|
|
ui.intensityVal.textContent = state.intensity.toFixed(2);
|
|
ui.speaking.checked = state.speaking;
|
|
ui.eyesMoving.checked = state.eyesMoving;
|
|
|
|
setActiveEmotion();
|
|
setDotFromLook();
|
|
};
|
|
|
|
es.addEventListener("state", (ev) => {
|
|
try { apply(JSON.parse(ev.data)); } catch (_) {}
|
|
});
|
|
|
|
es.onmessage = (ev) => {
|
|
try { apply(JSON.parse(ev.data)); } catch (_) {}
|
|
};
|
|
|
|
es.onerror = () => {
|
|
ui.conn.textContent = "offline";
|
|
try { es.close(); } catch (_) {}
|
|
setTimeout(open, 1200);
|
|
};
|
|
}
|
|
open();
|
|
})();
|
|
|
|
// init
|
|
renderEmotionButtons();
|
|
ui.intensityVal.textContent = state.intensity.toFixed(2);
|
|
setDotFromLook();
|
|
})();
|
|
|