feat(celular): sem a placa a carinha dorme e mostra placa ✕ celular

Sem servidor o app não finge mais que está tudo normal:
- o app guarda a última aparência e os ajustes vindos da placa (ponte
  CarinhaApp); a cópia do APK abre com eles;
- a carinha dorme e não acorda com nada; no canto direito aparece a
  placa ✕ celular animada com "sem conexão com a placa";
- o app tenta a placa a cada 10 s e volta sozinho; se a placa cai com
  a página aberta, em 15 s ela dorme e mostra o aviso;
- endereço padrão passa a ser a Orange Pi (192.168.1.35:8100).

Também no app (0.9.0):
- modo quiosque como dono do aparelho (Quiosque/Admin, menu "Modo
  quiosque"); a Samsung recusa com bootloader desbloqueado, vale no
  LineageOS;
- carinha como tela inicial (HOME);
- android/enxugar-s10.ps1 desativa 57 apps sem uso no S10 (-Reativar
  desfaz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-08 03:17:28 -03:00
co-authored by Claude Opus 5.5
parent f8d2ebf705
commit 43770c6d67
8 changed files with 620 additions and 29 deletions
+156 -11
View File
@@ -20,6 +20,52 @@
@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}
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o
desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */
#semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
z-index:5;pointer-events:none;text-align:center;opacity:.82;font-family:system-ui,sans-serif;
animation:semServidorEntra 1.2s ease-out}
#semServidor svg{display:block;margin:0 auto;overflow:visible;filter:drop-shadow(0 0 3px currentColor)}
#semServidor svg.horizontal{display:none}
#semServidor svg.vertical{height:58vh;width:auto;max-width:100%}
#semServidor svg.vertical .olhinhos{animation-name:semServidorOlharCima}
@media (orientation:portrait){
body.semServidor #tela,body.semServidor #rostoMotor{transform:translateY(-20%) scale(.8)}
#semServidor{right:auto;top:auto;left:50%;bottom:3.5vh;transform:translateX(-50%);width:min(80vw,400px)}
#semServidor svg.horizontal{display:block;width:100%;height:auto}
#semServidor svg.vertical{display:none}
}
#semServidor .titulo{text-wrap:balance;font-weight:600;font-size:clamp(12px,3.2vh,20px);margin-top:.4em;letter-spacing:.02em}
#semServidor .sub{font-size:clamp(10px,2.4vh,15px);opacity:.6;margin-top:.25em;font-variant-numeric:tabular-nums}
#semServidor .placa rect,#semServidor .celular>rect{fill:none;stroke:currentColor;stroke-width:3}
#semServidor .chip{fill:currentColor;fill-opacity:.25}
#semServidor .pino{fill:currentColor;opacity:.7}
#semServidor .led{fill:#ff5a5a;animation:semServidorLed 1.6s steps(1) infinite}
#semServidor .cabo{stroke:currentColor;stroke-width:3;stroke-dasharray:5 6;opacity:.5;fill:none}
#semServidor .arco{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;opacity:0;
animation:semServidorArco 2.4s ease-out infinite}
#semServidor .a2{animation-delay:.3s}
#semServidor .a3{animation-delay:.6s}
#semServidor .xis path{stroke:#ff5a5a;stroke-width:5;stroke-linecap:round;fill:none}
#semServidor .xis{transform-box:fill-box;transform-origin:center;animation:semServidorXis 2.4s ease-in-out infinite}
#semServidor .olhinhos{fill:currentColor;transform-box:fill-box;transform-origin:center;
animation:semServidorOlhar 5s ease-in-out infinite}
#semServidor .boquinha{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round}
#semServidor .celular{transform-box:fill-box;transform-origin:50% 100%;animation:semServidorProcura 4.8s ease-in-out infinite}
#semServidor.cutucado .celular{animation:semServidorChacoalha .5s ease-in-out}
@keyframes semServidorEntra{from{opacity:0}to{opacity:.82}}
/* em pé no canto direito: o celularzinho olha para cima, para a placa */
@keyframes semServidorOlharCima{0%,30%{transform:translateY(-3px) scaleY(1)}34%{transform:translateY(-3px) scaleY(.1)}38%,60%{transform:translateY(-3px) scaleY(1)}
70%,90%{transform:translateY(1px) scaleY(1)}100%{transform:translateY(-3px)}}
@keyframes semServidorArco{0%{opacity:0}25%{opacity:.9}70%,100%{opacity:0}}
@keyframes semServidorXis{0%,55%,100%{transform:scale(1);opacity:.75}65%{transform:scale(1.25);opacity:1}80%{transform:scale(1);opacity:.85}}
@keyframes semServidorLed{0%{opacity:1}50%{opacity:.15}}
/* o celularzinho olha para a placa, olha de volta e pisca */
@keyframes semServidorOlhar{0%,30%{transform:translateX(-3px) scaleY(1)}34%{transform:translateX(-3px) scaleY(.1)}38%,60%{transform:translateX(-3px) scaleY(1)}
70%,90%{transform:translateX(1px) scaleY(1)}100%{transform:translateX(-3px)}}
@keyframes semServidorProcura{0%,100%{transform:rotate(0)}20%{transform:rotate(-5deg)}40%{transform:rotate(0)}}
@keyframes semServidorChacoalha{0%,100%{transform:rotate(0)}25%{transform:rotate(-9deg)}50%{transform:rotate(7deg)}75%{transform:rotate(-4deg)}}
@media(prefers-reduced-motion:reduce){#semServidor *,#semServidor{animation:none!important}#semServidor .arco{opacity:.6}}
</style>
<body>
<svg id="tela" viewBox="0 0 480 320" preserveAspectRatio="xMidYMid meet">
@@ -106,7 +152,7 @@ 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;
const apagar = SONO.apagar && r.dormindo && !window.carinhaSemServidor && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
if (apagar !== SONO.apagada) {
SONO.apagada = apagar;
window.carinhaTelaApagada = apagar;
@@ -725,6 +771,11 @@ function trata(ordem) {
r.dormindo = true;
atividade(null);
} else if (ordem === "acordar") {
if (window.carinhaSemServidor) { // sem a placa continua dormindo; o aviso chacoalha
const aviso = document.getElementById("semServidor");
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
return;
}
r.dormindo = false; animar("surpreso");
} else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
@@ -777,27 +828,44 @@ async function buscarOrdens() {
try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return;
conexaoOk();
const lista = await resp.json();
for (const nome of lista) trata(nome);
} catch (e) { /* sem servidor (file://) — ignora */ }
}
setInterval(buscarOrdens, 500);
// O app (window.CarinhaApp) guarda a ultima aparencia e os ajustes vindos do servidor:
// a copia do APK (file://) nao enxerga o armazenamento da pagina do servidor.
const LOCAL = location.protocol === "file:";
function lembrar(chave, valor) {
try { if (window.CarinhaApp && !LOCAL) window.CarinhaApp.guardar(chave, JSON.stringify(valor)); } catch (e) {}
}
function lembrado(chave) {
try { const s = window.CarinhaApp && window.CarinhaApp.lembrado(chave); return s ? JSON.parse(s) : null; } catch (e) { return null; }
}
// 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.
// servidor vale a ultima que o app guardou (ou 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 */ }
let c = null;
if (!LOCAL) {
try {
const resp = await fetch("config", { cache: "no-store" });
if (resp.ok) c = await resp.json();
} catch (e) { /* sem servidor */ }
}
if (!c) c = lembrado("aparencia");
if (!c) return;
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); }
}
setTimeout(aplicarAparencia, 200);
// O painel troca rosto/cor por ordens: guarda a aparencia atual de tempos em tempos.
setInterval(() => { if (!window.carinhaSemServidor) lembrar("aparencia", { rosto: ROSTO_NOME, formato: FORMATO, cor: CIANO.slice() }); }, 15000);
// ?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';
@@ -1238,6 +1306,7 @@ function configurarApp(c) {
MOV.cooldown = c.movcooldown; MOV.olharMax = c.movolhar;
if (!c.reagir) { MOV.olharX = MOV.olharY = 0; MOV.ultimoMov = 0; }
configRecebida = c;
lembrar('config', c);
if(!c.seguirRastreamento || !c.sensorCamera || !c.reagir) TRACK.alvo=null;
}
let sincronizando = false;
@@ -1363,6 +1432,7 @@ function laco(t) {
if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
document.body.classList.toggle("atividade", !!ATIV && !ATIV.saindo);
if (window.carinhaSemServidor && !r.dormindo) { r.fila = []; r.kf = null; r.dormindo = true; } // sem a placa: só dorme
const ocupado = congelado ? true : passo(dt);
if (!ocupado) {
descanso += dt;
@@ -1434,6 +1504,81 @@ setTimeout(relatar, 1500);
setInterval(poll,1000);poll();
})();
/* ---------------------------------------------------------------- sem servidor
Sem a placa (copia do APK, ou o servidor caiu com a pagina aberta) a carinha dorme com a
ultima aparencia e ajustes guardados e mostra placa ✕ celular. Volta sozinha quando a placa
responde (o app recarrega a pagina do servidor; aberta pelo servidor, o SSE reconecta). */
let ultimoOk = performance.now();
const TOLERANCIA_SEM_SERVIDOR = 15000; // ms sem resposta antes de dormir
function conexaoOk() { ultimoOk = performance.now(); }
function mostrarSemServidor(ligar) {
let el = document.getElementById("semServidor");
document.body.classList.toggle("semServidor", ligar);
if (!ligar) { if (el) el.hidden = true; return; }
if (!el) {
el = document.createElement("div");
el.id = "semServidor";
// placa e celular pelo canto de cima à esquerda; o desenho vertical vai no canto direito com o celular deitado
const placa = (px, py) =>
'<g class="placa">' +
'<rect x="' + px + '" y="' + py + '" width="84" height="56" rx="6"/>' +
'<rect class="chip" x="' + (px + 29) + '" y="' + (py + 15) + '" width="26" height="26" rx="3"/>' +
[0,1,2,3,4,5,6,7].map((i) => '<circle class="pino" cx="' + (px + 11 + i * 9) + '" cy="' + (py + 7) + '" r="2"/>').join("") +
'<circle class="pino" cx="' + (px + 7) + '" cy="' + (py + 50) + '" r="2.4"/><circle class="pino" cx="' + (px + 77) + '" cy="' + (py + 50) + '" r="2.4"/>' +
'<circle class="led" cx="' + (px + 72) + '" cy="' + (py + 22) + '" r="2.6"/>' +
'</g>';
const celular = (cx, cy) =>
'<g class="celular">' +
'<rect x="' + cx + '" y="' + cy + '" width="40" height="76" rx="8"/>' +
'<g class="olhinhos"><ellipse cx="' + (cx + 13) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/><ellipse cx="' + (cx + 27) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/></g>' +
'<path class="boquinha" d="M' + (cx + 14) + ' ' + (cy + 48) + ' Q' + (cx + 20) + ' ' + (cy + 44) + ' ' + (cx + 26) + ' ' + (cy + 48) + '"/>' +
'</g>';
const xis = (x, y) => '<g class="xis"><path d="M' + (x - 10) + ' ' + (y - 10) + ' L' + (x + 10) + ' ' + (y + 10) + ' M' + (x + 10) + ' ' + (y - 10) + ' L' + (x - 10) + ' ' + (y + 10) + '"/></g>';
el.innerHTML =
'<svg class="horizontal" viewBox="0 0 300 104" aria-hidden="true">' + placa(18, 30) +
'<path class="cabo" d="M104 58 H146"/>' +
'<g class="arcos">' +
'<path class="arco a1" d="M212 50 A10 10 0 0 0 212 66"/>' +
'<path class="arco a2" d="M203 44 A18 18 0 0 0 203 72"/>' +
'<path class="arco a3" d="M194 38 A26 26 0 0 0 194 78"/>' +
'</g>' + xis(160, 58) + celular(220, 18) +
'</svg>' +
'<svg class="vertical" viewBox="0 0 120 262" aria-hidden="true">' + placa(18, 8) +
'<path class="cabo" d="M60 66 V108"/>' +
'<g class="arcos">' +
'<path class="arco a1" d="M52 172 A10 10 0 0 1 68 172"/>' +
'<path class="arco a2" d="M46 163 A18 18 0 0 1 74 163"/>' +
'<path class="arco a3" d="M40 154 A26 26 0 0 1 80 154"/>' +
'</g>' + xis(60, 122) + celular(40, 182) +
'</svg>' +
'<div class="titulo">sem conexão com a placa</div>' +
'<div class="sub"></div>';
document.body.appendChild(el);
}
let alvo = "";
try { alvo = (window.CarinhaApp && window.CarinhaApp.servidor()) || (LOCAL ? "" : location.host); } catch (e) {}
el.querySelector(".sub").textContent = "tentando de novo" + (alvo ? " · " + String(alvo).replace(/^https?:\/\//, "") : "") + "…";
el.style.color = "rgb(" + CIANO.map(Math.round).join(",") + ")";
el.hidden = false;
}
function checarServidor() {
if (MODO_PAINEL || par.get("espelho") === "1") return;
if (window.carinhaSSE) conexaoOk();
const fora = LOCAL || performance.now() - ultimoOk > TOLERANCIA_SEM_SERVIDOR;
if (window.carinhaSemServidor) mostrarSemServidor(true); // acompanha a cor guardada
if (fora === !!window.carinhaSemServidor) return;
window.carinhaSemServidor = fora;
console.log(fora ? "sem servidor: dormindo" : "servidor de volta");
mostrarSemServidor(fora);
if (fora) { atividade(null); r.fila = []; r.kf = null; r.dormindo = true; }
else trata("acordar");
}
// Copia do APK: os ultimos ajustes guardados (boca, sobrancelhas, sono...) valem também aqui.
setTimeout(() => { if (LOCAL) { const c = lembrado("config"); if (c) configurarApp(c); } checarServidor(); }, 300);
setInterval(checarServidor, 1000);
</script>
<script src="/reacoes-render.js"></script>
<script>