helva-robot/face/var/www/html/control/index.html

392 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Robot Face Control</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #0b0f14;
color: #d7e3f2;
}
.wrap {
max-width: 720px;
margin: 0 auto;
padding: 16px;
display: grid;
gap: 14px;
}
.card {
background: rgba(18, 25, 37, 0.85);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 18px;
padding: 14px;
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
}
h1 { font-size: 18px; margin: 0 0 10px 0; opacity: 0.95; }
h2 { font-size: 14px; margin: 0 0 10px 0; opacity: 0.85; }
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
button {
background: #121925;
color: #d7e3f2;
border: 1px solid rgba(255,255,255,0.10);
padding: 12px 10px;
border-radius: 14px;
cursor: pointer;
font-size: 14px;
transition: transform 120ms ease, border-color 120ms ease;
user-select: none;
touch-action: manipulation;
}
button:active { transform: scale(0.98); }
button.primary { border-color: rgba(0,255,180,0.35); }
.row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.row3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
margin-top: 10px;
}
input[type="range"] {
width: 100%;
accent-color: #6ee7ff;
}
.value {
font-variant-numeric: tabular-nums;
opacity: 0.85;
min-width: 72px;
text-align: right;
}
.toggle {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
align-items: center;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.10);
background: #121925;
}
.dot {
width: 10px; height: 10px; border-radius: 999px;
background: rgba(255,255,255,0.35);
}
.dot.ok { background: rgba(0,255,180,0.75); }
.dot.bad { background: rgba(255,70,70,0.75); }
.small { font-size: 12px; opacity: 0.75; }
.footer {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Robot Face Control</h1>
<div class="toggle">
<span class="pill"><span id="connDot" class="dot"></span><span id="connTxt">offline</span></span>
<div class="small" id="stateTxt"></div>
</div>
</div>
<div class="card">
<h2>Emotion</h2>
<div class="grid">
<button data-emotion="neutral" class="primary">neutral</button>
<button data-emotion="happy">happy</button>
<button data-emotion="sad">sad</button>
<button data-emotion="angry">angry</button>
<button data-emotion="surprised">surprised</button>
<button data-emotion="sleepy">sleepy</button>
</div>
<div class="row">
<label for="intensity">Intensität</label>
<div class="value" id="intensityVal">0.70</div>
</div>
<input id="intensity" type="range" min="0" max="1" step="0.01" value="0.70" />
<div class="footer">
<button id="blinkBtn">Blinzeln</button>
<button id="wanderBtn" class="primary">Blick: wander</button>
</div>
</div>
<div class="card">
<h2>Blick (fix)</h2>
<div class="row">
<label for="lookX">X (links ↔ rechts)</label>
<div class="value" id="lookXVal">0.00</div>
</div>
<input id="lookX" type="range" min="-1" max="1" step="0.01" value="0" />
<div class="row">
<label for="lookY">Y (oben ↕ unten)</label>
<div class="value" id="lookYVal">0.00</div>
</div>
<input id="lookY" type="range" min="-1" max="1" step="0.01" value="0" />
<div class="footer">
<button id="applyLookBtn">Blick anwenden</button>
<button id="centerLookBtn">Zentrieren</button>
</div>
</div>
<div class="card">
<h2>Sprechen</h2>
<div class="footer">
<button id="speakBtn">Speak (700ms)</button>
<button id="talkToggleBtn" class="primary">Talk: OFF</button>
</div>
<div class="row">
<label for="talkRate">Rate (Hz)</label>
<div class="value" id="talkRateVal">3.20</div>
</div>
<input id="talkRate" type="range" min="0.5" max="10" step="0.1" value="3.2" />
<div class="row">
<label for="talkAmount">Mund-Öffnung</label>
<div class="value" id="talkAmountVal">0.90</div>
</div>
<input id="talkAmount" type="range" min="0" max="1" step="0.01" value="0.9" />
<div class="row">
<label for="talkJitter">Jitter</label>
<div class="value" id="talkJitterVal">0.25</div>
</div>
<input id="talkJitter" type="range" min="0" max="1" step="0.01" value="0.25" />
</div>
<div class="card small">
Tipp: URL am Handy öffnen: <b>/control/</b> (z. B. http://raspy/control/)
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const connDot = $("connDot");
const connTxt = $("connTxt");
const stateTxt = $("stateTxt");
const intensity = $("intensity");
const intensityVal = $("intensityVal");
const lookX = $("lookX"), lookXVal = $("lookXVal");
const lookY = $("lookY"), lookYVal = $("lookYVal");
const talkToggleBtn = $("talkToggleBtn");
const talkRate = $("talkRate"), talkRateVal = $("talkRateVal");
const talkAmount = $("talkAmount"), talkAmountVal = $("talkAmountVal");
const talkJitter = $("talkJitter"), talkJitterVal = $("talkJitterVal");
let talkEnabled = false;
let wander = true;
let lastState = null;
function fmt(n, d=2){ return Number(n).toFixed(d); }
async function postState(patch) {
const res = await fetch("/api/state", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error("POST failed: " + res.status);
return res.json();
}
function setConn(ok) {
connDot.classList.toggle("ok", ok);
connDot.classList.toggle("bad", !ok);
connTxt.textContent = ok ? "online" : "offline";
}
function updateStateText(s) {
if (!s) { stateTxt.textContent = ""; return; }
const e = s.emotion ?? "?";
const i = s.intensity ?? "?";
const t = s.talk?.enabled ? "talk" : "silent";
const lk = (s.look === null || s.look === undefined) ? "wander" : `look(${fmt(s.look.x)},${fmt(s.look.y)})`;
stateTxt.textContent = `${e} | intensity ${fmt(i)} | ${t} | ${lk}`;
}
// Emotion buttons
document.querySelectorAll("button[data-emotion]").forEach(btn => {
btn.addEventListener("click", async () => {
try {
await postState({ emotion: btn.dataset.emotion });
} catch(e) { console.error(e); }
});
});
// Intensity slider
intensity.addEventListener("input", () => intensityVal.textContent = fmt(intensity.value));
intensity.addEventListener("change", async () => {
try { await postState({ intensity: Number(intensity.value) }); } catch(e){ console.error(e); }
});
// Blink + Wander toggle
$("blinkBtn").addEventListener("click", async () => {
try { await postState({ blink: true }); } catch(e){ console.error(e); }
});
$("wanderBtn").addEventListener("click", async (ev) => {
wander = !wander;
ev.target.textContent = "Blick: " + (wander ? "wander" : "fix");
ev.target.classList.toggle("primary", wander);
try {
await postState({ look: wander ? null : { x: Number(lookX.value), y: Number(lookY.value) } });
} catch(e){ console.error(e); }
});
// Look sliders
function updateLookLabels(){
lookXVal.textContent = fmt(lookX.value);
lookYVal.textContent = fmt(lookY.value);
}
lookX.addEventListener("input", updateLookLabels);
lookY.addEventListener("input", updateLookLabels);
updateLookLabels();
$("applyLookBtn").addEventListener("click", async () => {
try {
wander = false;
$("wanderBtn").textContent = "Blick: fix";
$("wanderBtn").classList.remove("primary");
await postState({ look: { x: Number(lookX.value), y: Number(lookY.value) } });
} catch(e){ console.error(e); }
});
$("centerLookBtn").addEventListener("click", async () => {
lookX.value = 0; lookY.value = 0; updateLookLabels();
try {
wander = false;
$("wanderBtn").textContent = "Blick: fix";
$("wanderBtn").classList.remove("primary");
await postState({ look: { x: 0, y: 0 } });
} catch(e){ console.error(e); }
});
// Speak (one-shot)
$("speakBtn").addEventListener("click", async () => {
try {
await postState({ mouth: { open: true, amount: Number(talkAmount.value), duration_ms: 700 } });
} catch(e){ console.error(e); }
});
// Talk controls
function updateTalkLabels(){
talkRateVal.textContent = fmt(talkRate.value);
talkAmountVal.textContent = fmt(talkAmount.value);
talkJitterVal.textContent = fmt(talkJitter.value);
}
talkRate.addEventListener("input", updateTalkLabels);
talkAmount.addEventListener("input", updateTalkLabels);
talkJitter.addEventListener("input", updateTalkLabels);
updateTalkLabels();
async function pushTalk() {
try {
await postState({
talk: {
enabled: talkEnabled,
rate_hz: Number(talkRate.value),
amount: Number(talkAmount.value),
jitter: Number(talkJitter.value),
}
});
} catch(e){ console.error(e); }
}
talkToggleBtn.addEventListener("click", async () => {
talkEnabled = !talkEnabled;
talkToggleBtn.textContent = "Talk: " + (talkEnabled ? "ON" : "OFF");
talkToggleBtn.classList.toggle("primary", !talkEnabled); // OFF = primary (wie vorher)
await pushTalk();
});
[talkRate, talkAmount, talkJitter].forEach(el => {
el.addEventListener("change", async () => {
if (talkEnabled) await pushTalk();
});
});
// Live state via SSE (optional, aber nice)
function connectSSE() {
try {
const es = new EventSource("/events");
es.addEventListener("state", (e) => {
setConn(true);
try {
lastState = JSON.parse(e.data);
updateStateText(lastState);
// sync some UI hints
if (typeof lastState?.intensity === "number") {
intensity.value = lastState.intensity;
intensityVal.textContent = fmt(intensity.value);
}
if (lastState?.look === null) {
wander = true;
$("wanderBtn").textContent = "Blick: wander";
$("wanderBtn").classList.add("primary");
} else if (lastState?.look) {
wander = false;
$("wanderBtn").textContent = "Blick: fix";
$("wanderBtn").classList.remove("primary");
lookX.value = lastState.look.x ?? 0;
lookY.value = lastState.look.y ?? 0;
updateLookLabels();
}
if (lastState?.talk) {
talkEnabled = !!lastState.talk.enabled;
talkToggleBtn.textContent = "Talk: " + (talkEnabled ? "ON" : "OFF");
talkToggleBtn.classList.toggle("primary", !talkEnabled);
if (typeof lastState.talk.rate_hz === "number") talkRate.value = lastState.talk.rate_hz;
if (typeof lastState.talk.amount === "number") talkAmount.value = lastState.talk.amount;
if (typeof lastState.talk.jitter === "number") talkJitter.value = lastState.talk.jitter;
updateTalkLabels();
}
} catch {}
});
es.onerror = () => { setConn(false); es.close(); setTimeout(connectSSE, 1200); };
} catch {
setConn(false);
}
}
connectSSE();
// Initial ping
fetch("/api/state").then(r => r.json()).then(j => {
setConn(true);
lastState = j.state;
updateStateText(lastState);
}).catch(() => setConn(false));
</script>
</body>
</html>