Files
carinha-control-panel/web/src/lib/video.svelte.ts
T
julioandClaude Opus 5.5 c5fd54c1d9 feat(celular): rosto e app usam SSE e token; remove telas legadas
- carinha.html assina /eventos (expressao, app_config, conversa, reacao,
  recarregar) e volta ao polling de /fila sozinho se o SSE cair; foto dos
  ajustes a cada (re)conexao; token (?t=) vai em todo fetch ao servidor
- APK 0.8-token: token nas preferencias e no menu de toque longo, enviado
  na pagina, no HTTP do Java e ao llama-server (--api-key); trocar servidor
  por Intent exige o token salvo; camera/microfone so para o servidor
  configurado ou a copia local
- cerebro/conversa mandam a chave ao llama-server do S10
- servidor: evento SSE 'recarregar'; remove /camera/frame e /app/connect;
  enderecos antigos redirecionam para a secao do painel novo
- remove dashboard, painel, conversa, emojis, treinamento, braco, servos,
  api, scripts e CSS antigos, audio-robot.js e servir-local.bat
- logica pura do painel em web/src/lib/nucleo.ts com testes node --test
  (substitui test_movimentos.js e test_rastreamento.js)
- testes de navegador portados para o painel novo (rtc, rosto, rastreamento)
- README, docs/PAINEL.md e docs/SEGURANCA.md reescritos; versao 0.8.0;
  carinha-pc.service le /etc/carinha.env

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:11:58 -03:00

237 lines
8.2 KiB
TypeScript
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.
/**
* Vídeo ao vivo do celular por WebRTC P2P. A sinalização passa pelo servidor (/rtc/*);
* o vídeo vai direto do celular para este navegador. Porta de painel-video.js.
* Só a tela Ao vivo cria uma instância: assim não há disputa pela única sessão.
*/
import { lerToken, pedir } from './api';
import type { AlvoRastreio } from './nucleo';
import type { AppConfig, Telemetria } from './tipos';
export type { AlvoRastreio } from './nucleo';
export class VideoAoVivo {
mensagem = $state('Transmissão desligada.');
exibindo = $state(false);
qualidade = $state('');
private pc: RTCPeerConnection | null = null;
private id: string | null = null;
private desde = 0;
private ice: RTCIceCandidateInit[] = [];
private querido = false;
private abrindo = false;
private consultando = false;
private geracao = 0;
private tentarEm = 0;
private ultimoStats: any = null;
private outroPainel = false;
private canal: RTCDataChannel | null = null;
private seqRastreio = 0;
private qualidadeEfetiva = '';
private config: AppConfig = {};
private relogios: number[] = [];
constructor(private video: HTMLVideoElement) {
video.onloadeddata = video.onplaying = () => this.mostrar();
this.relogios.push(
window.setInterval(() => {
this.iniciar();
this.consultar();
}, 400),
window.setInterval(() => this.estatisticas(), 1000),
);
addEventListener('pagehide', this.aoSair);
}
private mostrar() {
this.exibindo = true;
}
private texto(t: string) {
this.mensagem = t;
this.exibindo = false;
}
private parar(fechar = true) {
this.geracao++;
const antiga = this.id;
this.id = null;
this.pc?.close();
this.pc = null;
this.canal = null;
this.ice = [];
this.desde = 0;
this.ultimoStats = null;
this.video.pause();
this.video.srcObject = null;
this.exibindo = false;
if (antiga && fechar) pedir('/rtc/session', { acao: 'fechar', id: antiga }).catch(() => {});
}
private enviar(mensagem: unknown, sessao?: string) {
return pedir('/rtc/send', { id: sessao || this.id, de: 'pc', mensagem }, 3500);
}
private async iniciar() {
if (!this.querido || this.pc || this.abrindo || this.outroPainel || Date.now() < this.tentarEm) return;
this.abrindo = true;
const geracao = this.geracao;
this.texto('Conectando vídeo ao vivo… O rosto continua no celular.');
try {
const sessao = await pedir<{ id: string }>('/rtc/session', { acao: 'abrir' }, 3500);
if (geracao !== this.geracao || !this.querido) {
pedir('/rtc/session', { acao: 'fechar', id: sessao.id }).catch(() => {});
return;
}
this.id = sessao.id;
const id = sessao.id;
const pc = new RTCPeerConnection({ iceServers: [] });
this.pc = pc;
this.canal = pc.createDataChannel('rastreamento', { ordered: false, maxRetransmits: 0 });
pc.addTransceiver('video', { direction: 'recvonly' });
pc.onicecandidate = (e) => {
if (this.pc === pc && e.candidate) this.enviar({ tipo: 'candidate', candidate: e.candidate.toJSON() }, id).catch(() => {});
};
pc.ontrack = (e) => {
if (this.pc !== pc) return;
this.video.srcObject = e.streams[0] || new MediaStream([e.track]);
this.video.play().catch((err) => this.texto('Clique no vídeo para permitir a reprodução: ' + err.message));
};
pc.onconnectionstatechange = () => {
if (this.pc === pc && pc.connectionState === 'failed') {
this.parar();
this.tentarEm = Date.now() + 2000;
this.texto('Reconectando o vídeo…');
}
};
await pc.setLocalDescription(await pc.createOffer());
if (this.pc !== pc) return;
await this.enviar({ tipo: 'offer', sdp: pc.localDescription!.sdp }, id);
} catch (e) {
this.parar();
this.tentarEm = Date.now() + 2500;
this.texto('Falha ao iniciar vídeo: ' + (e as Error).message);
} finally {
this.abrindo = false;
}
}
private async consultar() {
if (!this.id || !this.pc || this.consultando) return;
this.consultando = true;
const pc = this.pc;
const sid = this.id;
try {
const pacote = await pedir<{ id: string | null; mensagens: { seq: number; mensagem: any }[] }>(
`/rtc/poll?lado=pc&desde=${this.desde}&id=${sid}`,
undefined,
3500,
);
if (this.pc !== pc) return;
if (pacote.id !== sid) {
this.outroPainel = !!pacote.id;
this.parar(false);
this.texto(this.outroPainel ? 'Vídeo aberto em outro painel. Recarregue para assumir.' : 'Aguardando reconexão do vídeo…');
return;
}
for (const { seq, mensagem: m } of pacote.mensagens) {
if (m.tipo === 'answer') {
await pc.setRemoteDescription({ type: 'answer', sdp: m.sdp });
for (const c of this.ice) await pc.addIceCandidate(c);
this.ice = [];
} else if (m.tipo === 'candidate') {
if (pc.remoteDescription) await pc.addIceCandidate(m.candidate);
else this.ice.push(m.candidate);
} else if (m.tipo === 'erro') {
this.parar();
this.tentarEm = Date.now() + 3500;
this.texto('Câmera no celular: ' + m.erro);
return;
}
this.desde = Math.max(this.desde, seq);
}
} catch {
if (this.pc === pc) {
this.parar();
this.tentarEm = Date.now() + 2500;
this.texto('Sinalização interrompida. Reconectando…');
}
} finally {
this.consultando = false;
}
}
private async estatisticas() {
const pc = this.pc;
if (!pc || pc.connectionState !== 'connected') return;
try {
const relatorio = await pc.getStats();
relatorio.forEach((s: any) => {
if (s.type !== 'inbound-rtp' || (s.kind !== 'video' && s.mediaType !== 'video')) return;
const u = this.ultimoStats;
const fps = u ? ((s.framesDecoded - u.framesDecoded) * 1000) / (s.timestamp - u.timestamp) : s.framesPerSecond || 0;
const kbps = u ? ((s.bytesReceived - u.bytesReceived) * 8) / (s.timestamp - u.timestamp) : 0;
this.ultimoStats = s;
const auto =
this.qualidadeEfetiva === 'auto' || (!this.qualidadeEfetiva && this.config.videoResolucao === 'auto' && !this.config.videoFPS);
this.qualidade = `${auto ? 'Automática' : 'Manual'} · ${s.frameWidth || this.video.videoWidth}×${s.frameHeight || this.video.videoHeight} · ${Math.round(fps)} fps · ${Math.round(kbps)} kb/s`;
if (fps > 0) this.mostrar();
});
} catch {
/* conexão em transição */
}
}
get rastreamentoConectado() {
return this.canal?.readyState === 'open';
}
rastrear(alvo: AlvoRastreio | null) {
if (this.canal?.readyState === 'open' && this.canal.bufferedAmount < 4096)
this.canal.send(JSON.stringify({ tipo: 'alvo', seq: ++this.seqRastreio, alvo }));
}
atualizar(status: Telemetria | null, config: AppConfig | null, online: boolean) {
if (!config) return;
this.config = config;
this.qualidadeEfetiva = (status?.estado?.video as any)?.qualidade || '';
const dormindo = !!status?.estado?.dormindo;
const liberada = config.sensorCamera !== false && (!dormindo || config.cameraSono === true);
const quer = !!config.preview && online && liberada;
if (!quer) {
if (this.querido || this.pc) this.parar();
this.querido = false;
this.texto(
!liberada
? 'Câmera desativada pelos ajustes de sensores.'
: config.preview
? 'Celular sem conexão. Abra o app Carinha.'
: 'Transmissão desligada. O rosto e os sensores continuam funcionando.',
);
} else {
this.querido = true;
this.iniciar();
}
}
private aoSair = () => {
if (this.id) {
const t = lerToken();
navigator.sendBeacon(
'/rtc/session' + (t ? '?t=' + encodeURIComponent(t) : ''),
new Blob([JSON.stringify({ acao: 'fechar', id: this.id })], { type: 'application/json' }),
);
}
this.querido = false;
this.parar(false);
};
destruir() {
this.relogios.forEach(clearInterval);
removeEventListener('pagehide', this.aoSair);
this.querido = false;
this.outroPainel = false;
this.parar();
}
}