/** * 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(); } }