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>
This commit is contained in:
julio
2026-10-05 23:07:52 -03:00
co-authored by Claude Opus 5.5
parent 3e51266644
commit a058c6f453
13 changed files with 705 additions and 192 deletions
+207 -188
View File
@@ -1,15 +1,18 @@
<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 { postar } from '../lib/api';
import { salvarApp } from '../lib/ajustes.svelte';
import { tentar } from '../lib/avisos.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 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 Selo from '../lib/componentes/Selo.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { estado } from '../lib/estado.svelte';
import { Rastreamento } from '../lib/rastreamento.svelte';
import { VideoAoVivo } from '../lib/video.svelte';
@@ -19,6 +22,9 @@
let video = $state<VideoAoVivo>();
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(() => {
video = new VideoAoVivo(elVideo);
rastreio = new Rastreamento(elVideo, elTela, video);
@@ -40,16 +46,11 @@
rastreio?.atualizar(cfg, permitido);
});
const sensores = $derived(estado.app?.estado ?? {});
const bateria = $derived.by(() => {
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 cfg = $derived(estado.appConfig);
const transmitindo = $derived(!!cfg?.preview);
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;
const c = document.createElement('canvas');
c.width = elVideo.videoWidth;
@@ -61,69 +62,106 @@
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>
<div class="ao-vivo">
<section class="palco">
<div class="quadro camera">
<!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
<section class="palco" aria-label="Câmera e rosto">
<div class="quadro camera" class:mini={rostoGrande}>
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
<canvas bind:this={elTela} class="sobreposicao" aria-hidden="true"></canvas>
{#if !video?.exibindo}
<div class="vazio">
<Camera size={32} />
<p>{video?.mensagem ?? 'Carregando…'}</p>
<Camera size={rostoGrande ? 18 : 32} />
{#if !rostoGrande}
<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>
{/if}
{#if rostoGrande}
<button class="trocar" onclick={() => (rostoGrande = false)} aria-label="Mostrar a câmera em destaque"></button>
{:else}
<div class="rodape">
<span class="ponto" class:ao-vivo-on={video?.exibindo}></span>
<span>{video?.exibindo ? 'Ao vivo' : 'Câmera'}</span>
{#if video?.qualidade && video.exibindo}<span class="mono">{video.qualidade}</span>{/if}
<span class="espaco"></span>
{#if video?.exibindo}
<button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button>
{/if}
</div>
{/if}
<div class="rodape">
<Selo tom={video?.exibindo ? 'ok' : 'neutro'}>Câmera</Selo>
{#if video?.qualidade && video.exibindo}<span class="mono fraco">{video.qualidade}</span>{/if}
<span class="espaco"></span>
{#if video?.exibindo}
<button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button>
{/if}
</div>
</div>
<div class="quadro rosto">
<div class="quadro rosto" class:mini={!rostoGrande}>
<RostoPrevia titulo="Rosto ao vivo" />
<div class="rodape">
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>Rosto {estado.celularOnline ? 'no celular' : '· prévia'}</Selo>
</div>
{#if !rostoGrande}
<button class="trocar" onclick={() => (rostoGrande = true)} aria-label="Mostrar o rosto em destaque">
<Repeat size={14} />
</button>
{/if}
</div>
</section>
<div class="controles">
<div class="transmissao">
<Toggle
rotulo="Transmitir câmera do celular"
ajuda="Vídeo direto do celular para este PC (WebRTC)"
checked={!!estado.appConfig?.preview}
disabled={!estado.appConfig}
onchange={(v) => salvarApp({ preview: v })}
/>
{#if rastreio && estado.appConfig?.preview}
<p class="fraco pequeno-texto linha"><ScanFace size={14} /> {rastreio.texto}</p>
<div class="lista">
<Grupo titulo="Celular">
<Celular />
<Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
</Grupo>
<Grupo titulo="Câmera" rodape={transmitindo && rastreio ? rastreio.texto : 'O vídeo vai direto do celular para este navegador (WebRTC); nada é gravado.'}>
<Linha icone={Video} cor="#34c759" rotulo="Transmitir câmera do celular" detalhe={video?.exibindo ? video.qualidade : ''}>
{#snippet acessorio()}
<Interruptor rotulo="Transmitir câmera do celular" checked={transmitindo} disabled={!cfg} onchange={(v) => salvarApp({ preview: v })} />
{/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}
</div>
</div>
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
</Grupo>
<div class="grade-cards">
<Card titulo="Gestos" descricao="Favoritos com atalho de 1 a 9. Fixe outros na tela de Reações ou pelo Ctrl+K.">
<Gestos />
</Card>
<Grupo titulo="Gestos" rodape="Teclas 1 a 9 no teclado. Ctrl+K busca qualquer gesto.">
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
<Gestos />
</Linha>
<Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" />
</Grupo>
<Card titulo="Aparência">
<div class="campo">
<span>Rosto</span>
<Grupo titulo="Aparência">
<Linha icone={Smile} cor="#5ac8fa" rotulo="Rosto">
<div class="segmentado" role="radiogroup" aria-label="Rosto">
{#each estado.rostos as r (r)}
<button role="radio" aria-checked={estado.aparencia.rosto === r} onclick={() => rosto(r)}>{r}</button>
{/each}
</div>
</div>
<div class="campo">
<span>Cor dos olhos</span>
</Linha>
<Linha icone={Palette} cor="#ff9500" rotulo="Cor dos olhos">
<div class="cores">
{#each CORES as c (c.nome)}
<button
@@ -134,83 +172,38 @@
onclick={() => cor(c.rgb)}
></button>
{/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))} />
<span class="sr-only">Escolher outra cor</span>
</label>
</div>
</div>
</Card>
</Linha>
</Grupo>
<Card titulo="Celular">
{#snippet acoes()}
<button class="btn pequeno" onclick={recarregarCelular} disabled={!estado.celularOnline}>
<RefreshCw size={14} /> Recarregar tela
</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
<Grupo titulo="Personalidade" rodape={estado.ia?.erro || 'Qwen escolhe expressões a partir de sons e movimentos do celular.'}>
<Linha
icone={Brain}
cor="#5e5ce6"
rotulo="Reagir sozinho com IA local"
ajuda={estado.ia ? `${estado.ia.modelo} · ${estado.ia.executor}` : ''}
checked={!!estado.ia?.ativo}
disabled={!estado.ia}
onchange={iaLocal}
/>
{#if estado.ia?.ultima}
<p class="pequeno-texto linha">
<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>
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}` : ''}
>
{#snippet acessorio()}
<Interruptor rotulo="Reagir sozinho com IA local" checked={!!estado.ia?.ativo} disabled={!estado.ia} onchange={iaLocal} />
{/snippet}
</Linha>
</Grupo>
<Card titulo="Acontecimentos" class="feed">
<Grupo titulo="Acontecimentos">
{#if estado.feed.length}
<ol>
{#each estado.feed as item (item.id)}
<li class={item.tipo}><time class="mono fraco">{hora(item.quando)}</time> {item.texto}</li>
<ol class="feed">
{#each estado.feed.slice(0, 12) as item (item.id)}
<li class={item.tipo}><time class="mono">{hora(item.quando)}</time> <span>{item.texto}</span></li>
{/each}
</ol>
{: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}
</Card>
</Grupo>
</div>
</div>
@@ -218,19 +211,35 @@
.ao-vivo {
display: grid;
gap: var(--e-4);
align-items: start;
}
/* ---------- palco: sempre visível */
.palco {
display: grid;
gap: var(--e-3);
grid-template-columns: 1fr;
position: sticky;
top: calc(var(--topo-altura) + env(safe-area-inset-top, 0px));
z-index: 20;
aspect-ratio: 16 / 9;
margin: calc(-1 * var(--e-4)) calc(-1 * var(--e-4)) 0;
background: #000;
}
.quadro {
position: relative;
aspect-ratio: 16 / 9;
border-radius: var(--raio-lg);
position: absolute;
overflow: hidden;
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,
.sobreposicao {
@@ -249,12 +258,23 @@
display: grid;
place-content: center;
justify-items: center;
gap: var(--e-2);
gap: var(--e-3);
padding: var(--e-5);
text-align: center;
color: #8396ab;
color: #a9b8c9;
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 {
position: absolute;
left: 0;
@@ -264,46 +284,48 @@
align-items: center;
gap: var(--e-2);
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;
font-size: var(--t-xs);
pointer-events: none;
}
.rodape button {
pointer-events: auto;
.rodape .mono {
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 {
flex: 1;
}
.controles {
display: flex;
flex-wrap: wrap;
gap: var(--e-4);
align-items: center;
}
.transmissao {
/* ---------- lista */
.lista {
display: grid;
gap: var(--e-1);
min-width: min(100%, 420px);
}
.grade-cards {
display: grid;
gap: var(--e-4);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
gap: var(--e-5);
min-width: 0;
}
.segmentado {
display: flex;
background: var(--fundo-2);
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
padding: 3px;
gap: 3px;
background: var(--superficie-2);
border-radius: 9px;
padding: 2px;
gap: 2px;
}
.segmentado button {
flex: 1;
min-height: 34px;
min-height: 32px;
border: 0;
border-radius: 6px;
border-radius: 7px;
background: transparent;
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
@@ -311,8 +333,9 @@
cursor: pointer;
}
.segmentado button[aria-checked='true'] {
background: var(--superficie-3);
background: var(--superficie);
color: var(--texto);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
}
.cores {
display: flex;
@@ -341,50 +364,46 @@
opacity: 0;
cursor: pointer;
}
.sensores {
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 {
.feed {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-2);
max-height: 260px;
padding: var(--e-2) 0;
max-height: 280px;
overflow: auto;
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);
}
@media (min-width: 900px) {
.vazio-feed {
padding: var(--e-4);
font-size: var(--t-sm);
color: var(--texto-3);
}
@media (min-width: 640px) {
.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>