feat(web): cria painel Svelte com shell, Ao vivo e Ajustes
- web/ com Vite + Svelte 5 + TypeScript; servidor entrega web/dist em "/" (telas antigas seguem em /dashboard, /painel, /braco...) - design system em tokens CSS, tema escuro e claro, fontes locais - estado unico: foto de /estado + um EventSource em /eventos, sem polling - shell com navegacao lateral/inferior, selos de status reais e E-Stop fixo - command palette (Ctrl+K), gestos favoritos com atalhos 1-9, g+letra navega - Ao vivo: camera WebRTC e rastreamento portados para TS, rosto, aparencia, sensores do celular, IA local e feed de acontecimentos - Ajustes: campos do celular agrupados com busca, salvamento otimista e restaurar padrao (GET /app/padrao); motor da conversa, Gemini e token - carinha.html ganha modo ?painel=1 dirigido por postMessage (sem polling) - teste Playwright de ponta a ponta do painel novo Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 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 { AppConfig, Telemetria } from './tipos';
|
||||
|
||||
export interface AlvoRastreio {
|
||||
tipo: 'mao' | 'rosto';
|
||||
x: number;
|
||||
y: number;
|
||||
atividade?: boolean;
|
||||
}
|
||||
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user