- 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>
237 lines
8.2 KiB
TypeScript
237 lines
8.2 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|
||
}
|