Files
carinha-control-panel/carinha.html
T
julioandClaude Opus 5.5 9e09df40e2 feat(vida): ritmo também define a sensibilidade dos sensores
O servidor manda "ritmo calmo|normal|agitado" ao celular junto com o
humor, e o rosto ajusta limiares e quantos estímulos precisa:

- calmo: limiares 50% mais altos, 3 sustos seguidos para reagir, o dobro
  de sons e 4 movimentos para acordar, 3 disparos do PIR em 2 min;
- normal: reage a barulho alto e movimento forte, 2 movimentos ou os
  sons configurados para acordar;
- agitado: limiares 40% mais baixos, reage a qualquer som ou movimento
  (com reações variadas) e acorda no primeiro estímulo.

A madrugada silenciosa continua valendo por cima. Com o modo vida
desligado o celular volta ao "normal" (só os Ajustes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:39:14 -03:00

1355 lines
64 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="pt-BR">
<meta charset="utf-8">
<title>Eilik</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000}
svg{display:block;width:100%;height:100%}
.corpo{fill-rule:evenodd}
.traco{fill:none;stroke-linecap:round}
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
#o1,#o2,#boca,#sobrancelha1,#sobrancelha2{color:#70cadd}
.iconeOlho,.figurinhaDinamica{animation:aparecerFigura .28s ease-out}
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
</style>
<body>
<svg id="tela" viewBox="0 0 480 320" preserveAspectRatio="xMidYMid meet">
<rect x="0" y="0" width="480" height="320" fill="rgb(0,0,0)"/>
<g id="o1"><path class="corpo"/><path class="traco"/><ellipse class="brilho" fill="rgb(245,250,255)"/></g>
<g id="o2"><path class="corpo"/><path class="traco"/><ellipse class="brilho" fill="rgb(245,250,255)"/></g>
<path id="boca" class="traco"/>
<path id="sobrancelha1" class="traco" style="display:none"/>
<path id="sobrancelha2" class="traco" style="display:none"/>
</svg>
<div id="fps"></div>
<script src="rosto-motor.js"></script>
<script src="voz-robo.js"></script>
<script>
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
(function () {
const token = new URLSearchParams(location.search).get('t');
window.carinhaToken = token || '';
if (!token || !window.fetch) return;
const original = window.fetch.bind(window);
window.fetch = (recurso, opcoes = {}) => {
try {
const url = new URL(typeof recurso === 'string' ? recurso : recurso.url, location.href);
if (url.origin === location.origin) {
const cabecalhos = new Headers(opcoes.headers || {});
cabecalhos.set('Authorization', 'Bearer ' + token);
opcoes = Object.assign({}, opcoes, { headers: cabecalhos });
}
} catch (e) { /* URL estranha: segue sem token */ }
return original(recurso, opcoes);
};
})();
"use strict";
/* -------------------------------------------------------------------------
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
Mesmas medidas, mesmas cores, mesmos gestos e o mesmo comportamento ocioso.
Roda em qualquer navegador, inclusive num kiosk (cage + Epiphany) na telinha.
------------------------------------------------------------------------- */
// Medidas de projeto (as mesmas do eilik.py) — a tela de verdade e 480x320.
const LW = 480, LH = 320;
const E1P = [150, 166], E2P = [330, 166]; // centros dos olhos
const RXP = 64, RYP = 76; // raio base
const BOCAP = [240, 284], BOCALP = 96; // centro e largura da boca
// A area util nem sempre e 480x320 (o navegador pode reservar uma barra de
// titulo), entao tudo e escalado junto: preenche a ALTURA mantendo a
// proporcao dos olhos, e as posicoes acompanham a largura e a altura.
let W = LW, H = LH, ESC = 1;
let E1 = E1P.slice(), E2 = E2P.slice(), RX = RXP, RY = RYP;
let BOCA = BOCAP.slice(), BOCA_L = BOCALP;
function ajustar() {
W = Math.max(80, window.innerWidth);
H = Math.max(60, window.innerHeight);
ESC = H / LH;
// O rosto fica CENTRADO e o afastamento dos olhos vem da altura (ESC), nao
// da largura: a cara mantem a mesma proporcao em qualquer tela — inclusive
// nas compridas, como a do Galaxy S10 (19:9). A sobra vira margem.
const meio = W / 2;
E1 = [meio - (LW / 2 - E1P[0]) * ESC, H * (E1P[1] / LH)];
E2 = [meio + (E2P[0] - LW / 2) * ESC, H * (E2P[1] / LH)];
RX = RXP * ESC;
RY = RYP * ESC;
BOCA = [W * (BOCAP[0] / LW), H * (BOCAP[1] / LH)];
BOCA_L = BOCALP * ESC;
const svg = document.getElementById("tela");
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
const fundo = svg.querySelector("rect");
fundo.setAttribute("width", W);
fundo.setAttribute("height", H);
}
ajustar();
addEventListener("resize", ajustar);
const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255];
const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false };
/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo.
Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */
function cuidarDaTela() {
const agora = performance.now();
if (!r.dormindo) SONO.desde = 0;
else if (!SONO.desde) SONO.desde = agora;
const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
if (apagar !== SONO.apagada) {
SONO.apagada = apagar;
window.carinhaTelaApagada = apagar;
document.body.style.visibility = apagar ? "hidden" : "";
console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa");
}
}
setInterval(cuidarDaTela, 1000);
const CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78];
const ROSA = [255, 112, 176];
const AMARELO = [255, 218, 96];
const AZUL = [130, 170, 255];
// Tons derivados da cor escolhida no painel (CIANO é trocada pela cor escolhida em tempo real):
// "claro" para susto/surpresa, "escuro" para tristeza e sono. Nada de azul fixo.
const corClara = (k) => CIANO.map((v) => v + (255 - v) * (k === undefined ? 0.4 : k));
const corEscura = (k) => CIANO.map((v) => v * (k === undefined ? 0.62 : k));
function resolverCor(c) { return c === "claro" ? corClara() : c === "escuro" ? corEscura() : c === "escolhida" ? CIANO.slice() : c; }
const VERDE = [120, 255, 160];
const BRANCO = [245, 250, 255];
// -------------------------------------------------------------------- rostos
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
const ROSTOS = {
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
};
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
let FORMATO = "padrao";
let ROSTO_NOME = "carinha";
let ROSTO = ROSTOS[ROSTO_NOME];
// ------------------------------------------------------------------ utilidades
const suave = (x) => x * x * x * (x * (x * 6 - 15) + 10); // smoothstep
const lerp = (a, b, e) => a + (b - a) * e;
const lerpCor = (a, b, e) => [0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * e);
const rgb = (c) => "rgb(" + c.map((v) => Math.round(v)).join(",") + ")";
// ------------------------------------------------------------------ geometria
function caminhoElipse(cx, cy, rx, ry) {
return "M " + (cx - rx) + "," + cy +
" a " + rx + "," + ry + " 0 1 0 " + (2 * rx) + ",0" +
" a " + rx + "," + ry + " 0 1 0 " + (-2 * rx) + ",0 Z";
}
function caminhoArco(cx, cy, rx, ry) { // 196 -> 344 graus ( "^" )
const a0 = 196 * Math.PI / 180, a1 = 344 * Math.PI / 180;
const x0 = cx + rx * Math.cos(a0), y0 = cy + ry * Math.sin(a0);
const x1 = cx + rx * Math.cos(a1), y1 = cy + ry * Math.sin(a1);
return "M " + x0 + "," + y0 + " A " + rx + "," + ry + " 0 0 1 " + x1 + "," + y1;
}
function caminhoCoracao(cx, cy, s) {
return "M " + cx + "," + (cy + s * 0.72) +
" C " + (cx - s * 1.30) + "," + (cy - s * 0.30) + " " +
(cx - s * 0.80) + "," + (cy - s * 1.18) + " " +
cx + "," + (cy - s * 0.30) +
" C " + (cx + s * 0.80) + "," + (cy - s * 1.18) + " " +
(cx + s * 1.30) + "," + (cy - s * 0.30) + " " +
cx + "," + (cy + s * 0.72) + " Z";
}
function caminhoLinha(x0, y0, x1, y1) {
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
}
/* formas da boca (o humor escolhe qual) */
function deslocamentoBoca() {
const auto = ROSTO && ROSTO.motor ? 22 : 0; // rostos novos têm olhos mais baixos
return -(auto + (EXTRAS.subirBoca || 0)) * ESC;
}
/* Topo do olho atual (para a sobrancelha ficar perto dele, em qualquer estilo/formato). */
function metadeOlho(esc) {
const m = ROSTO && ROSTO.motor && window.MotorRosto && window.MotorRosto.metadeAltura
? window.MotorRosto.metadeAltura(ROSTO.motor, FORMATO) : 1;
return m * RY * esc;
}
function caminhoBoca(tipo) {
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca(), l = BOCA_L, meia = l / 2;
if (tipo === "aberta") { // surpresa: um "O"
const rx = l * 0.17, ry = l * 0.22;
return caminhoElipse(cx, cy, rx, ry);
}
if (tipo === "brava") { // canto para baixo
return "M " + (cx - meia) + "," + (cy + l * 0.16) +
" Q " + cx + "," + (cy - l * 0.26) + " " + (cx + meia) + "," + (cy + l * 0.16);
}
if (tipo === "triste") {
return "M " + (cx - l * 0.40) + "," + (cy + l * 0.16) +
" Q " + cx + "," + (cy - l * 0.20) + " " + (cx + l * 0.40) + "," + (cy + l * 0.16);
}
if (tipo === "sono") { // dormindo: um tracinho torto
return "M " + (cx - l * 0.24) + "," + cy +
" q " + (l * 0.24) + "," + (l * 0.16) + " " + (l * 0.48) + ",0";
}
if (tipo === "sorriso") { // feliz: sorriso grande
return "M " + (cx - meia) + "," + (cy - l * 0.10) +
" Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10);
}
// repouso: leve, com as microexpressões (canto que sobe, boca de lado, entreaberta)
const f = FEICAO.atual, w = l * 0.30, mx = cx + f.dx * l * 0.10;
const yE = cy - Math.max(0, f.lado) * l * 0.12, yD = cy - Math.max(0, -f.lado) * l * 0.12;
const curva = l * (0.18 + f.curva * 0.35);
let d = "M " + (mx - w) + "," + yE + " Q " + mx + "," + (cy + curva - f.abre * l * 0.12) + " " + (mx + w) + "," + yD;
if (f.abre > 0.04) d += " Q " + mx + "," + (cy + curva + f.abre * l * 0.30) + " " + (mx - w) + "," + yE;
return d;
}
/* Sobrancelha i (0 = esquerda): humor do gesto + microexpressão + desce um pouco ao piscar. */
function caminhoSobrancelha(i, esc) {
const c = i === 0 ? E1 : E2, lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, f = FEICAO.atual;
const ab = i === 0 ? r.ab : (r.ab2 === null ? r.ab : r.ab2);
const piscando = 1 - Math.min(1, Math.max(0, ab));
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC - (f.sob[i] - piscando * 3) * ESC;
const inc = ((r.forma === 'bravo' ? 18 : r.forma === 'triste' ? -14 : 0) + f.inc[i]) * lado * ESC;
const ele = r.esc > 1.1 ? -15 * ESC : 0;
return 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele);
}
// ------------------------------------------------------------------ desenho
const OLHOS = [document.getElementById("o1"), document.getElementById("o2")];
const PE = OLHOS.map((g) => ({
corpo: g.querySelector(".corpo"),
traco: g.querySelector(".traco"),
brilho: g.querySelector(".brilho"),
}));
function esconderTudo(p) {
p.corpo.style.display = "none";
p.traco.style.display = "none";
p.brilho.style.display = "none";
}
const elBoca = document.getElementById("boca");
/* A boca so aparece nos rostos que tem boca; a forma segue o humor. */
function desenharBoca(tipo) {
if (!EXTRAS.boca) {
elBoca.style.display = "none";
return;
}
const forma = tipo || r.boca;
elBoca.setAttribute("d", caminhoBoca(forma));
elBoca.setAttribute("stroke-width", BOCA_L * 0.15);
elBoca.setAttribute("stroke", rgb(r.cor));
elBoca.style.color=rgb(r.cor);
elBoca.setAttribute("fill", forma === "aberta" ? rgb(r.cor) : "none");
elBoca.style.display = "";
}
/* Desenha um olho igual ao _olho() do eilik.py. */
function olho(indice, cx, cy, rx, ry, ab, forma, cor, lado, lx, ly) {
const p = PE[indice];
document.getElementById('o'+(indice+1)).style.color=rgb(cor);
esconderTudo(p);
if (!p.icone) {
p.icone = document.createElementNS('http://www.w3.org/2000/svg','text');
p.icone.setAttribute('class','iconeOlho');p.icone.setAttribute('text-anchor','middle');
p.icone.setAttribute('dominant-baseline','central');document.getElementById('o'+(indice+1)).append(p.icone);
}
const desenho = !r.dormindo && window.carinhaOlhosDesenho;
const icone = !r.dormindo && (window.carinhaOlhosIcone || desenho);
p.icone.style.display = icone && !desenho ? '' : 'none';
if (p.figurinha) p.figurinha.style.display=desenho?'':'none';
if(desenho && window.carinhaConstruirSVG){
if(p.desenho!==desenho){if(p.figurinha)p.figurinha.remove();p.figurinha=window.carinhaConstruirSVG(desenho);p.desenho=desenho;if(p.figurinha)document.getElementById('o'+(indice+1)).append(p.figurinha);}
if(p.figurinha){p.figurinha.style.display='';p.figurinha.setAttribute('x',cx-rx*.65);p.figurinha.setAttribute('y',cy-ry*.65);p.figurinha.setAttribute('width',rx*1.3);p.figurinha.setAttribute('height',ry*1.3);}
}
if (icone) {
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
p.icone.setAttribute('font-size',Math.min(rx*1.1,ry*1.1));
}
if (forma === "aberto" || forma === "surpreso") {
const rr = Math.max(2, ry * ab);
p.corpo.setAttribute("d", caminhoElipse(cx, cy, rx, rr));
p.corpo.setAttribute("fill", rgb(cor));
p.corpo.style.display = "";
if (ab > 0.62 && !icone) {
// o brilho ("pupila") anda dentro do olho: e o que da a sensacao de olhar
let hx = cx - rx * 0.30 + lx * 1.2 * ESC;
let hy = cy - rr * 0.36 + ly * 1.2 * ESC;
hx = Math.max(cx - rx * 0.46, Math.min(cx + rx * 0.46, hx));
hy = Math.max(cy - rr * 0.46, Math.min(cy + rr * 0.46, hy));
p.brilho.setAttribute("cx", hx);
p.brilho.setAttribute("cy", hy);
p.brilho.setAttribute("rx", rx * 0.20);
p.brilho.setAttribute("ry", rr * 0.18);
p.brilho.style.display = "";
}
} else if (forma === "feliz") {
p.traco.setAttribute("d", caminhoArco(cx, cy + ry * 0.22, rx * 1.0, ry * 0.80));
p.traco.setAttribute("stroke-width", rx * 0.34);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "dormir") {
p.traco.setAttribute("d", caminhoLinha(cx - rx * 0.86, cy + ry * 0.14,
cx + rx * 0.86, cy + ry * 0.14));
p.traco.setAttribute("stroke-width", rx * 0.22);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "bravo") {
p.traco.setAttribute("d", caminhoLinha(cx - lado * rx * 0.9, cy - ry * 0.42,
cx + lado * rx * 0.9, cy + ry * 0.26));
p.traco.setAttribute("stroke-width", rx * 0.26);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "triste") {
p.traco.setAttribute("d", caminhoLinha(cx - lado * rx * 0.9, cy + ry * 0.22,
cx + lado * rx * 0.9, cy - ry * 0.46));
p.traco.setAttribute("stroke-width", rx * 0.26);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "coracao") {
p.corpo.setAttribute("d", caminhoCoracao(cx, cy, Math.min(rx, ry) * 0.98));
p.corpo.setAttribute("fill", rgb(cor));
p.corpo.style.display = "";
} else {
p.corpo.setAttribute("d", caminhoElipse(cx, cy, rx, ry));
p.corpo.setAttribute("fill", rgb(cor));
p.corpo.style.display = "";
}
}
// ------------------------------------------------------------------ gestos
function sequencias() {
const piscar = [[{ ab: 0.0 }, 0.06], [{ ab: 0.0 }, 0.04], [{ ab: 1.0 }, 0.11]];
const piscar2 = piscar.concat([[{}, 0.09]], piscar);
const olhar_esq = [[{ dx: -24, dy: 3 }, 0.16], [{}, 0.55], [{ dx: 0, dy: 0 }, 0.2]];
const olhar_dir = [[{ dx: 24, dy: 3 }, 0.16], [{}, 0.55], [{ dx: 0, dy: 0 }, 0.2]];
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: "claro", boca: "aberta" }, 0.09],
[{ dy: -10 }, 0.5],
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
[{ dx: -7 }, 0.06], [{ dx: 7 }, 0.06], [{ dx: -7 }, 0.06], [{ dx: 0 }, 0.06],
[{ forma: "aberto", cor: CIANO }, 0.32]];
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
[{ forma: "aberto", cor: CIANO }, 0.3]];
const triste = [[{ forma: "triste", cor: "escuro", dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
// Gestos com mãos: o motor desenha as mãos com a mesma duração (rosto-motor.js, DUR_MAOS).
const esconder = [[{ forma: "aberto", esc: 0.95, dy: 4 }, 0.3], [{}, 1.6], [{ forma: "feliz", boca: "sorriso", esc: 1.05, dy: 0 }, 0.3], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const espiar = [[{ esc: 0.96 }, 0.4], [{}, 0.6], [{ dx: -14, forma: "aberto", ab: 1.08 }, 0.25], [{}, 0.9], [{ dx: 0, ab: 1.0 }, 0.35], [{ forma: "feliz", boca: "sorriso" }, 0.4], [{ forma: "aberto" }, 0.3]];
const acenar = [[{ forma: "feliz", boca: "sorriso", esc: 1.05 }, 0.3], [{}, 1.7], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const pensar = [[{ dy: -10, dx: 14, boca: "neutra" }, 0.4], [{}, 2.0], [{ dy: 0, dx: 0 }, 0.4]];
const musica = [[{ forma: "feliz", boca: "sorriso", dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25],
[{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ forma: "aberto", dy: 0 }, 0.3]];
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
return { esconder, espiar, acenar, pensar, musica,
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
bravo, coracao, triste, piscadinha, alegre };
}
const r = {
ab: 1.0, ab2: null, esc: 1.0, dx: 0.0, dy: 0.0,
forma: "aberto", forma2: null, cor: CIANO.slice(), dormindo: false,
boca: "neutra",
fila: [], kf: null, kf_ini: null, kf_t: 0, cor_ini: CIANO.slice(),
};
const SEQS = sequencias();
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
/* Modo vida (servidor): o humor do bichinho escolhe os gestos soltos entre um ato e outro. */
const LIVRES_HUMOR = {
feliz: ["feliz", "alegre", "piscadinha", "coracao", "olhar_esq", "olhar_dir"],
tranquilo: ["olhar_esq", "olhar_dir", "feliz", "piscadinha", "olhar_baixo"],
entediado: ["olhar_esq", "olhar_dir", "olhar_baixo", "pensar"],
carente: ["olhar_baixo", "triste", "olhar_esq", "olhar_dir"],
triste: ["triste", "olhar_baixo", "olhar_baixo"],
cansado: ["olhar_baixo", "piscar2"],
};
let HUMOR = "normal";
/* Microexpressões acordado: de vez em quando a boca e as sobrancelhas mexem sozinhas (um canto
da boca sobe, uma sobrancelha arqueia, um suspiro) e voltam devagar ao neutro. O humor do modo
vida escolhe quais. Valores em "pixels de projeto" (multiplicados por ESC no desenho). */
function feicaoNeutra() { return { curva: 0, lado: 0, abre: 0, dx: 0, sob: [0, 0], inc: [0, 0] }; }
const FEICAO = { atual: feicaoNeutra(), alvo: feicaoNeutra(), prox: 0, volta: 0, t: 0 };
const ao = () => (Math.random() < 0.5 ? 1 : -1);
const MICRO = {
arquear: () => { const f = feicaoNeutra(), i = Math.random() < 0.5 ? 0 : 1; f.sob[i] = 12; f.inc[i] = -4; return f; },
interesse: () => Object.assign(feicaoNeutra(), { sob: [8, 8], abre: 0.35 }),
meioSorriso: () => Object.assign(feicaoNeutra(), { lado: ao(), curva: 0.1 }),
sorrisinho: () => Object.assign(feicaoNeutra(), { curva: 0.35, sob: [4, 4] }),
ladinho: () => { const d = ao(); return Object.assign(feicaoNeutra(), { dx: d, lado: d * 0.4, curva: -0.08 }); },
pensativo: () => Object.assign(feicaoNeutra(), { inc: [8, 8], sob: [-3, -3], dx: ao() * 0.7, curva: -0.15 }),
preocupado: () => Object.assign(feicaoNeutra(), { inc: [-9, -9], sob: [4, 4], curva: -0.3 }),
suspiro: () => Object.assign(feicaoNeutra(), { sob: [5, 5], abre: 0.6, curva: -0.05 }),
};
const MICRO_HUMOR = {
feliz: ["sorrisinho", "sorrisinho", "meioSorriso", "arquear", "interesse"],
tranquilo: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse"],
entediado: ["ladinho", "ladinho", "pensativo", "suspiro", "arquear"],
carente: ["preocupado", "preocupado", "suspiro", "meioSorriso"],
triste: ["preocupado", "suspiro", "pensativo"],
cansado: ["suspiro", "suspiro", "ladinho"],
normal: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse", "pensativo", "suspiro"],
};
function atualizarFeicao(s) {
const dt = Math.min(0.1, Math.max(0, s - (FEICAO.t || s)));
FEICAO.t = s;
// só no rosto "de repouso": gesto com boca própria (sorriso, triste...) ou dormindo = neutro
const livre = !r.dormindo && !congelado && r.boca === "neutra";
if (!livre) {
FEICAO.alvo = feicaoNeutra();
FEICAO.prox = Math.max(FEICAO.prox, s + 1.5);
} else if (s >= FEICAO.prox) {
const lista = MICRO_HUMOR[HUMOR] || MICRO_HUMOR.normal;
FEICAO.alvo = MICRO[lista[Math.floor(Math.random() * lista.length)]]();
FEICAO.volta = s + 0.9 + Math.random() * 1.6;
FEICAO.prox = s + (2.5 + Math.random() * 5) * ({ cansado: 1.6, triste: 1.4, feliz: 0.8 }[HUMOR] || 1);
} else if (s >= FEICAO.volta) FEICAO.alvo = feicaoNeutra();
const k = 1 - Math.exp(-dt * 6), a = FEICAO.atual, b = FEICAO.alvo;
for (const c of ["curva", "lado", "abre", "dx"]) a[c] += (b[c] - a[c]) * k;
for (let i = 0; i < 2; i++) { a.sob[i] += (b.sob[i] - a.sob[i]) * k; a.inc[i] += (b.inc[i] - a.inc[i]) * k; }
}
function gestoSolto() {
const lista = LIVRES_HUMOR[HUMOR] || LIVRES;
return lista[Math.floor(Math.random() * lista.length)];
}
function proximo() {
if (!r.fila.length) { r.kf = null; return; }
const [alvo, dur] = r.fila[0];
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
r.cor_ini = r.cor.slice();
if ("cor" in alvo) alvo.cor = resolverCor(alvo.cor); // "claro"/"escuro" viram tons da cor escolhida
if ("forma" in alvo) r.forma = alvo.forma;
if ("forma2" in alvo) r.forma2 = alvo.forma2;
if ("boca" in alvo) r.boca = alvo.boca;
r.kf = [alvo, dur];
r.kf_t = 0;
}
function animar(nome) {
if (SEQS[nome]) {
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
proximo();
tocarSom(nome); // trilha sonora do gesto (se houver)
return true;
}
return false;
}
function passo(dt) { // avanca a fila; true se ha gesto em curso
if (!r.fila.length || !r.kf) return false;
const [alvo, dur] = r.kf;
r.kf_t += dt;
const e = suave(dur <= 0 ? 1 : Math.min(1, r.kf_t / dur));
for (const k of ["ab", "ab2", "esc", "dx", "dy"]) {
if (k in alvo && alvo[k] !== null && r.kf_ini[k] !== null) r[k] = lerp(r.kf_ini[k], alvo[k], e);
else if (k in alvo && alvo[k] === null) r[k] = null;
}
if ("cor" in alvo) r.cor = lerpCor(r.cor_ini, alvo.cor, e);
if (e >= 1) { r.fila.shift(); proximo(); }
return true;
}
function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
r.cor = lerpCor(corEscura(0.75), corEscura(0.35), 0.5 + 0.5 * Math.sin(t * 1.2)); // respira na cor escolhida
r.ab = 0; r.ab2 = 0;
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
r.dx = 0;
r.dy = 3.0 * Math.sin(t * 1.4);
return;
}
r.forma = "aberto"; r.forma2 = null;
r.boca = "neutra";
r.cor = CIANO.slice();
r.ab = 1.0; r.ab2 = null; r.esc = 1.0;
// o vaivem de sempre + a direcao de quem se mexeu na frente (se houver)
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX;
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY;
}
function desenhar() {
const esc = r.esc * ROSTO.escala, ab = r.ab;
const ab2 = r.ab2 === null ? r.ab : r.ab2;
const f2 = r.forma2 === null ? r.forma : r.forma2;
// durante o repouso o corpo do olho fica parado e so o brilho se move
// mirando: algo se mexeu ha pouco, entao o olho inteiro (nao so o brilho)
// desliza na direcao do movimento; parado, volta ao repouso discreto
const tAgora = performance.now() / 1000;
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
const mirando = MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2;
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
const comMotor = ROSTO.motor && window.MotorRosto;
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) {
window.MotorRosto.desenhar({
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null,
iris: EXTRAS.iris,
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : null,
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
ab: abx, forma: fx, lado, lx: dx, ly: dy * 0.6,
})),
});
} else if (window.MotorRosto) window.MotorRosto.esconder();
for (let i = 0; i < 2 && !comMotor; i++) {
const [c, abx, fx, lado] = olhos[i];
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6);
}
const matriz = comMotor && ROSTO.motor === "matriz";
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
else desenharBoca();
for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
brow.setAttribute('d', caminhoSobrancelha(i, esc));
brow.setAttribute('stroke', rgb(r.cor));
brow.setAttribute('stroke-width', 7 * ESC);
}
}
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
function tracosDoRosto(esc) {
const tracos = [];
if (EXTRAS.boca) {
const forma = r.boca;
if (r.dormindo) { // dormindo na Matriz: "– _ –"
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca() + BOCA_L * 0.08, l = BOCA_L * 0.32;
tracos.push({ d: 'M ' + (cx - l) + ',' + cy + ' L ' + (cx + l) + ',' + cy, largura: BOCA_L * 0.16 });
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
}
if (EXTRAS.sobrancelhas && !r.dormindo) {
for (let i = 0; i < 2; i++) tracos.push({ d: caminhoSobrancelha(i, esc), largura: 9 * ESC });
}
return tracos;
}
// ------------------------------------------------------------------ controle
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
function trocarRosto(nome) {
const alvo = ROSTOS[(nome || "").trim().toLowerCase()];
if (!alvo) return false;
ROSTO_NOME = (nome || "").trim().toLowerCase();
ROSTO = alvo;
r.fila = []; r.kf = null;
r.boca = "neutra";
desenhar();
relatar(); // conta ao servidor qual rosto ficou
return true;
}
function trata(ordem) {
ordem = (ordem || "").trim().toLowerCase();
if (!ordem) return;
if(ordem==='recarregar') {
if(new URLSearchParams(location.search).get('aparelho')==='1') {
fetch('/app/status',{cache:'no-store'}).then(r=>r.json()).then(s=>{const url=new URL(location.href);url.searchParams.set('atualizacao',s.recarga?.id||Date.now());location.replace(url.href);}).catch(()=>{});
}
return;
}
window.carinhaUltimoComando = Date.now();
if (ordem.indexOf("rosto ") === 0) {
trocarRosto(ordem.slice(6).trim());
} else if (ROSTOS[ordem]) {
trocarRosto(ordem); // aceita so o nome: "miudo"
} else if (ordem.indexOf("formato ") === 0) {
const f = ordem.slice(8).trim();
if (FORMATOS.includes(f)) FORMATO = f;
} else if (ordem.indexOf("cor ") === 0) {
const v = ordem.slice(4).split(",").map(Number);
if (v.length === 3 && v.every((n) => !isNaN(n))) {
CIANO.splice(0, 3, ...v); r.cor = v;
}
} else if (ordem.indexOf("humor ") === 0) {
HUMOR = ordem.slice(6).trim();
} else if (ordem.indexOf("ritmo ") === 0) {
PERFIL = PERFIS[ordem.slice(6).trim()] || PERFIS.normal;
} else if (ordem === "dormir" || ordem === "sono") {
r.dormindo = true;
} else if (ordem === "acordar") {
r.dormindo = false; animar("surpreso");
} else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
}
animar(ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
} else if (ordem === "parar") {
try { window.close(); } catch (e) { r.fila = []; }
}
}
let congelado = false; // util para conferir poses (ver eilik.pose)
/* Mostra a pose caracteristica de um gesto e congela — serve para tirar
"foto" de cada gesto e conferir o desenho. eilik.soltar() volta ao normal. */
function pose(nome) {
const seq = SEQS[nome];
if (!seq) return false;
const alvo = seq[0][0];
r.fila = []; r.kf = null;
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
}
if ("cor" in alvo) r.cor = resolverCor(alvo.cor).slice();
congelado = true;
desenhar();
return true;
}
window.eilik = {
animar, trata, pose, trocarRosto, som, tocarSom, movimento, rastrear, face: r,
gestos: Object.keys(SEQS), rostos: Object.keys(ROSTOS),
rostoAtual: () => ROSTO_NOME,
soltar: () => { congelado = false; r.dormindo = false; },
presenca: () => MOV, // estado do detector de movimento (para conferir)
ouvido: () => MIC, // estado do microfone (para conferir)
ligarCamera, desligarCamera, decidirCameraOculta, // camera oculta (sensor)
cameraTick: detectarCamera, // um passo do detector (conferencia/sem laco)
};
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
// do PC com um curl, igual ao FIFO de antes
async function buscarOrdens() {
if (new URLSearchParams(location.search).get('espelho') === '1' || window.carinhaSSE) return;
try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return;
const lista = await resp.json();
for (const nome of lista) trata(nome);
} catch (e) { /* sem servidor (file://) — ignora */ }
}
setInterval(buscarOrdens, 500);
// A aparencia guardada no servidor (rosto + cor) e aplicada ao carregar: o
// painel de controle personaliza e a carinha ja abre do jeito escolhido. Sem
// servidor (file://) isso falha calado e vale o padrao.
async function aplicarAparencia() {
try {
const resp = await fetch("config", { cache: "no-store" });
if (!resp.ok) return;
const c = await resp.json();
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ }
}
setTimeout(aplicarAparencia, 200);
// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
// do servidor (SSE) por postMessage.
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
if (MODO_PAINEL) {
window.carinhaAtiva = false;
addEventListener('message', (e) => {
if (e.origin !== location.origin || e.source !== parent || !e.data) return;
const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// toque na tela: cutucar dá susto e depois risada; deslizar o dedo é carinho.
// O toque longo (menu do app) passa direto: só reagimos a toques curtos.
(function () {
let inicio = null;
addEventListener('pointerdown', (e) => { inicio = { x: e.clientX, y: e.clientY, t: performance.now() }; });
addEventListener('pointerup', (e) => {
if (!inicio) return;
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
inicio = null;
if (r.dormindo) { trata('acordar'); cuidar('cutucar'); return; }
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); cuidar('carinho'); return; }
if (dur < 350 && Math.hypot(dx, dy) < 15) {
cuidar('cutucar');
animar('surpreso');
setTimeout(() => animar('feliz'), 700);
}
});
})();
// teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir",
k: "olhar_baixo", w: "piscadinha" };
addEventListener("keydown", (ev) => {
const k = ev.key.toLowerCase();
if (TECLAS[k]) animar(TECLAS[k]);
else if (k === "d") { r.dormindo = !r.dormindo; if (!r.dormindo) animar("surpreso"); }
else if (k === " ") { animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); }
});
// ------------------------------------------------------------------ laço
const par = new URLSearchParams(location.search);
const FPS = parseFloat(par.get("fps") || "30"); // 0 = sem limite
let AUTO = par.get("auto") !== "0"; // gestos sozinhos
const COMPORTAMENTO = { ouvir: true, reagir: true };
const passoFixo = FPS > 0 ? 1 / FPS : 0;
// ----------------------------------------------------------- ouvido (microfone)
/* A ponte nativa do APK chama window.eilik.som(nivel) ~20x/s, com 0=silencio e
1=bem alto. Quem reage e a propria pagina, entao a telinha do Pi (sem
microfone) continua igual: sem chamadas, nada acontece. Ajustes por URL:
?somlim= ?somaltolim= ?sompausa= ?somcooldown= */
const MIC = {
ativo: false, // virou true na primeira leitura recebida
visto: 0, // t da ultima leitura recebida (s)
nivel: 0,
ultimoSom: 0, // t do ultimo som audivel (s)
estavaAlto: false,
proxSurpresa: 0,
ultimoLog: 0,
piso: null, // piso de ruido do ambiente (segue devagar)
ganho: parseFloat(par.get("somganho") || "0.40"), // quanto acima do piso = susto
limiarSom: parseFloat(par.get("somlim") || "0.35"), // acima: "tem som"
limiarAlto: parseFloat(par.get("somaltolim") || "0.55"), // minimo absoluto p/ susto
segSilencio: parseFloat(par.get("sompausa") || "20"), // s calado -> dormir
cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos
};
/* Acordar com som: precisa de N barulhos em 1 minuto (ajuste "Sons para acordar"). Na madrugada
silenciosa só barulho ALTO conta, e movimento/câmera não acordam (alguém se mexendo na cama). */
const DESPERTAR = { sons: 3, noite: true, ini: 23, fim: 7 };
function madrugada() {
if (!DESPERTAR.noite || DESPERTAR.ini === DESPERTAR.fim) return false;
const h = new Date().getHours();
return DESPERTAR.ini < DESPERTAR.fim ? h >= DESPERTAR.ini && h < DESPERTAR.fim : h >= DESPERTAR.ini || h < DESPERTAR.fim;
}
/* Perfil de sensibilidade = ritmo do modo vida (o servidor manda "ritmo calmo|normal|agitado").
Calmo precisa de vários estímulos para reagir e limiares mais altos; agitado reage a qualquer
som ou movimento. Multiplica os limiares dos Ajustes, sem mudar os Ajustes. */
const PERFIS = {
calmo: { lim: 1.5, reagir: 3, acordarSom: 2, acordarMov: 4, espera: 2, qualquer: false },
normal: { lim: 1.0, reagir: 1, acordarSom: 1, acordarMov: 2, espera: 1, qualquer: false },
agitado: { lim: 0.6, reagir: 1, acordarSom: 0.34, acordarMov: 1, espera: 0.5, qualquer: true },
};
let PERFIL = PERFIS.normal;
const limiar = (x) => Math.min(0.95, x * PERFIL.lim);
/* Conta o INÍCIO de cada estímulo (o mesmo som/movimento comprido vale um) numa janela de tempo
e diz quando chegou a `precisa`. */
function novoContador(janela) { return { janela, eventos: [], ultimo: -Infinity, estava: false, visto: -Infinity }; }
const CONTA = { somAcordar: novoContador(60), movAcordar: novoContador(30), somReagir: novoContador(20), movReagir: novoContador(20) };
function contar(c, ativo, agora, precisa, nome) {
// contador parado um tempo (ex.: o de "acordar" enquanto estava acordado): começa do zero
if (agora - c.visto > 2) c.estava = false;
c.visto = agora;
if (ativo && !c.estava && agora - c.ultimo >= 1.0) {
c.ultimo = agora;
c.eventos = c.eventos.filter((t) => agora - t < c.janela).concat(agora);
if (precisa > 1) console.log(nome + " " + c.eventos.length + "/" + precisa + (madrugada() ? " (madrugada)" : ""));
}
c.estava = ativo;
if (c.eventos.length >= precisa && agora - c.eventos[c.eventos.length - 1] < 1) { c.eventos = []; return true; }
return false;
}
/* Reação acordado: agitado varia; os outros se assustam. */
function reacaoEstimulo() {
if (!PERFIL.qualquer) return "surpreso";
const l = ["surpreso", "feliz", "alegre", "piscadinha", "surpreso"];
return l[Math.floor(Math.random() * l.length)];
}
function som(nivel) {
const n = Math.max(0, Math.min(1, Number(nivel) || 0));
const agora = performance.now() / 1000;
MIC.ativo = true;
MIC.visto = agora;
MIC.nivel = n;
if (!COMPORTAMENTO.ouvir) return;
// piso de ruido: desce rapido e sobe devagar, acompanhando o ambiente. Assim
// o "susto" e sempre relativo ao barulho de fundo e nao dispara sem parar
// num lugar barulhento. Ajuste por URL: ?somganho=
if (MIC.piso === null) MIC.piso = n;
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
const temSom = n >= limiar(MIC.limiarSom); // ruido audivel (absoluto)
const alto = n >= limiar(MIC.limiarAlto) && (n - MIC.piso) >= limiar(MIC.ganho);
if (temSom) MIC.ultimoSom = agora;
// registro periodico (aparece no logcat do app: prova que o ouvido chegou)
if (agora - MIC.ultimoLog >= 2) {
MIC.ultimoLog = agora;
console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2));
}
if (r.dormindo) {
const precisa = Math.max(1, Math.round(DESPERTAR.sons * PERFIL.acordarSom));
if (contar(CONTA.somAcordar, madrugada() ? alto : temSom, agora, precisa, "ouvido: barulho")) { // acordou
r.dormindo = false;
animar("surpreso");
MIC.proxSurpresa = agora + MIC.cooldown;
console.log("ouvido: acordou nivel=" + n.toFixed(2));
relatarSom("acordou", n);
}
} else if (contar(CONTA.somReagir, PERFIL.qualquer ? temSom : alto, agora, PERFIL.reagir, "ouvido: estimulo") &&
agora >= MIC.proxSurpresa) {
MIC.proxSurpresa = agora + MIC.cooldown * PERFIL.espera; // susto: barulho alto e repentino
animar(reacaoEstimulo());
console.log("ouvido: surpreso nivel=" + n.toFixed(2));
relatarSom("surpreso", n);
}
MIC.estavaAlto = alto;
}
/* Sem barulho por tempo demais, adormece (so quando o microfone esta ativo). */
function checarSilencio(agora) {
if (!COMPORTAMENTO.ouvir) return;
if (!MIC.ativo) return; // telinha do Pi: sem ouvido
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
if (r.dormindo) return;
// movimento tambem conta como "tem alguem aqui": nao adormece com gente passando
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
if (agora - atividade >= MIC.segSilencio) {
r.dormindo = true;
console.log("ouvido: adormeceu (" + MIC.segSilencio + "s sem som nem movimento)");
relatarSom("adormeceu", 0);
}
}
/* Conta ao servidor o que o ouvido percebeu — da para conferir de longe no
journal do carinha-web (so quando o microfone esta ativo). */
function relatarSom(evento, n) {
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
if (evento !== "adormeceu") registrarEvento("som", evento, n);
}
/* Toque na tela vira cuidado no modo vida (carinho/cutucada). Só o celular conta. */
function cuidar(acao) {
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
try {
fetch("/vida/cuidar", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ acao }) }).catch(() => {});
} catch (e) {}
}
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
function registrarEvento(tipo, evento, nivel) {
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
try {
fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {});
} catch (e) {}
}
// ------------------------------------------------------ presenca (movimento)
/* A ponte nativa do APK abre a camera FRONTAL, mede o movimento (diferenca de
luminancia entre quadros) e chama window.eilik.movimento(vel, cx, cy) umas
12x/s: vel vai de 0 (parado) a 1 (muito movimento) e cx,cy e o centro do que
se mexeu, de 0 a 1 na imagem. A carinha acorda quando algo se mexe na frente
dela e, acordada, os olhos acompanham o movimento. Sem a ponte nada acontece
(a telinha do Pi continua igual, sem camera). Ajustes por URL:
?movlim= ?movforte= ?movcooldown= ?movolhar= */
const MOV = {
ativo: false, // virou true na primeira leitura recebida
vel: 0, // ultimo movimento (0 a 1)
cx: 0.5, cy: 0.5, // ultimo centro do movimento (0 a 1)
visto: 0, // t da ultima leitura (s)
ultimoMov: 0, // t do ultimo movimento percebido (s)
proxSurpresa: 0, // t a partir do qual um novo susto pode disparar
estavaForte: false, // ja vinha em movimento forte (so o INICIO assusta)
ultimoLog: 0,
olharX: 0, olharY: 0, // deslocamento dos olhos na direcao do movimento
limiar: parseFloat(par.get("movlim") || "0.05"), // acima: houve movimento
limiarForte: parseFloat(par.get("movforte") || "0.12"), // acima: susto (medido: mao ~0,1)
cooldown: parseFloat(par.get("movcooldown") || "6"), // s entre sustos
olharMax: parseFloat(par.get("movolhar") || "40"), // quanto os olhos seguem
};
const TRACK = {alvo:null,visto:-Infinity};
function rastrear(t) {
if (!t) { TRACK.alvo=null; return; }
if (!Number.isFinite(t.x) || !Number.isFinite(t.y) || t.x<0 || t.x>1 || t.y<0 || t.y>1 || !['mao','rosto'].includes(t.tipo)) return;
if (!COMPORTAMENTO.reagir || configRecebida?.seguirRastreamento===false || configRecebida?.sensorCamera===false ||
(t.tipo==='mao' && configRecebida?.rastrearMaos===false) || (t.tipo==='rosto' && configRecebida?.rastrearRosto===false) ||
(r.dormindo && !configRecebida?.cameraSono)) return;
const agora=performance.now()/1000;
TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y;
MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65;
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
if(t.atividade===true) {
MOV.ultimoMov=agora;
if(r.dormindo && !madrugada() && PERFIL !== PERFIS.calmo) { r.dormindo=false;animar('surpreso'); }
}
}
function movimento(vel, cx, cy) {
const v = Math.max(0, Math.min(1, Number(vel) || 0));
const agora = performance.now() / 1000;
MOV.ativo = true;
MOV.vel = v;
MOV.visto = agora;
if (typeof cx === "number" && !isNaN(cx)) MOV.cx = Math.max(0, Math.min(1, cx));
if (typeof cy === "number" && !isNaN(cy)) MOV.cy = Math.max(0, Math.min(1, cy));
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; }
if (TRACK.alvo && agora-TRACK.visto<.35) return;
const mexeu = v >= limiar(MOV.limiar);
if (mexeu) MOV.ultimoMov = agora;
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
const alvoX = mexeu ? (MOV.cx - 0.5) * 2 * MOV.olharMax : agora-MOV.ultimoMov<.2 ? MOV.olharX : 0;
const alvoY = mexeu ? (MOV.cy - 0.5) * 2 * MOV.olharMax * 0.6 : agora-MOV.ultimoMov<.2 ? MOV.olharY : 0;
MOV.olharX += (alvoX - MOV.olharX) * 0.65;
MOV.olharY += (alvoY - MOV.olharY) * 0.65;
if (agora - MOV.ultimoLog >= 2) {
MOV.ultimoLog = agora;
console.log("presenca nivel=" + v.toFixed(2) + " centro=" +
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
}
const forte = v >= limiar(MOV.limiarForte);
if (r.dormindo) {
// acordou: algo se mexeu na frente (de dia; calmo precisa de vários movimentos)
if (!madrugada() && contar(CONTA.movAcordar, mexeu, agora, PERFIL.acordarMov, "presenca: movimento")) {
r.dormindo = false;
animar("surpreso");
MOV.proxSurpresa = agora + MOV.cooldown;
console.log("presenca: acordou nivel=" + v.toFixed(2));
relatarMov("acordou", v);
}
} else if (contar(CONTA.movReagir, PERFIL.qualquer ? mexeu : forte, agora, PERFIL.reagir, "presenca: estimulo") &&
agora >= MOV.proxSurpresa) {
// susto so no INICIO do movimento forte: se continuar se mexendo, nao repete
MOV.proxSurpresa = agora + MOV.cooldown * PERFIL.espera;
animar(reacaoEstimulo());
console.log("presenca: surpreso nivel=" + v.toFixed(2));
relatarMov("surpreso", v);
}
MOV.estavaForte = forte;
}
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
function relatarMov(evento, n) {
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
registrarEvento("movimento", evento, n);
}
// ------------------------------------------------- camera oculta (sensor)
/* A propria pagina abre a camera para SENTIR o movimento sem mostrar a imagem:
o video fica escondido e a tela continua so com os olhos do robo. Serve para
o kiosk do Pi com uma webcam (a pagina e servida por 127.0.0.1, que conta
como contexto seguro). No celular quem faz isso e a ponte nativa, que tambem
nao mostra imagem — entao, se ela ja estiver mandando movimento, nao abrimos
outra camera (dois acessos juntos costumam falhar).
Para ver a imagem em um cantinho (so para conferir): ?campreview=1
Para desligar de vez: ?camera=0 */
const CAM = {
querer: par.get("camera") !== "0"
&& typeof window.isSecureContext !== "undefined" && window.isSecureContext,
ativo: false,
erro: null,
fluxo: null,
video: null,
cv: null,
ctx: null,
lumaAnt: null,
quadros: 0, // aquecimento: a exposicao demora a assentar
CW: 160, CH: 120,
limite: 26, // salto de luminancia (igual a camera.html)
mostrar: par.get("campreview") === "1",
atraso: parseInt(par.get("camatraso") || "2000", 10), // espera antes de abrir
};
async function ligarCamera() {
if (CAM.ativo) return true;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
CAM.erro = "sem getUserMedia (contexto inseguro)";
return false;
}
try {
const fluxo = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "user" }, width: { ideal: 640 }, height: { ideal: 480 } },
audio: false,
});
CAM.fluxo = fluxo;
const v = document.createElement("video");
v.autoplay = true; v.muted = true; v.setAttribute("playsinline", "");
// escondido de verdade (fora da tela); so aparece com ?campreview=1
v.style.cssText = CAM.mostrar
? "position:fixed;right:8px;bottom:8px;width:160px;z-index:9;border:1px solid #24405a;border-radius:6px"
: "position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none";
v.srcObject = fluxo;
document.body.appendChild(v);
try { await v.play(); } catch (e) { /* segue */ }
CAM.video = v;
CAM.cv = document.createElement("canvas");
CAM.cv.width = CAM.CW; CAM.cv.height = CAM.CH;
CAM.ctx = CAM.cv.getContext("2d", { willReadFrequently: true });
CAM.quadros = 0;
CAM.ativo = true;
const f = fluxo.getVideoTracks()[0];
console.log("camera: sensor oculto ligado (" + ((f && f.label) || "camera") + ")");
requestAnimationFrame(lacoCamera);
return true;
} catch (e) {
CAM.erro = e.name;
console.log("camera: sensor oculto falhou — " + e.name);
return false;
}
}
function desligarCamera() {
CAM.ativo = false;
if (CAM.fluxo) { try { CAM.fluxo.getTracks().forEach((t) => t.stop()); } catch (e) {} }
if (CAM.video && CAM.video.parentNode) CAM.video.parentNode.removeChild(CAM.video);
CAM.fluxo = null; CAM.video = null; CAM.lumaAnt = null;
}
function detectarCamera() {
const v = CAM.video;
if (!v || !v.videoWidth) return;
CAM.ctx.drawImage(v, 0, 0, CAM.CW, CAM.CH);
const d = CAM.ctx.getImageData(0, 0, CAM.CW, CAM.CH).data;
if (!CAM.lumaAnt) CAM.lumaAnt = new Float32Array(CAM.CW * CAM.CH);
let mexidos = 0, sx = 0, sy = 0;
for (let i = 0; i < CAM.CW * CAM.CH; i++) {
const l = d[i * 4] * 0.299 + d[i * 4 + 1] * 0.587 + d[i * 4 + 2] * 0.114;
if (Math.abs(l - CAM.lumaAnt[i]) > CAM.limite) { mexidos++; sx += i % CAM.CW; sy += (i / CAM.CW) | 0; }
CAM.lumaAnt[i] = l;
}
CAM.quadros++;
if (CAM.quadros <= 6) return; // ignora o pico de entrada
const vel = Math.min(1, mexidos / (CAM.CW * CAM.CH * 0.25));
const cx = mexidos > 0 ? (sx / mexidos) / CAM.CW : 0.5;
const cy = mexidos > 0 ? (sy / mexidos) / CAM.CH : 0.5;
movimento(vel, cx, cy);
}
function lacoCamera() {
if (!CAM.ativo) return;
try { detectarCamera(); } catch (e) { CAM.erro = e.name; }
requestAnimationFrame(lacoCamera);
}
/* Decisao de abrir (ou nao) a camera escondida. Fica numa funcao a parte para
dar para conferir sem depender do relogio. */
function decidirCameraOculta() {
if (!CAM.querer) { if (!CAM.erro) CAM.erro = "desligada"; return; }
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
CAM.erro = "sem getUserMedia (contexto inseguro)";
console.log("camera: sem sensor na pagina — " + CAM.erro);
return;
}
// se a ponte nativa ja esta mandando movimento, deixa a camera com ela
// (dois acessos a camera ao mesmo tempo costumam falhar)
if (MOV.ativo) { CAM.erro = "ponte nativa no comando"; return; }
ligarCamera();
}
setTimeout(decidirCameraOculta, CAM.atraso);
// -------------------------------------------------------------- voz (saida)
/* Sons curtos sintetizados com Web Audio — sem arquivos, sem permissao e sem
rede. Cada gesto tem o seu (piscar e olhar ficam mudos de proposito). Para
silenciar: ?audio=0 ; volume: ?volume=0.5 */
const AUDIO = {
ligado: par.get("audio") !== "0",
volume: parseFloat(par.get("volume") || "0.35"),
ctx: null,
};
if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ
if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
let configRecebida = null;
function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
SONO.apagar = c.apagarTela !== false;
if (typeof c.somAcordar === 'number') DESPERTAR.sons = Math.max(1, Math.round(c.somAcordar));
DESPERTAR.noite = c.noiteSilenciosa !== false;
if (typeof c.noiteInicio === 'number') DESPERTAR.ini = Math.round(c.noiteInicio);
if (typeof c.noiteFim === 'number') DESPERTAR.fim = Math.round(c.noiteFim);
if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin;
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
MIC.limiarSom = c.somlim; MIC.ganho = c.somganho;
MIC.limiarAlto = c.somaltolim; MIC.segSilencio = c.sompausa; MIC.cooldown = c.somcooldown;
MOV.limiar = c.movlim; MOV.limiarForte = c.movforte;
MOV.cooldown = c.movcooldown; MOV.olharMax = c.movolhar;
if (!c.reagir) { MOV.olharX = MOV.olharY = 0; MOV.ultimoMov = 0; }
configRecebida = c;
if(!c.seguirRastreamento || !c.sensorCamera || !c.reagir) TRACK.alvo=null;
}
let sincronizando = false;
async function sincronizarApp() {
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
sincronizando = true;
try {
if (!window.carinhaSSE) {
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
if (resp.ok) configurarApp(await resp.json());
}
if (par.get('aparelho') === '1') {
const agora = performance.now() / 1000;
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
signal: AbortSignal.timeout(2500), body: JSON.stringify({
tela_atualizada: par.get('atualizacao'), rosto: ROSTO_NOME, cor: r.cor, dormindo: r.dormindo,
microfone: MIC.nivel, microfone_ativo: window.carinhaSensores ? window.carinhaSensores.microfone : MIC.ativo && agora - MIC.visto < 2,
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho,
tela_apagada: !!window.carinhaTelaApagada,
bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo,
}) });
}
} catch (e) { /* servidor ausente: animação continua */ }
finally { sincronizando = false; }
}
setTimeout(sincronizarApp, 300);
setInterval(sincronizarApp, 1000);
function contextoAudio() {
if (AUDIO.ctx) return AUDIO.ctx;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
try { AUDIO.ctx = new AC(); } catch (e) { return null; }
return AUDIO.ctx;
}
/* Uma nota simples: freq -> freqFim (se houver), com subida e queda suaves. */
function nota(c, freq, t0, dur, tipo, vol, freqFim) {
const osc = c.createOscillator();
const ganho = c.createGain();
osc.type = tipo || "sine";
osc.frequency.setValueAtTime(freq, t0);
if (freqFim) osc.frequency.exponentialRampToValueAtTime(freqFim, t0 + dur);
ganho.gain.setValueAtTime(0.0001, t0);
ganho.gain.exponentialRampToValueAtTime(vol, t0 + 0.012);
ganho.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(ganho);
ganho.connect(c.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.03);
}
const SONS = {
feliz: (c, t, v) => { nota(c, 660, t, 0.12, "sine", v); nota(c, 990, t + 0.10, 0.16, "sine", v); },
alegre: (c, t, v) => [523, 659, 784, 1047].forEach((f, i) => nota(c, f, t + i * 0.07, 0.12, "triangle", v)),
coracao: (c, t, v) => { nota(c, 784, t, 0.5, "sine", v * 0.9); nota(c, 988, t + 0.04, 0.5, "sine", v * 0.7); },
piscadinha: (c, t, v) => { nota(c, 880, t, 0.09, "square", v * 0.6); nota(c, 1175, t + 0.09, 0.12, "square", v * 0.5); },
surpreso: (c, t, v) => nota(c, 300, t, 0.28, "sawtooth", v * 0.6, 1400), // "uou!"
bravo: (c, t, v) => nota(c, 220, t, 0.35, "sawtooth", v * 0.7, 90), // resmungo
triste: (c, t, v) => { nota(c, 500, t, 0.22, "sine", v * 0.8); nota(c, 330, t + 0.20, 0.34, "sine", v * 0.8); },
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
};
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
os sons dos gestos ficam quietos para não embolar. */
function falar(texto) {
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
const c = contextoAudio();
if (!c) return;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try {
const dur = window.VozRobo.balbuciar(c, texto, AUDIO.volume * 0.85);
AUDIO.mudoAte = performance.now() + dur * 1000;
} catch (e) { /* segue sem voz */ }
}
function tocarSom(gesto) {
if (!AUDIO.ligado) return false;
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
const f = SONS[gesto];
if (!voz && !f) return false;
const c = contextoAudio();
if (!c) return false;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try {
if (voz) window.VozRobo.tocar(c, gesto, AUDIO.volume); // voz de robô sintetizada
else f(c, c.currentTime + 0.02, AUDIO.volume); // bipes antigos (reserva)
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
console.log("voz: " + gesto + " ctx=" + c.state);
return true;
} catch (e) { return false; }
}
let anterior = performance.now();
let descanso = 0;
let agora = performance.now();
let proxPiscar = agora + 1500 + Math.random() * 2500;
let proxGesto = agora + 6000 + Math.random() * 6000;
const elFps = document.getElementById("fps");
if (par.get("fpsmostrar") === "1") elFps.style.display = "block";
let contador = 0, marco = performance.now();
function laco(t) {
const dt = Math.min(0.25, (t - anterior) / 1000);
anterior = t;
// se a janela mudou de tamanho (ou so ficou pronta depois do primeiro
// quadro), refaz a geometria — assim a carinha nunca fica enquadrada errado
if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
checarSilencio(t / 1000);
atualizarFeicao(t / 1000);
const ocupado = congelado ? true : passo(dt);
if (!ocupado) {
descanso += dt;
repouso(descanso);
if (r.dormindo) {
proxPiscar = proxGesto = t + 3600000;
} else if (t >= proxPiscar) {
animar("piscar");
proxPiscar = t + 1600 + Math.random() * 3500;
} else if (AUTO && t >= proxGesto) {
animar(gestoSolto());
// cansado e triste mexem menos; feliz, mais
const ritmo = { cansado: 1.8, triste: 1.5, feliz: 0.8 }[HUMOR] || 1;
proxGesto = t + (7000 + Math.random() * 8000) * ritmo;
}
} else if (t >= proxGesto) {
proxGesto = t + 7000 + Math.random() * 8000;
}
desenhar();
contador++;
if (t - marco >= 1000) {
if (elFps.style.display !== "none") elFps.textContent = contador + " fps";
contador = 0; marco = t;
}
if (passoFixo > 0) {
const folga = passoFixo * 1000 - (performance.now() - t);
if (folga > 1) { setTimeout(() => requestAnimationFrame(laco), folga); return; }
}
requestAnimationFrame(laco);
}
requestAnimationFrame(laco);
// gesto inicial por URL: carinha.html?gesto=feliz
if (par.get("gesto")) setTimeout(() => trata(par.get("gesto")), 300);
// Relatorio de diagnostico: conta ao servidor o tamanho da janela e qual rosto
// esta em uso. Aparece no journal do servico carinha-web. Tambem e chamado
// quando alguem troca de rosto, para dar para conferir de longe.
function relatar() {
try {
const g = document.getElementById("tela").getBoundingClientRect();
const info = "rosto=" + ROSTO_NOME +
" janela=" + innerWidth + "x" + innerHeight +
" tela=" + screen.width + "x" + screen.height +
" svg=" + Math.round(g.width) + "x" + Math.round(g.height);
fetch("diag/" + encodeURIComponent(info)).catch(() => {});
} catch (e) {}
}
setTimeout(relatar, 1500);
/* Respostas escritas ficam na aba Conversar. No aparelho, só expressões. */
(function(){
if(location.protocol==='file:')return;
let seen=null,fetching=false;
async function poll(){
if(fetching||window.carinhaAtiva===false||window.carinhaSSE)return;fetching=true;
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
falar(m.texto);
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto});
}catch(e){}finally{fetching=false;}
}
setInterval(poll,1000);poll();
})();
</script>
<script src="/reacoes-render.js"></script>
<script>
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) {
const aviso=document.createElement('div');aviso.id='avisoRecarga';aviso.textContent='Tela atualizada';aviso.style.cssText='position:fixed;bottom:8%;left:0;right:0;text-align:center;color:#fff;font:24px sans-serif;z-index:100;pointer-events:none';document.body.append(aviso);setTimeout(()=>aviso.remove(),4000);
}
/* Tempo real: um EventSource em /eventos substitui os pollings de /fila, /app/config,
/conversa/status e /treinamento/expressao. Se cair, os pollings voltam sozinhos
(window.carinhaSSE=false) até o EventSource reconectar. */
(function () {
const p = new URLSearchParams(location.search);
if (location.protocol === 'file:' || p.get('espelho') === '1' || !window.EventSource) return;
const aparelho = p.get('aparelho') === '1';
const t = window.carinhaToken;
const fonte = new EventSource('/eventos' + (t ? '?t=' + encodeURIComponent(t) : ''));
let ultimaConversa = null, ultimaReacao = null, primeira = true;
const ouvir = (tipo, fn) => fonte.addEventListener(tipo, (e) => { try { fn(JSON.parse(e.data)); } catch (err) {} });
fonte.addEventListener('pronto', () => {
// Ordens que chegaram antes da conexão ficam na fila: executa uma vez e passa ao SSE.
if (primeira) { primeira = false; buscarOrdens(); }
window.carinhaSSE = true;
// Foto dos ajustes a cada (re)conexão; depois só mudanças chegam por 'app_config'.
fetch('/app/config', { cache: 'no-store' }).then((r) => r.ok && r.json()).then((c) => c && configurarApp(c)).catch(() => {});
});
fonte.onerror = () => {
if (!window.carinhaSSE) return;
window.carinhaSSE = false;
// Volta ao polling sem repetir o que o SSE já entregou.
fetch('fila' + (aparelho ? '?aparelho=1' : ''), { cache: 'no-store' }).catch(() => {});
};
ouvir('expressao', (d) => trata(d.ordem));
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
ouvir('app_config', (c) => configurarApp(c));
ouvir('conversa', (s) => {
if (window.MotorRosto) {
if (s.ocupado) window.MotorRosto.faixa('digitando');
else window.MotorRosto.faixa(null, { so: 'digitando' });
}
const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id;
falar(m.texto);
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
ouvir('reacao', (d) => {
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
ultimaReacao = d.id;
if (window.eilik && window.eilik.receita) window.eilik.receita(d.receita);
});
})();
</script>
</body>
</html>