feat: initial commit - Carinha Control Panel v1.0 com UI glassmorphism, watch-bar, emojis, braco robotico e conversa IA
This commit is contained in:
+138
@@ -0,0 +1,138 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
const video = document.getElementById('cameraVideo');
|
||||
const mensagem = document.getElementById('cameraMensagem');
|
||||
const qualidade = document.getElementById('videoQualidade');
|
||||
let pc = null, id = null, desde = 0, ice = [], busy = false, wanted = false;
|
||||
let opening = false, polling = false, generation = 0, retryAt = 0, last = null, outroPainel = false;
|
||||
let trackingChannel = null;
|
||||
let trackingSequence = 0;
|
||||
let effectiveQuality = '';
|
||||
async function req(path, data) {
|
||||
const r = await fetch(path, {cache:'no-store',signal:AbortSignal.timeout(3500),
|
||||
...(data === undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})});
|
||||
const body = await r.json();
|
||||
if (!r.ok) throw new Error(body.erro || 'HTTP ' + r.status);
|
||||
return body;
|
||||
}
|
||||
function texto(t) {
|
||||
mensagem.textContent = t;
|
||||
mensagem.hidden = false; video.hidden = true;
|
||||
}
|
||||
function parar(fechar = true) {
|
||||
generation++;
|
||||
const antiga = id; id = null;
|
||||
if (pc) pc.close(); pc = null; trackingChannel=null; ice = []; desde = 0; last = null;
|
||||
if (window.CarinhaRastreamento) window.CarinhaRastreamento.parar();
|
||||
video.pause(); video.srcObject = null; video.hidden = true;
|
||||
if (antiga && fechar) req('/rtc/session',{acao:'fechar',id:antiga}).catch(() => {});
|
||||
}
|
||||
async function enviar(msg, sessao) {
|
||||
await req('/rtc/send',{id:sessao || id,de:'pc',mensagem:msg});
|
||||
}
|
||||
async function iniciar() {
|
||||
if (!wanted || pc || opening || outroPainel || Date.now() < retryAt) return;
|
||||
opening = true;
|
||||
const gen = generation;
|
||||
texto('Conectando vídeo ao vivo… O rosto continua no celular.');
|
||||
try {
|
||||
const session = await req('/rtc/session',{acao:'abrir'});
|
||||
if (gen !== generation || !wanted) { req('/rtc/session',{acao:'fechar',id:session.id}).catch(()=>{}); return; }
|
||||
id = session.id;
|
||||
const currentId = id;
|
||||
pc = new RTCPeerConnection({iceServers:[]});
|
||||
const mine = pc;
|
||||
trackingChannel=pc.createDataChannel('rastreamento',{ordered:false,maxRetransmits:0});
|
||||
pc.addTransceiver('video',{direction:'recvonly'});
|
||||
pc.onicecandidate = event => {
|
||||
if (pc === mine && event.candidate) enviar({tipo:'candidate',candidate:event.candidate.toJSON()}, currentId).catch(()=>{});
|
||||
};
|
||||
pc.ontrack = event => {
|
||||
if (pc !== mine) return;
|
||||
video.srcObject = event.streams[0] || new MediaStream([event.track]);
|
||||
video.play().catch(e => texto('Clique em Ligar câmera para permitir o vídeo: ' + e.message));
|
||||
};
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (pc !== mine) return;
|
||||
if (pc.connectionState === 'failed') {
|
||||
parar(); retryAt = Date.now()+2000; texto('Reconectando o vídeo…');
|
||||
}
|
||||
};
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
if (pc !== mine) return;
|
||||
await enviar({tipo:'offer',sdp:pc.localDescription.sdp}, currentId);
|
||||
} catch (e) { parar(); retryAt = Date.now()+2500; texto('Falha ao iniciar vídeo: ' + e.message); }
|
||||
finally { opening = false; }
|
||||
}
|
||||
async function poll() {
|
||||
if (!id || !pc || polling) return;
|
||||
polling = true;
|
||||
const mine = pc, sid = id;
|
||||
try {
|
||||
const packet = await req('/rtc/poll?lado=pc&desde='+desde+'&id='+sid);
|
||||
if (pc !== mine) return;
|
||||
if (packet.id !== sid) {
|
||||
outroPainel = !!packet.id; parar(false);
|
||||
texto(outroPainel ? 'Vídeo aberto em outro painel. Recarregue para assumir o controle.' : 'Aguardando reconexão do vídeo…');
|
||||
return;
|
||||
}
|
||||
for (const envelope of packet.mensagens) {
|
||||
const msg = envelope.mensagem;
|
||||
if (msg.tipo === 'answer') {
|
||||
await pc.setRemoteDescription({type:'answer',sdp:msg.sdp});
|
||||
for (const c of ice) await pc.addIceCandidate(c);
|
||||
ice = [];
|
||||
} else if (msg.tipo === 'candidate') {
|
||||
if (pc.remoteDescription) await pc.addIceCandidate(msg.candidate); else ice.push(msg.candidate);
|
||||
} else if (msg.tipo === 'erro') {
|
||||
parar(); retryAt = Date.now()+3500;
|
||||
texto('Câmera no celular: ' + msg.erro); return;
|
||||
}
|
||||
desde = Math.max(desde,envelope.seq);
|
||||
}
|
||||
} catch (e) { if (pc === mine) { parar(); retryAt = Date.now()+2500; texto('Sinalização interrompida. Reconectando…'); } }
|
||||
finally { polling = false; }
|
||||
}
|
||||
video.onloadeddata = () => { video.hidden = false; mensagem.hidden = true; };
|
||||
video.onplaying = () => { video.hidden = false; mensagem.hidden = true; };
|
||||
let statsBusy = false;
|
||||
setInterval(async () => {
|
||||
if (!pc || pc.connectionState !== 'connected' || statsBusy) return;
|
||||
statsBusy = true;
|
||||
const mine = pc;
|
||||
try {
|
||||
const report = await pc.getStats(); if (pc !== mine) return;
|
||||
report.forEach(s => {
|
||||
if (s.type !== 'inbound-rtp' || (s.kind !== 'video' && s.mediaType !== 'video')) return;
|
||||
const fps = last ? (s.framesDecoded-last.framesDecoded)*1000/(s.timestamp-last.timestamp) : s.framesPerSecond || 0;
|
||||
const kbps = last ? (s.bytesReceived-last.bytesReceived)*8/(s.timestamp-last.timestamp) : 0;
|
||||
last = s;
|
||||
qualidade.textContent = (effectiveQuality==='auto' || (!effectiveQuality && appConfig?.videoResolucao === 'auto' && !appConfig?.videoFPS) ? 'Automática' : 'Manual') + ' · '+(s.frameWidth||video.videoWidth)+' × '+(s.frameHeight||video.videoHeight)+' · '+Math.round(fps)+' fps · '+Math.round(kbps)+' kb/s';
|
||||
if (fps > 0) { video.hidden = false; mensagem.hidden = true; }
|
||||
});
|
||||
} catch (e) { /* conexão em transição */ }
|
||||
finally { statsBusy = false; }
|
||||
},1000);
|
||||
setInterval(() => { iniciar(); poll(); },400);
|
||||
window.CarinhaVideo = {
|
||||
rastrear(alvo) {
|
||||
if (trackingChannel && trackingChannel.readyState==='open' && trackingChannel.bufferedAmount<4096)
|
||||
trackingChannel.send(JSON.stringify({tipo:'alvo',seq:++trackingSequence,alvo}));
|
||||
},
|
||||
rastreamentoConectado() { return !!trackingChannel && trackingChannel.readyState==='open'; },
|
||||
parar() { wanted = false; outroPainel = false; parar(); },
|
||||
atualizar(status, config) {
|
||||
effectiveQuality=(status.estado || {}).video?.qualidade || '';
|
||||
const liberada = config.sensorCamera !== false && (!(status.estado || {}).dormindo || config.cameraSono === true);
|
||||
const nova = !!config.preview && !!status.online && liberada;
|
||||
if (!nova) {
|
||||
if (wanted || pc) parar(); wanted = false;
|
||||
texto(!liberada ? 'Câmera desativada pelos ajustes de sensores.' : config.preview ? 'Celular sem conexão. Abra o Carinha.' : 'Transmissão desligada. O rosto e os sensores continuam funcionando.');
|
||||
} else { wanted = true; iniciar(); }
|
||||
}
|
||||
};
|
||||
window.addEventListener('pagehide', () => {
|
||||
if (id) navigator.sendBeacon('/rtc/session',new Blob([JSON.stringify({acao:'fechar',id})],{type:'application/json'}));
|
||||
wanted = false; parar(false);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user