- 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>
410 lines
13 KiB
Svelte
410 lines
13 KiB
Svelte
<script lang="ts">
|
|
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 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 { estado } from '../lib/estado.svelte';
|
|
import { Rastreamento } from '../lib/rastreamento.svelte';
|
|
import { VideoAoVivo } from '../lib/video.svelte';
|
|
|
|
let elVideo: HTMLVideoElement;
|
|
let elTela: HTMLCanvasElement;
|
|
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);
|
|
(window as any).carinhaVideo = video; // depuração e testes de ponta a ponta
|
|
});
|
|
onDestroy(() => {
|
|
rastreio?.destruir();
|
|
video?.destruir();
|
|
delete (window as any).carinhaVideo;
|
|
});
|
|
|
|
// Vídeo e rastreamento seguem o estado do servidor (SSE), sem polling próprio.
|
|
$effect(() => {
|
|
const cfg = estado.appConfig ? { ...estado.appConfig } : null;
|
|
const online = estado.celularOnline;
|
|
video?.atualizar(estado.app, cfg, online);
|
|
const dormindo = !!estado.app?.estado?.dormindo;
|
|
const permitido = !!cfg?.preview && online && cfg.sensorCamera !== false && (!dormindo || !!cfg.cameraSono);
|
|
rastreio?.atualizar(cfg, permitido);
|
|
});
|
|
|
|
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' });
|
|
|
|
function foto() {
|
|
if (!elVideo.videoWidth) return;
|
|
const c = document.createElement('canvas');
|
|
c.width = elVideo.videoWidth;
|
|
c.height = elVideo.videoHeight;
|
|
c.getContext('2d')!.drawImage(elVideo, 0, 0);
|
|
const a = document.createElement('a');
|
|
a.href = c.toDataURL('image/png');
|
|
a.download = `carinha-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
|
a.click();
|
|
}
|
|
|
|
const iaLocal = (ativo: boolean) =>
|
|
tentar(() => postar('/ia/config', { ativo }), ativo ? 'Personalidade local ligada.' : 'Personalidade local desligada.');
|
|
</script>
|
|
|
|
<div class="ao-vivo">
|
|
<!-- 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={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>
|
|
|
|
<div class="quadro rosto" class:mini={!rostoGrande}>
|
|
<RostoPrevia titulo="Rosto ao vivo" />
|
|
{#if !rostoGrande}
|
|
<button class="trocar" onclick={() => (rostoGrande = true)} aria-label="Mostrar o rosto em destaque">
|
|
<Repeat size={14} />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</section>
|
|
|
|
<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}
|
|
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
|
|
</Grupo>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
</Linha>
|
|
<Linha icone={Palette} cor="#ff9500" rotulo="Cor dos olhos">
|
|
<div class="cores">
|
|
{#each CORES as c (c.nome)}
|
|
<button
|
|
class="cor"
|
|
style="--c: {hex(c.rgb)}"
|
|
aria-label={c.nome}
|
|
aria-pressed={hex(estado.aparencia.cor) === hex(c.rgb)}
|
|
onclick={() => cor(c.rgb)}
|
|
></button>
|
|
{/each}
|
|
<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>
|
|
</Linha>
|
|
</Grupo>
|
|
|
|
<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"
|
|
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>
|
|
|
|
<Grupo titulo="Acontecimentos">
|
|
{#if estado.feed.length}
|
|
<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="vazio-feed">Gestos, respostas e reações aparecem aqui na hora.</p>
|
|
{/if}
|
|
</Grupo>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.ao-vivo {
|
|
display: grid;
|
|
gap: var(--e-4);
|
|
align-items: start;
|
|
}
|
|
/* ---------- palco: sempre visível */
|
|
.palco {
|
|
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: absolute;
|
|
overflow: hidden;
|
|
background: #000;
|
|
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 {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
.sobreposicao {
|
|
pointer-events: none;
|
|
}
|
|
.vazio {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: grid;
|
|
place-content: center;
|
|
justify-items: center;
|
|
gap: var(--e-3);
|
|
padding: var(--e-5);
|
|
text-align: center;
|
|
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;
|
|
right: 0;
|
|
bottom: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--e-2);
|
|
padding: var(--e-2) var(--e-3);
|
|
background: linear-gradient(transparent, rgb(0 0 0 / 0.75));
|
|
color: #e8eef6;
|
|
font-size: var(--t-xs);
|
|
}
|
|
.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;
|
|
}
|
|
/* ---------- lista */
|
|
.lista {
|
|
display: grid;
|
|
gap: var(--e-5);
|
|
min-width: 0;
|
|
}
|
|
.segmentado {
|
|
display: flex;
|
|
background: var(--superficie-2);
|
|
border-radius: 9px;
|
|
padding: 2px;
|
|
gap: 2px;
|
|
}
|
|
.segmentado button {
|
|
flex: 1;
|
|
min-height: 32px;
|
|
border: 0;
|
|
border-radius: 7px;
|
|
background: transparent;
|
|
color: var(--texto-2);
|
|
font: 500 var(--t-sm) var(--fonte);
|
|
text-transform: capitalize;
|
|
cursor: pointer;
|
|
}
|
|
.segmentado button[aria-checked='true'] {
|
|
background: var(--superficie);
|
|
color: var(--texto);
|
|
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
|
|
}
|
|
.cores {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--e-2);
|
|
}
|
|
.cor {
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: 50%;
|
|
border: 2px solid var(--borda-forte);
|
|
background: var(--c);
|
|
cursor: pointer;
|
|
position: relative;
|
|
}
|
|
.cor[aria-pressed='true'] {
|
|
outline: 2px solid var(--texto);
|
|
outline-offset: 2px;
|
|
}
|
|
.livre {
|
|
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
|
|
}
|
|
.livre input {
|
|
position: absolute;
|
|
inset: 0;
|
|
opacity: 0;
|
|
cursor: pointer;
|
|
}
|
|
.feed {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: var(--e-2) 0;
|
|
max-height: 280px;
|
|
overflow: auto;
|
|
font-size: var(--t-sm);
|
|
}
|
|
.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);
|
|
}
|
|
.vazio-feed {
|
|
padding: var(--e-4);
|
|
font-size: var(--t-sm);
|
|
color: var(--texto-3);
|
|
}
|
|
@media (min-width: 640px) {
|
|
.palco {
|
|
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>
|