Compare commits

..
Author SHA1 Message Date
julioandClaude Opus 5.5 b6b8e2efd5 fix(android): le o sinal do Wi-Fi sem depender do ID da rede
No Android 10+ sem localizacao o networkId vem -1, mas o RSSI e valido.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:57:56 -03:00
julioandClaude Opus 5.5 69d04d4972 chore(deploy): aceita alias SSH (-Ssh) do ~/.ssh/config
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:43:55 -03:00
julioandClaude Opus 5.5 a058c6f453 feat(ao-vivo): camera sempre visivel, lista estilo iOS e dados do aparelho
- palco fixo no topo: camera grande com o rosto num quadrinho no canto
  (toque troca os dois); botao "Ligar camera" direto no palco
- controles em lista agrupada estilo Ajustes do iPhone (Grupo, Linha,
  Interruptor): camera e rastreamento, gestos, aparencia, personalidade
- cartao do celular com modelo, Android, versao do app, bateria, Wi-Fi,
  IP e estado do rosto
- APK 0.8.1 informa ip/modelo/android/app/wifi (ACCESS_WIFI_STATE, so o
  nivel do sinal); servidor usa o IP informado pelo app em vez do
  endereco da conexao

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:07:52 -03:00
14 changed files with 709 additions and 193 deletions
+4 -2
View File
@@ -1,14 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.carinha.rosto" package="br.carinha.rosto"
android:versionCode="8" android:versionCode="9"
android:versionName="0.8-token"> android:versionName="0.8.1">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" /> <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
<!-- precisa de rede para falar com o servidor da carinha (troca de rosto) --> <!-- precisa de rede para falar com o servidor da carinha (troca de rosto) -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- só o nível do sinal do Wi-Fi, para o painel (sem localização nem nome da rede) -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- escutar o ambiente: reage a barulho (surpreso) e a silencio (dormir) --> <!-- escutar o ambiente: reage a barulho (surpreso) e a silencio (dormir) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- camera: pagina de visualizacao e, depois, seguir o movimento --> <!-- camera: pagina de visualizacao e, depois, seguir o movimento -->
+1
View File
@@ -865,6 +865,7 @@ async function sincronizarApp() {
centro: [MOV.cx, MOV.cy], config: configRecebida, centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho, brilho: window.carinhaBrilho,
bateria: window.carinhaBateria, bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo, video: window.carinhaVideo,
}) }); }) });
} }
@@ -1021,8 +1021,53 @@ public class MainActivity extends Activity {
return String.format(java.util.Locale.US, "%.3f", v); return String.format(java.util.Locale.US, "%.3f", v);
} }
private String aparelhoJson = "null";
private long aparelhoEm = 0;
/** IP, modelo, Android, versão do app e sinal do Wi-Fi; recalculado a cada 10 s. */
private String infoAparelho() {
long agora = System.currentTimeMillis();
if (agora - aparelhoEm < 10000) return aparelhoJson;
aparelhoEm = agora;
try {
JSONObject a = new JSONObject();
a.put("ip", ipLocal());
a.put("modelo", (Build.MANUFACTURER + " " + Build.MODEL).trim());
a.put("android", Build.VERSION.RELEASE);
try { a.put("app", getPackageManager().getPackageInfo(getPackageName(), 0).versionName); }
catch (Exception e) { a.put("app", JSONObject.NULL); }
try {
android.net.wifi.WifiManager wifi = (android.net.wifi.WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE);
android.net.wifi.WifiInfo info = wifi == null ? null : wifi.getConnectionInfo();
// Sem localização o Android 10+ esconde o ID da rede, mas o RSSI continua valendo.
if (info != null && info.getRssi() > -127 && info.getRssi() < 0) {
a.put("wifi", android.net.wifi.WifiManager.calculateSignalLevel(info.getRssi(), 5));
a.put("rssi", info.getRssi());
}
} catch (Exception e) { /* sem permissão ou sem Wi-Fi */ }
aparelhoJson = a.toString();
} catch (Exception e) {
aparelhoJson = "null";
}
return aparelhoJson;
}
/** Primeiro IPv4 privado da rede (Wi-Fi), sem precisar de permissão extra. */
private static String ipLocal() {
try {
for (java.net.NetworkInterface n : java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) {
if (!n.isUp() || n.isLoopback()) continue;
for (java.net.InetAddress e : java.util.Collections.list(n.getInetAddresses())) {
if (e instanceof java.net.Inet4Address && e.isSiteLocalAddress()) return e.getHostAddress();
}
}
} catch (Exception e) { /* sem rede */ }
return null;
}
private void atualizarBrilho() { private void atualizarBrilho() {
if (naCamera) return; if (naCamera) return;
web.evaluateJavascript("window.carinhaAparelho=" + infoAparelho(), null);
Intent bateria = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); Intent bateria = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
if (bateria != null) { if (bateria != null) {
int level = bateria.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); int level = bateria.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
+1
View File
@@ -865,6 +865,7 @@ async function sincronizarApp() {
centro: [MOV.cx, MOV.cy], config: configRecebida, centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho, brilho: window.carinhaBrilho,
bateria: window.carinhaBateria, bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo, video: window.carinhaVideo,
}) }); }) });
} }
+12 -1
View File
@@ -1,4 +1,5 @@
"""Configuração persistente e telemetria do celular; sinalização do vídeo WebRTC.""" """Configuração persistente e telemetria do celular; sinalização do vídeo WebRTC."""
import ipaddress
import json import json
import math import math
from pathlib import Path from pathlib import Path
@@ -51,6 +52,16 @@ def validar(dados):
return dados return dados
def ip_informado(dados):
"""IP que o próprio app informa (aparelho.ip); o endereço da conexão pode ser de um proxy."""
aparelho = dados.get('aparelho')
ip = aparelho.get('ip') if isinstance(aparelho, dict) else None
try:
return str(ipaddress.IPv4Address(ip)) if isinstance(ip, str) else None
except ValueError:
return None
class ControlePC: class ControlePC:
def __init__(self, arquivo): def __init__(self, arquivo):
self.arquivo = Path(arquivo) self.arquivo = Path(arquivo)
@@ -134,7 +145,7 @@ class ControlePC:
raise ValueError('Estado deve ser objeto JSON') raise ValueError('Estado deve ser objeto JSON')
with self.lock: with self.lock:
self.estado = dados self.estado = dados
self.celular_ip = h.client_address[0] self.celular_ip = ip_informado(dados) or h.client_address[0]
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]: if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
self.recarga["confirmada"] = True self.recarga["confirmada"] = True
self.status_at = time.monotonic() self.status_at = time.monotonic()
+3 -1
View File
@@ -7,12 +7,14 @@
.\deploy.ps1 # compila, publica em root@192.168.1.35:/opt/carinha-pc e reinicia .\deploy.ps1 # compila, publica em root@192.168.1.35:/opt/carinha-pc e reinicia
.\deploy.ps1 -SoPacote # só gera o pacote local (nada vai para a placa) .\deploy.ps1 -SoPacote # só gera o pacote local (nada vai para a placa)
.\deploy.ps1 -Placa 192.168.1.40 -Usuario carinha .\deploy.ps1 -Placa 192.168.1.40 -Usuario carinha
.\deploy.ps1 -Ssh orangepi # usa um Host do ~/.ssh/config (chave própria)
#> #>
param( param(
[string]$Placa = '192.168.1.35', [string]$Placa = '192.168.1.35',
[string]$Usuario = 'root', [string]$Usuario = 'root',
[string]$Destino = '/opt/carinha-pc', [string]$Destino = '/opt/carinha-pc',
[int]$Porta = 8100, [int]$Porta = 8100,
[string]$Ssh = '',
[switch]$SemCompilar, [switch]$SemCompilar,
[switch]$SoPacote [switch]$SoPacote
) )
@@ -67,7 +69,7 @@ if ($SoPacote) {
return return
} }
$alvo = "$Usuario@$Placa" $alvo = if ($Ssh) { $Ssh } else { "$Usuario@$Placa" }
$remoto = "/tmp/carinha-$versao-$carimbo.tar.gz" $remoto = "/tmp/carinha-$versao-$carimbo.tar.gz"
Write-Host "== Enviando para $alvo" Write-Host "== Enviando para $alvo"
& scp -q $pacote "${alvo}:$remoto" & scp -q $pacote "${alvo}:$remoto"
+6
View File
@@ -86,6 +86,12 @@ class ControleTest(unittest.TestCase):
self.assertTrue(json.loads(body)['online']) self.assertTrue(json.loads(body)['online'])
self.assertEqual(json.loads(body)['estado']['rosto'], 'miudo') self.assertEqual(json.loads(body)['estado']['rosto'], 'miudo')
def test_phone_reports_its_own_ip(self):
self.request('/app/status', {'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F'}})
self.assertEqual(json.loads(self.request('/app/status')[1])['celular_ip'], '192.168.1.16')
self.request('/app/status', {'aparelho': {'ip': 'não é ip'}})
self.assertEqual(json.loads(self.request('/app/status')[1])['celular_ip'], '127.0.0.1')
def test_legacy_camera_and_adb_routes_are_gone(self): def test_legacy_camera_and_adb_routes_are_gone(self):
self.request('/app/config', {'preview': True}) self.request('/app/config', {'preview': True})
self.assertEqual(self.request('/camera/frame', b'\xff\xd8x\xff\xd9', 'image/jpeg')[0], 404) self.assertEqual(self.request('/camera/frame', b'\xff\xd8x\xff\xd9', 'image/jpeg')[0], 404)
+6 -1
View File
@@ -74,8 +74,13 @@ def testar(base, server, capturas):
# 2. telemetria do celular chega por SSE # 2. telemetria do celular chega por SSE
page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8, page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8,
'microfone_ativo': True, 'microfone': 0.3}) 'microfone_ativo': True, 'microfone': 0.3,
'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F', 'android': '12',
'app': '0.8.1', 'wifi': 3, 'rssi': -58}})
expect(page.get_by_text('Celular online · 80%')).to_be_visible(timeout=5000) expect(page.get_by_text('Celular online · 80%')).to_be_visible(timeout=5000)
# Dados que o próprio APK informa aparecem no cartão do celular.
expect(page.get_by_text('samsung SM-G973F')).to_be_visible()
expect(page.get_by_text('192.168.1.16')).to_be_visible()
# 3. gesto pelo dock # 3. gesto pelo dock
kiosk.FILA.limpar() kiosk.FILA.limpar()
+181
View File
@@ -0,0 +1,181 @@
<script lang="ts">
import { BatteryCharging, BatteryFull, BatteryLow, BatteryMedium, Moon, Smartphone, Sun, WifiOff } from '@lucide/svelte';
import { estado } from '../estado.svelte';
// Cartão do aparelho com o que o próprio app informa (IP, modelo, Android, versão, Wi-Fi, bateria).
const s = $derived(estado.app?.estado ?? {});
const ap = $derived(s.aparelho ?? null);
const bateria = $derived.by(() => {
const b = s.bateria;
const n = typeof b === 'number' ? b : b?.nivel;
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
});
const carregando = $derived(typeof s.bateria === 'object' && !!s.bateria?.carregando);
const IconeBateria = $derived(carregando ? BatteryCharging : bateria === null ? BatteryMedium : bateria < 20 ? BatteryLow : bateria < 70 ? BatteryMedium : BatteryFull);
const ip = $derived(ap?.ip || estado.app?.celular_ip || '—');
const visto = $derived(estado.app?.idade ?? null);
</script>
<div class="celular" class:offline={!estado.celularOnline}>
<div class="cabeca">
<span class="aparelho" aria-hidden="true"><Smartphone size={22} /></span>
<div class="nome">
<strong>{ap?.modelo || 'Celular do rosto'}</strong>
<span>
{#if estado.celularOnline}
{[ap?.android && 'Android ' + ap.android, ap?.app && 'app ' + ap.app].filter(Boolean).join(' · ') || 'online'}
{:else}
offline{visto !== null ? ` · visto há ${Math.round(visto)} s` : ''}
{/if}
</span>
</div>
<span class="estado" class:on={estado.celularOnline}>{estado.celularOnline ? 'Online' : 'Offline'}</span>
</div>
{#if estado.celularOnline}
<dl class="dados">
<div>
<dt>Bateria</dt>
<dd class:baixa={bateria !== null && bateria < 20 && !carregando}>
<IconeBateria size={18} />
{bateria !== null ? bateria + '%' : '—'}
{#if carregando}<small>carregando</small>{/if}
</dd>
</div>
<div>
<dt>Wi-Fi</dt>
<dd>
{#if typeof ap?.wifi === 'number'}
<svg viewBox="0 0 20 16" width="20" height="16" aria-hidden="true">
{#each [0, 1, 2, 3] as i (i)}
<rect x={i * 5} y={12 - i * 4} width="3.5" height={4 + i * 4} rx="1" class:cheia={ap.wifi > i} />
{/each}
</svg>
<span class="sr-only">{ap.wifi} de 4</span>
{#if ap.rssi}<small>{ap.rssi} dBm</small>{/if}
{:else}
<WifiOff size={16} /> <small>sem dado</small>
{/if}
</dd>
</div>
<div>
<dt>IP</dt>
<dd class="mono">{ip}</dd>
</div>
<div>
<dt>Rosto</dt>
<dd>
{#if s.dormindo}<Moon size={16} /> Dormindo{:else}<Sun size={16} /> Acordado{/if}
</dd>
</div>
</dl>
{:else}
<p class="ajuda">Abra o app Carinha no celular e confira se ele aponta para este servidor (toque longo › Mudar o servidor).</p>
{/if}
</div>
<style>
.celular {
display: grid;
gap: var(--e-4);
padding: var(--e-4);
}
.cabeca {
display: flex;
align-items: center;
gap: var(--e-3);
}
.aparelho {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, #5e5ce6, #0a84ff);
color: #fff;
flex: none;
}
.offline .aparelho {
background: var(--superficie-3);
color: var(--texto-3);
}
.nome {
flex: 1;
display: grid;
min-width: 0;
}
.nome strong {
font-size: var(--t-md);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nome span {
font-size: var(--t-xs);
color: var(--texto-3);
}
.estado {
font-size: var(--t-xs);
font-weight: 600;
padding: 2px 10px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
color: var(--texto-3);
}
.estado.on {
background: var(--ok-suave);
color: var(--ok);
}
.dados {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--e-2);
margin: 0;
}
.dados div {
display: grid;
gap: 2px;
padding: var(--e-2) var(--e-3);
border-radius: var(--raio);
background: var(--superficie-2);
min-width: 0;
}
dt {
font-size: 11px;
color: var(--texto-3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
dd {
margin: 0;
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: var(--t-sm);
flex-wrap: wrap;
overflow-wrap: anywhere;
}
dd small {
font-weight: 400;
font-size: 11px;
color: var(--texto-3);
}
.baixa {
color: var(--perigo);
}
rect {
fill: var(--superficie-3);
}
rect.cheia {
fill: var(--texto);
}
.ajuda {
font-size: var(--t-sm);
color: var(--texto-3);
}
dd.mono {
white-space: nowrap;
overflow-wrap: normal;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// Lista agrupada no estilo dos Ajustes do iPhone: título pequeno, cartão arredondado, rodapé de ajuda.
let { titulo = '', rodape = '', children }: { titulo?: string; rodape?: string; children: Snippet } = $props();
</script>
<section class="grupo">
{#if titulo}<h2>{titulo}</h2>{/if}
<div class="itens">{@render children()}</div>
{#if rodape}<p class="rodape">{rodape}</p>{/if}
</section>
<style>
.grupo {
display: grid;
gap: 6px;
min-width: 0;
}
h2 {
padding: 0 var(--e-4);
font-size: var(--t-xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--texto-3);
}
.itens {
background: var(--superficie);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
overflow: hidden;
}
.rodape {
padding: 0 var(--e-4);
font-size: var(--t-xs);
color: var(--texto-3);
}
</style>
@@ -0,0 +1,62 @@
<script lang="ts">
// Interruptor no estilo iOS (verde quando ligado). O rótulo vira o nome acessível.
let { rotulo, checked = false, disabled = false, onchange }: {
rotulo: string;
checked?: boolean;
disabled?: boolean;
onchange: (v: boolean) => void;
} = $props();
</script>
<label class="interruptor">
<input type="checkbox" role="switch" aria-label={rotulo} {checked} {disabled} onchange={(e) => onchange(e.currentTarget.checked)} />
<span class="trilho" aria-hidden="true"></span>
</label>
<style>
.interruptor {
position: relative;
display: inline-flex;
cursor: pointer;
}
input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.trilho {
width: 51px;
height: 31px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
position: relative;
transition: background var(--transicao);
}
.trilho::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 27px;
height: 27px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
transition: transform var(--transicao);
}
input:checked + .trilho {
background: #34c759;
}
input:checked + .trilho::after {
transform: translateX(20px);
}
input:focus-visible + .trilho {
outline: 2px solid var(--destaque);
outline-offset: 2px;
}
input:disabled + .trilho {
opacity: 0.45;
cursor: not-allowed;
}
</style>
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import { ChevronRight } from '@lucide/svelte';
import type { Component, Snippet } from 'svelte';
// Uma linha da lista: ícone colorido, rótulo, detalhe e um acessório (interruptor, valor ou seta).
// Com href ou onclick a linha inteira é clicável e mostra a seta.
let {
icone,
cor = 'var(--destaque)',
rotulo,
detalhe = '',
href,
onclick,
disabled = false,
acessorio,
children,
}: {
icone?: Component<any>;
cor?: string;
rotulo: string;
detalhe?: string;
href?: string;
onclick?: () => void;
disabled?: boolean;
acessorio?: Snippet;
children?: Snippet;
} = $props();
const clicavel = $derived(!!href || !!onclick);
</script>
{#snippet corpo()}
{#if icone}
{@const Icone = icone}
<span class="icone" style="--cor: {cor}" aria-hidden="true"><Icone size={16} strokeWidth={2.4} /></span>
{/if}
<span class="texto">
<span class="rotulo">{rotulo}</span>
{#if detalhe}<span class="detalhe">{detalhe}</span>{/if}
</span>
{#if acessorio}<span class="acessorio">{@render acessorio()}</span>{/if}
{#if clicavel}<ChevronRight size={18} class="seta" aria-hidden="true" />{/if}
{/snippet}
<div class="linha-ios" class:com-icone={!!icone} class:desativada={disabled}>
{#if href}
<a class="conteudo" {href}>{@render corpo()}</a>
{:else if onclick}
<button class="conteudo" {onclick} {disabled}>{@render corpo()}</button>
{:else}
<div class="conteudo">{@render corpo()}</div>
{/if}
{#if children}<div class="extra">{@render children()}</div>{/if}
</div>
<style>
.linha-ios {
position: relative;
}
/* separador fino que começa depois do ícone, como no iOS */
.linha-ios:not(:first-child)::before {
content: '';
position: absolute;
top: 0;
right: 0;
left: var(--e-4);
border-top: 1px solid var(--borda);
}
.linha-ios.com-icone:not(:first-child)::before {
left: calc(var(--e-4) + 30px + var(--e-3));
}
.conteudo {
display: flex;
align-items: center;
gap: var(--e-3);
width: 100%;
min-height: 52px;
padding: var(--e-2) var(--e-4);
border: 0;
background: transparent;
color: var(--texto);
font: inherit;
text-align: left;
text-decoration: none;
}
a.conteudo,
button.conteudo {
cursor: pointer;
}
a.conteudo:hover,
button.conteudo:hover:not(:disabled) {
background: var(--superficie-2);
}
a.conteudo:active,
button.conteudo:active:not(:disabled) {
background: var(--superficie-3);
}
.icone {
flex: none;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--cor);
color: #fff;
}
.texto {
flex: 1;
display: grid;
min-width: 0;
}
.rotulo {
font-size: var(--t-sm);
font-weight: 500;
}
.detalhe {
font-size: var(--t-xs);
color: var(--texto-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acessorio {
flex: none;
display: flex;
align-items: center;
gap: var(--e-2);
color: var(--texto-3);
font-size: var(--t-sm);
}
.conteudo :global(.seta) {
flex: none;
color: var(--texto-3);
}
.extra {
padding: 0 var(--e-4) var(--e-4);
}
.desativada {
opacity: 0.5;
}
</style>
+2
View File
@@ -18,6 +18,8 @@ export interface Telemetria {
bateria?: { nivel?: number; carregando?: boolean } | number | null; bateria?: { nivel?: number; carregando?: boolean } | number | null;
brilho?: number; brilho?: number;
video?: { qualidade?: string } | null; video?: { qualidade?: string } | null;
/** Informado pelo próprio app (APK 0.8.1+). */
aparelho?: { ip?: string | null; modelo?: string; android?: string; app?: string | null; wifi?: number; rssi?: number } | null;
[k: string]: unknown; [k: string]: unknown;
}; };
preview: boolean; preview: boolean;
+199 -180
View File
@@ -1,15 +1,18 @@
<script lang="ts"> <script lang="ts">
import { Battery, Brain, Camera, Download, Ear, Moon, RefreshCw, ScanFace } from '@lucide/svelte'; import {
Brain, Camera, Download, Eye, Hand, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
} from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { postar } from '../lib/api'; import { postar } from '../lib/api';
import { salvarApp } from '../lib/ajustes.svelte'; import { salvarApp } from '../lib/ajustes.svelte';
import { tentar } from '../lib/avisos.svelte'; import { tentar } from '../lib/avisos.svelte';
import { cor, CORES, hex, recarregarCelular, rgb, rosto } from '../lib/comandos.svelte'; import { cor, CORES, hex, recarregarCelular, rgb, rosto } from '../lib/comandos.svelte';
import Card from '../lib/componentes/Card.svelte'; import Celular from '../lib/componentes/Celular.svelte';
import Gestos from '../lib/componentes/Gestos.svelte'; import Gestos from '../lib/componentes/Gestos.svelte';
import Grupo from '../lib/componentes/Grupo.svelte';
import Interruptor from '../lib/componentes/Interruptor.svelte';
import Linha from '../lib/componentes/Linha.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte'; import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { estado } from '../lib/estado.svelte'; import { estado } from '../lib/estado.svelte';
import { Rastreamento } from '../lib/rastreamento.svelte'; import { Rastreamento } from '../lib/rastreamento.svelte';
import { VideoAoVivo } from '../lib/video.svelte'; import { VideoAoVivo } from '../lib/video.svelte';
@@ -19,6 +22,9 @@
let video = $state<VideoAoVivo>(); let video = $state<VideoAoVivo>();
let rastreio = $state<Rastreamento>(); let rastreio = $state<Rastreamento>();
// Palco: câmera grande e rosto no quadrinho do canto; tocar no quadrinho troca os dois.
let rostoGrande = $state(false);
onMount(() => { onMount(() => {
video = new VideoAoVivo(elVideo); video = new VideoAoVivo(elVideo);
rastreio = new Rastreamento(elVideo, elTela, video); rastreio = new Rastreamento(elVideo, elTela, video);
@@ -40,16 +46,11 @@
rastreio?.atualizar(cfg, permitido); rastreio?.atualizar(cfg, permitido);
}); });
const sensores = $derived(estado.app?.estado ?? {}); const cfg = $derived(estado.appConfig);
const bateria = $derived.by(() => { const transmitindo = $derived(!!cfg?.preview);
const b = sensores.bateria;
const n = typeof b === 'number' ? b : b?.nivel;
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
});
const pct = (v: unknown) => (typeof v === 'number' ? Math.round(Math.min(1, v) * 100) : 0);
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
async function foto() { function foto() {
if (!elVideo.videoWidth) return; if (!elVideo.videoWidth) return;
const c = document.createElement('canvas'); const c = document.createElement('canvas');
c.width = elVideo.videoWidth; c.width = elVideo.videoWidth;
@@ -61,69 +62,106 @@
a.click(); a.click();
} }
const iaLocal = (ativo: boolean) => tentar(() => postar('/ia/config', { ativo }), ativo ? 'Personalidade local ligada.' : 'Personalidade local desligada.'); const iaLocal = (ativo: boolean) =>
tentar(() => postar('/ia/config', { ativo }), ativo ? 'Personalidade local ligada.' : 'Personalidade local desligada.');
</script> </script>
<div class="ao-vivo"> <div class="ao-vivo">
<section class="palco"> <!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
<div class="quadro camera"> <section class="palco" aria-label="Câmera e rosto">
<div class="quadro camera" class:mini={rostoGrande}>
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video> <video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
<canvas bind:this={elTela} class="sobreposicao" aria-hidden="true"></canvas> <canvas bind:this={elTela} class="sobreposicao" aria-hidden="true"></canvas>
{#if !video?.exibindo} {#if !video?.exibindo}
<div class="vazio"> <div class="vazio">
<Camera size={32} /> <Camera size={rostoGrande ? 18 : 32} />
{#if !rostoGrande}
<p>{video?.mensagem ?? 'Carregando…'}</p> <p>{video?.mensagem ?? 'Carregando…'}</p>
{#if !transmitindo && cfg}
<button class="btn primario" onclick={() => salvarApp({ preview: true })}><Video size={16} /> Ligar câmera</button>
{/if}
{/if}
</div> </div>
{/if} {/if}
{#if rostoGrande}
<button class="trocar" onclick={() => (rostoGrande = false)} aria-label="Mostrar a câmera em destaque"></button>
{:else}
<div class="rodape"> <div class="rodape">
<Selo tom={video?.exibindo ? 'ok' : 'neutro'}>Câmera</Selo> <span class="ponto" class:ao-vivo-on={video?.exibindo}></span>
{#if video?.qualidade && video.exibindo}<span class="mono fraco">{video.qualidade}</span>{/if} <span>{video?.exibindo ? 'Ao vivo' : 'Câmera'}</span>
{#if video?.qualidade && video.exibindo}<span class="mono">{video.qualidade}</span>{/if}
<span class="espaco"></span> <span class="espaco"></span>
{#if video?.exibindo} {#if video?.exibindo}
<button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button> <button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button>
{/if} {/if}
</div> </div>
{/if}
</div> </div>
<div class="quadro rosto">
<div class="quadro rosto" class:mini={!rostoGrande}>
<RostoPrevia titulo="Rosto ao vivo" /> <RostoPrevia titulo="Rosto ao vivo" />
<div class="rodape"> {#if !rostoGrande}
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>Rosto {estado.celularOnline ? 'no celular' : '· prévia'}</Selo> <button class="trocar" onclick={() => (rostoGrande = true)} aria-label="Mostrar o rosto em destaque">
</div> <Repeat size={14} />
</button>
{/if}
</div> </div>
</section> </section>
<div class="controles"> <div class="lista">
<div class="transmissao"> <Grupo titulo="Celular">
<Toggle <Celular />
rotulo="Transmitir câmera do celular" <Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
ajuda="Vídeo direto do celular para este PC (WebRTC)" </Grupo>
checked={!!estado.appConfig?.preview}
disabled={!estado.appConfig} <Grupo titulo="Câmera" rodape={transmitindo && rastreio ? rastreio.texto : 'O vídeo vai direto do celular para este navegador (WebRTC); nada é gravado.'}>
onchange={(v) => salvarApp({ preview: v })} <Linha icone={Video} cor="#34c759" rotulo="Transmitir câmera do celular" detalhe={video?.exibindo ? video.qualidade : ''}>
/> {#snippet acessorio()}
{#if rastreio && estado.appConfig?.preview} <Interruptor rotulo="Transmitir câmera do celular" checked={transmitindo} disabled={!cfg} onchange={(v) => salvarApp({ preview: v })} />
<p class="fraco pequeno-texto linha"><ScanFace size={14} /> {rastreio.texto}</p> {/snippet}
</Linha>
{#if transmitindo}
<Linha icone={ScanFace} cor="#af52de" rotulo="Rastrear rosto">
{#snippet acessorio()}
<Interruptor rotulo="Rastrear rosto" checked={!!cfg?.rastrearRosto} onchange={(v) => salvarApp({ rastrearRosto: v })} />
{/snippet}
</Linha>
<Linha icone={Hand} cor="#ff9500" rotulo="Rastrear mãos">
{#snippet acessorio()}
<Interruptor rotulo="Rastrear mãos" checked={!!cfg?.rastrearMaos} onchange={(v) => salvarApp({ rastrearMaos: v })} />
{/snippet}
</Linha>
<Linha icone={Eye} cor="#007aff" rotulo="Olhos seguem o alvo">
{#snippet acessorio()}
<Interruptor rotulo="Olhos seguem o alvo" checked={!!cfg?.seguirRastreamento} onchange={(v) => salvarApp({ seguirRastreamento: v })} />
{/snippet}
</Linha>
<Linha icone={PenLine} cor="#8e8e93" rotulo="Desenhar rastreamento no vídeo">
{#snippet acessorio()}
<Interruptor rotulo="Desenhar rastreamento no vídeo" checked={!!cfg?.mostrarRastreamento} onchange={(v) => salvarApp({ mostrarRastreamento: v })} />
{/snippet}
</Linha>
{/if} {/if}
</div> <Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
</div> </Grupo>
<div class="grade-cards"> <Grupo titulo="Gestos" rodape="Teclas 1 a 9 no teclado. Ctrl+K busca qualquer gesto.">
<Card titulo="Gestos" descricao="Favoritos com atalho de 1 a 9. Fixe outros na tela de Reações ou pelo Ctrl+K."> <Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
<Gestos /> <Gestos />
</Card> </Linha>
<Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" />
</Grupo>
<Card titulo="Aparência"> <Grupo titulo="Aparência">
<div class="campo"> <Linha icone={Smile} cor="#5ac8fa" rotulo="Rosto">
<span>Rosto</span>
<div class="segmentado" role="radiogroup" aria-label="Rosto"> <div class="segmentado" role="radiogroup" aria-label="Rosto">
{#each estado.rostos as r (r)} {#each estado.rostos as r (r)}
<button role="radio" aria-checked={estado.aparencia.rosto === r} onclick={() => rosto(r)}>{r}</button> <button role="radio" aria-checked={estado.aparencia.rosto === r} onclick={() => rosto(r)}>{r}</button>
{/each} {/each}
</div> </div>
</div> </Linha>
<div class="campo"> <Linha icone={Palette} cor="#ff9500" rotulo="Cor dos olhos">
<span>Cor dos olhos</span>
<div class="cores"> <div class="cores">
{#each CORES as c (c.nome)} {#each CORES as c (c.nome)}
<button <button
@@ -134,83 +172,38 @@
onclick={() => cor(c.rgb)} onclick={() => cor(c.rgb)}
></button> ></button>
{/each} {/each}
<label class="cor livre" style="--c: {hex(estado.aparencia.cor)}" title="Outra cor"> <label class="cor livre" title="Outra cor">
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} /> <input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
<span class="sr-only">Escolher outra cor</span> <span class="sr-only">Escolher outra cor</span>
</label> </label>
</div> </div>
</div> </Linha>
</Card> </Grupo>
<Card titulo="Celular"> <Grupo titulo="Personalidade" rodape={estado.ia?.erro || 'Qwen escolhe expressões a partir de sons e movimentos do celular.'}>
{#snippet acoes()} <Linha
<button class="btn pequeno" onclick={recarregarCelular} disabled={!estado.celularOnline}> icone={Brain}
<RefreshCw size={14} /> Recarregar tela cor="#5e5ce6"
</button>
{/snippet}
{#if estado.celularOnline}
<dl class="sensores">
<div>
<dt><Battery size={16} /> Bateria</dt>
<dd>{bateria !== null ? bateria + '%' : '—'}</dd>
</div>
<div>
<dt><Moon size={16} /> Estado</dt>
<dd>{sensores.dormindo ? 'Dormindo' : 'Acordado'}</dd>
</div>
<div>
<dt><Ear size={16} /> Som</dt>
<dd>
{#if sensores.microfone_ativo}
<meter min="0" max="100" value={pct(sensores.microfone)} aria-label="Nível de som"></meter>
{:else}<span class="fraco">desligado</span>{/if}
</dd>
</div>
<div>
<dt><Camera size={16} /> Movimento</dt>
<dd>
{#if sensores.camera_ativa}
<meter min="0" max="100" value={pct((sensores.movimento as number) * 5)} aria-label="Movimento"></meter>
{:else}<span class="fraco">desligado</span>{/if}
</dd>
</div>
</dl>
<p class="fraco pequeno-texto mono">{estado.app?.celular_ip ?? ''}</p>
{:else}
<p class="fraco pequeno-texto">
O celular não está enviando sinais. Abra o app Carinha no aparelho e confira se ele aponta para este servidor.
</p>
{/if}
</Card>
<Card titulo="Personalidade local" descricao="Qwen escolhe expressões a partir de sons e movimentos.">
<Toggle
rotulo="Reagir sozinho com IA local" rotulo="Reagir sozinho com IA local"
ajuda={estado.ia ? `${estado.ia.modelo} · ${estado.ia.executor}` : ''} detalhe={estado.ia?.ultima ? `Último: ${estado.ia.ultima.evento} → ${estado.ia.ultima.gesto} (${estado.ia.ultima.segundos}s)` : estado.ia ? `${estado.ia.modelo} · ${estado.ia.executor}` : ''}
checked={!!estado.ia?.ativo} >
disabled={!estado.ia} {#snippet acessorio()}
onchange={iaLocal} <Interruptor rotulo="Reagir sozinho com IA local" checked={!!estado.ia?.ativo} disabled={!estado.ia} onchange={iaLocal} />
/> {/snippet}
{#if estado.ia?.ultima} </Linha>
<p class="pequeno-texto linha"> </Grupo>
<Brain size={14} /> Último: {estado.ia.ultima.evento} → <strong>{estado.ia.ultima.gesto}</strong>
<span class="fraco">({estado.ia.ultima.segundos}s)</span>
</p>
{/if}
{#if estado.ia?.erro}<p class="erro pequeno-texto">{estado.ia.erro}</p>{/if}
</Card>
<Card titulo="Acontecimentos" class="feed"> <Grupo titulo="Acontecimentos">
{#if estado.feed.length} {#if estado.feed.length}
<ol> <ol class="feed">
{#each estado.feed as item (item.id)} {#each estado.feed.slice(0, 12) as item (item.id)}
<li class={item.tipo}><time class="mono fraco">{hora(item.quando)}</time> {item.texto}</li> <li class={item.tipo}><time class="mono">{hora(item.quando)}</time> <span>{item.texto}</span></li>
{/each} {/each}
</ol> </ol>
{:else} {:else}
<p class="fraco pequeno-texto">Gestos, respostas e reações aparecem aqui assim que acontecerem.</p> <p class="vazio-feed">Gestos, respostas e reações aparecem aqui na hora.</p>
{/if} {/if}
</Card> </Grupo>
</div> </div>
</div> </div>
@@ -218,19 +211,35 @@
.ao-vivo { .ao-vivo {
display: grid; display: grid;
gap: var(--e-4); gap: var(--e-4);
align-items: start;
} }
/* ---------- palco: sempre visível */
.palco { .palco {
display: grid; position: sticky;
gap: var(--e-3); top: calc(var(--topo-altura) + env(safe-area-inset-top, 0px));
grid-template-columns: 1fr; z-index: 20;
aspect-ratio: 16 / 9;
margin: calc(-1 * var(--e-4)) calc(-1 * var(--e-4)) 0;
background: #000;
} }
.quadro { .quadro {
position: relative; position: absolute;
aspect-ratio: 16 / 9;
border-radius: var(--raio-lg);
overflow: hidden; overflow: hidden;
background: #000; background: #000;
border: 1px solid var(--borda); transition: all 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.quadro:not(.mini) {
inset: 0;
}
.quadro.mini {
right: var(--e-3);
top: var(--e-3);
width: 30%;
aspect-ratio: 16 / 9;
border-radius: var(--raio);
border: 2px solid rgb(255 255 255 / 0.85);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
z-index: 2;
} }
.camera video, .camera video,
.sobreposicao { .sobreposicao {
@@ -249,12 +258,23 @@
display: grid; display: grid;
place-content: center; place-content: center;
justify-items: center; justify-items: center;
gap: var(--e-2); gap: var(--e-3);
padding: var(--e-5); padding: var(--e-5);
text-align: center; text-align: center;
color: #8396ab; color: #a9b8c9;
font-size: var(--t-sm); font-size: var(--t-sm);
} }
.trocar {
position: absolute;
inset: 0;
display: grid;
place-items: start end;
padding: 6px;
border: 0;
background: transparent;
color: #fff;
cursor: pointer;
}
.rodape { .rodape {
position: absolute; position: absolute;
left: 0; left: 0;
@@ -264,46 +284,48 @@
align-items: center; align-items: center;
gap: var(--e-2); gap: var(--e-2);
padding: var(--e-2) var(--e-3); padding: var(--e-2) var(--e-3);
background: linear-gradient(transparent, rgb(0 0 0 / 0.7)); background: linear-gradient(transparent, rgb(0 0 0 / 0.75));
color: #e8eef6; color: #e8eef6;
font-size: var(--t-xs); font-size: var(--t-xs);
pointer-events: none;
} }
.rodape button { .rodape .mono {
pointer-events: auto; opacity: 0.8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ponto {
width: 8px;
height: 8px;
border-radius: 50%;
background: #8e8e93;
flex: none;
}
.ponto.ao-vivo-on {
background: #ff3b30;
box-shadow: 0 0 0 3px rgb(255 59 48 / 0.3);
} }
.espaco { .espaco {
flex: 1; flex: 1;
} }
.controles { /* ---------- lista */
display: flex; .lista {
flex-wrap: wrap;
gap: var(--e-4);
align-items: center;
}
.transmissao {
display: grid; display: grid;
gap: var(--e-1); gap: var(--e-5);
min-width: min(100%, 420px); min-width: 0;
}
.grade-cards {
display: grid;
gap: var(--e-4);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
} }
.segmentado { .segmentado {
display: flex; display: flex;
background: var(--fundo-2); background: var(--superficie-2);
border: 1px solid var(--borda); border-radius: 9px;
border-radius: var(--raio-sm); padding: 2px;
padding: 3px; gap: 2px;
gap: 3px;
} }
.segmentado button { .segmentado button {
flex: 1; flex: 1;
min-height: 34px; min-height: 32px;
border: 0; border: 0;
border-radius: 6px; border-radius: 7px;
background: transparent; background: transparent;
color: var(--texto-2); color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte); font: 500 var(--t-sm) var(--fonte);
@@ -311,8 +333,9 @@
cursor: pointer; cursor: pointer;
} }
.segmentado button[aria-checked='true'] { .segmentado button[aria-checked='true'] {
background: var(--superficie-3); background: var(--superficie);
color: var(--texto); color: var(--texto);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
} }
.cores { .cores {
display: flex; display: flex;
@@ -341,50 +364,46 @@
opacity: 0; opacity: 0;
cursor: pointer; cursor: pointer;
} }
.sensores { .feed {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--e-3);
margin: 0;
}
.sensores div {
display: grid;
gap: 2px;
}
dt {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--t-xs);
color: var(--texto-3);
}
dd {
margin: 0;
font-weight: 500;
}
meter {
width: 100%;
height: 8px;
}
.erro {
color: var(--perigo);
}
:global(.feed) ol {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: var(--e-2) 0;
display: grid; max-height: 280px;
gap: var(--e-2);
max-height: 260px;
overflow: auto; overflow: auto;
font-size: var(--t-sm); font-size: var(--t-sm);
} }
:global(.feed) li.estop { .feed li {
display: flex;
gap: var(--e-3);
padding: 6px var(--e-4);
}
.feed time {
color: var(--texto-3);
flex: none;
}
.feed li.estop {
color: var(--aviso); color: var(--aviso);
} }
@media (min-width: 900px) { .vazio-feed {
padding: var(--e-4);
font-size: var(--t-sm);
color: var(--texto-3);
}
@media (min-width: 640px) {
.palco { .palco {
grid-template-columns: 1fr 1fr; margin: 0;
border-radius: var(--raio-lg);
overflow: hidden;
border: 1px solid var(--borda);
}
}
@media (min-width: 1024px) {
.ao-vivo {
grid-template-columns: minmax(0, 1.5fr) minmax(340px, 1fr);
gap: var(--e-5);
}
.palco {
top: calc(var(--topo-altura) + var(--e-5));
} }
} }
</style> </style>