feat: enhance call and point registration features with sensor data integration

- Updated the CallWindow component to include connection quality states and reconnection attempts, improving user experience during calls.
- Enhanced the ChatWindow to allow starting audio and video calls in a new window, providing users with more flexibility.
- Integrated accelerometer and gyroscope data collection in the RegistroPonto component, enabling validation of point registration authenticity.
- Improved error handling and user feedback for sensor permissions and data validation, ensuring a smoother registration process.
- Updated backend logic to validate sensor data and adjust confidence scores for point registration, enhancing security against spoofing.
This commit is contained in:
2025-11-22 20:49:52 -03:00
parent fc4b5c5ba5
commit f818756efc
15 changed files with 2100 additions and 275 deletions

View File

@@ -5,69 +5,14 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { X, GripVertical, GripHorizontal } from 'lucide-svelte';
// Tipos para Jitsi (evitando 'any')
interface JitsiConnection {
connect(): void;
disconnect(): void;
addEventListener(event: string, handler: (data?: unknown) => void): void;
initJitsiConference(roomName: string, options: Record<string, unknown>): JitsiConference;
}
interface JitsiConference {
join(): void;
leave(): void;
on(event: string, handler: (...args: unknown[]) => void): void;
removeEventListener(event: string, handler: (...args: unknown[]) => void): void;
muteAudio(): void;
unmuteAudio(): void;
muteVideo(): void;
unmuteVideo(): void;
getParticipants(): Map<string, unknown>;
getLocalTracks(): JitsiTrack[];
setDisplayName(name: string): void;
addTrack(track: JitsiTrack): Promise<void>;
}
interface JitsiTrack {
getType(): 'audio' | 'video';
isMuted(): boolean;
mute(): Promise<void>;
unmute(): Promise<void>;
attach(element: HTMLElement): void;
detach(element: HTMLElement): void;
dispose(): Promise<void>;
getParticipantId(): string;
track: MediaStreamTrack;
}
interface JitsiMeetJSLib {
JitsiConnection: new (appId: string | null, token: string | null, options: Record<string, unknown>) => JitsiConnection;
constants: {
events: {
connection: {
CONNECTION_ESTABLISHED: string;
CONNECTION_FAILED: string;
CONNECTION_DISCONNECTED: string;
};
conference: {
USER_JOINED: string;
USER_LEFT: string;
TRACK_ADDED: string;
TRACK_REMOVED: string;
TRACK_MUTE_CHANGED: string;
CONFERENCE_JOINED: string;
CONFERENCE_LEFT: string;
};
};
logLevels: {
ERROR: number;
};
};
init(options: Record<string, unknown>): void;
setLogLevel(level: number): void;
createLocalTracks(options: Record<string, unknown>): Promise<JitsiTrack[]>;
}
import type {
JitsiConnection,
JitsiConference,
JitsiTrack,
JitsiMeetJSLib,
JitsiConnectionOptions,
WindowWithBlobBuilder
} from '$lib/types/jitsi';
// Importação dinâmica do Jitsi apenas no cliente
let JitsiMeetJS: JitsiMeetJSLib | null = $state(null);
@@ -149,6 +94,12 @@
let errorMessage = $state('');
let errorDetails = $state<string | undefined>(undefined);
// Estados de conexão e qualidade
let qualidadeConexao = $state<'excelente' | 'boa' | 'regular' | 'ruim' | 'desconhecida'>('desconhecida');
let tentativasReconexao = $state(0);
const MAX_TENTATIVAS_RECONEXAO = 3;
let reconectando = $state(false);
// Queries
const chamadaQuery = useQuery(api.chamadas.obterChamada, { chamadaId });
const chamada = $derived(chamadaQuery?.data);
@@ -161,12 +112,29 @@
// Configuração Jitsi (busca do backend primeiro, depois fallback para env vars)
const configJitsi = $derived.by(() => obterConfiguracaoJitsi(configJitsiBackend?.data || null));
// Handler de erro
function handleError(message: string, details?: string): void {
// Handler de erro melhorado
function handleError(message: string, details?: string, podeReconectar: boolean = false): void {
const erroTraduzido = traduzirErro(new Error(message));
errorTitle = erroTraduzido.titulo;
errorMessage = erroTraduzido.mensagem;
errorDetails = details || erroTraduzido.instrucoes;
// Adicionar sugestões de solução baseadas no tipo de erro
let sugestoes = '';
if (message.includes('conectar') || message.includes('servidor')) {
sugestoes = '\n\nSugestões:\n• Verifique sua conexão com a internet\n• Verifique se o servidor Jitsi está acessível\n• Tente recarregar a página';
} else if (message.includes('permissão') || message.includes('microfone') || message.includes('câmera')) {
sugestoes = '\n\nSugestões:\n• Verifique as permissões do navegador para microfone e câmera\n• Certifique-se de que nenhum outro aplicativo está usando os dispositivos\n• Tente recarregar a página e permitir novamente';
} else if (message.includes('certificado') || message.includes('SSL')) {
sugestoes = '\n\nSugestões:\n• Se estiver em desenvolvimento local, aceite o certificado autoassinado\n• Verifique as configurações de segurança do navegador';
}
errorDetails = (details || erroTraduzido.instrucoes) + sugestoes;
// Se pode reconectar e ainda há tentativas
if (podeReconectar && tentativasReconexao < MAX_TENTATIVAS_RECONEXAO) {
errorDetails += `\n\nTentando reconectar automaticamente... (${tentativasReconexao + 1}/${MAX_TENTATIVAS_RECONEXAO})`;
}
showErrorModal = true;
console.error(message, details);
}
@@ -180,13 +148,37 @@
// Polyfill BlobBuilder já deve estar disponível via app.html
// Verificar se está disponível antes de carregar a biblioteca
if (typeof (window as any).BlobBuilder === 'undefined') {
const windowWithBlobBuilder = window as WindowWithBlobBuilder;
if (
typeof windowWithBlobBuilder.BlobBuilder === 'undefined' &&
typeof windowWithBlobBuilder.webkitBlobBuilder === 'undefined' &&
typeof windowWithBlobBuilder.MozBlobBuilder === 'undefined'
) {
console.warn('⚠️ Polyfill BlobBuilder não encontrado, pode causar erros');
}
// Tentar carregar o módulo lib-jitsi-meet dinamicamente
// Usar import dinâmico para evitar problemas de SSR e permitir carregamento apenas no browser
const module = await import('lib-jitsi-meet');
let module;
try {
module = await import('lib-jitsi-meet');
} catch (importError) {
const importErrorMessage = importError instanceof Error ? importError.message : String(importError);
console.error('❌ Erro ao importar lib-jitsi-meet:', importError);
// Verificar se é um erro de módulo não encontrado
if (importErrorMessage.includes('Failed to fetch') ||
importErrorMessage.includes('Cannot find module') ||
importErrorMessage.includes('Failed to resolve') ||
importErrorMessage.includes('Dynamic import')) {
throw new Error(
'A biblioteca Jitsi não pôde ser carregada. ' +
'Verifique se o pacote "lib-jitsi-meet" está instalado corretamente. ' +
'Se o problema persistir, tente limpar o cache do navegador e recarregar a página.'
);
}
throw importError;
}
console.log('📦 Módulo carregado, verificando exportações...', {
hasDefault: !!module.default,
@@ -267,7 +259,11 @@
});
// Verificar se é um erro de módulo não encontrado
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Cannot find module')) {
if (errorMessage.includes('Failed to fetch') ||
errorMessage.includes('Cannot find module') ||
errorMessage.includes('Failed to resolve') ||
errorMessage.includes('Dynamic import') ||
errorMessage.includes('biblioteca Jitsi não pôde ser carregada')) {
handleError(
'Biblioteca de vídeo não encontrada',
'A biblioteca Jitsi não pôde ser encontrada. Verifique se o pacote "lib-jitsi-meet" está instalado. Se o problema persistir, tente limpar o cache do navegador e recarregar a página.'
@@ -320,26 +316,54 @@
const { host, porta } = obterHostEPorta(config.domain);
const protocol = config.useHttps ? 'https' : 'http';
// Para Docker Jitsi local, a configuração deve ser:
// - domain: apenas o host (sem porta)
// - serviceUrl: URL completa com porta para BOSH
// - muc: geralmente conference.host ou apenas host
const options: Record<string, unknown> = {
// Configuração conforme documentação oficial do Jitsi Meet
// https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-ljm-api/
const baseUrl = `${protocol}://${host}${porta && porta !== (config.useHttps ? 443 : 80) ? `:${porta}` : ''}`;
const boshUrl = `${baseUrl}/http-bind`;
// Determinar MUC baseado no host
// Para localhost, usar conference.localhost
// Para domínios reais, usar conference.{host}
const mucDomain = host === 'localhost' || host.startsWith('127.0.0.1')
? `conference.${host}`
: `conference.${host}`;
const options: JitsiConnectionOptions = {
hosts: {
domain: host, // Apenas o host para o domain
muc: `conference.${host}` // MUC no mesmo domínio
domain: host,
muc: mucDomain,
focus: `focus.${host}`
},
serviceUrl: `${protocol}://${host}:${porta}/http-bind`, // BOSH endpoint com porta
bosh: `${protocol}://${host}:${porta}/http-bind`, // BOSH alternativo
clientNode: config.appId
serviceUrl: boshUrl,
bosh: boshUrl,
clientNode: config.appId,
// Opções de performance recomendadas
enableLayerSuspension: true,
enableLipSync: false,
disableAudioLevels: false,
disableSimulcast: false,
enableRemb: true,
enableTcc: true,
useStunTurn: true,
// Configurações de codec
preferredVideoCodec: 'VP8',
disableVP8: false,
disableVP9: false,
disableH264: false,
// Configurações de áudio
stereo: false,
enableOpusRed: true,
enableDtmf: true
};
console.log('🔧 Configurando conexão Jitsi:', {
console.log('🔧 Configurando conexão Jitsi (conforme documentação oficial):', {
host,
porta,
protocol,
baseUrl,
serviceUrl: options.serviceUrl,
muc: options.hosts.muc
muc: options.hosts?.muc,
focus: options.hosts?.focus
});
const connection = new JitsiMeetJS.JitsiConnection(null, null, options);
@@ -350,15 +374,34 @@
connection.addEventListener(JitsiMeetJS.constants.events.connection.CONNECTION_ESTABLISHED, () => {
console.log('✅ Conexão estabelecida');
atualizarStatusConexao(true);
tentativasReconexao = 0; // Resetar contador de tentativas
reconectando = false;
qualidadeConexao = 'boa'; // Inicial como boa
// Iniciar chamada no backend
client.mutation(api.chamadas.iniciarChamada, { chamadaId });
// Criar conferência
// Criar conferência com opções recomendadas pela documentação oficial
const estadoAtual = get(callState);
const conferenceOptions: Record<string, unknown> = {
startAudioMuted: !estadoAtual.audioHabilitado,
startVideoMuted: !estadoAtual.videoHabilitado
startVideoMuted: !estadoAtual.videoHabilitado,
// Opções de P2P (peer-to-peer) para melhor performance
p2p: {
enabled: true,
stunServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
},
// Configurações de qualidade de vídeo
resolution: 720,
maxBitrate: 2500000, // 2.5 Mbps
// Configurações de áudio
audioQuality: {
stereo: false,
opusMaxAverageBitrate: 64000
}
};
const conference = connection.initJitsiConference(roomName, conferenceOptions);
@@ -377,15 +420,41 @@
atualizarStatusConexao(false);
const errorMsg = error instanceof Error ? error.message : String(error);
handleError(
'Erro ao conectar com servidor de vídeo',
`Não foi possível conectar ao servidor Jitsi. Verifique se o servidor está rodando e acessível.\n\nErro: ${errorMsg}`
);
// Tentar reconectar se ainda houver tentativas
if (tentativasReconexao < MAX_TENTATIVAS_RECONEXAO) {
tentativasReconexao++;
reconectando = true;
setTimeout(() => {
console.log(`🔄 Tentativa de reconexão ${tentativasReconexao}/${MAX_TENTATIVAS_RECONEXAO}...`);
connection.connect();
}, 2000 * tentativasReconexao); // Backoff exponencial
} else {
reconectando = false;
handleError(
'Erro ao conectar com servidor de vídeo',
`Não foi possível conectar ao servidor Jitsi após ${MAX_TENTATIVAS_RECONEXAO} tentativas.\n\nErro: ${errorMsg}`,
false
);
}
});
connection.addEventListener(JitsiMeetJS.constants.events.connection.CONNECTION_DISCONNECTED, () => {
console.log('🔌 Conexão desconectada');
atualizarStatusConexao(false);
qualidadeConexao = 'desconhecida';
// Tentar reconectar automaticamente se não foi intencional
if (tentativasReconexao < MAX_TENTATIVAS_RECONEXAO && !reconectando) {
tentativasReconexao++;
reconectando = true;
setTimeout(() => {
console.log(`🔄 Tentando reconectar após desconexão (${tentativasReconexao}/${MAX_TENTATIVAS_RECONEXAO})...`);
connection.connect();
}, 3000);
}
});
// Conectar
@@ -411,7 +480,12 @@
conference.on(JitsiMeetJS.constants.events.conference.USER_JOINED, (id: unknown, user: unknown) => {
console.log('👤 Participante entrou:', id, user);
// Atualizar lista de participantes
atualizarListaParticipantes();
atualizarListaParticipantes().then(() => {
// Atualizar nomes nos overlays
if (typeof id === 'string') {
atualizarNomeParticipante(id);
}
});
});
// Participante saiu
@@ -420,22 +494,17 @@
atualizarListaParticipantes();
});
// Áudio mutado/desmutado
// Áudio/vídeo mutado/desmutado
conference.on(JitsiMeetJS.constants.events.conference.TRACK_MUTE_CHANGED, (track: unknown) => {
const jitsiTrack = track as JitsiTrack;
console.log('🎤 Mute mudou:', jitsiTrack);
if (jitsiTrack.getType() === 'audio') {
// Atualizar estado do participante
atualizarListaParticipantes();
}
});
// Vídeo mutado/desmutado
conference.on(JitsiMeetJS.constants.events.conference.TRACK_MUTE_CHANGED, (track: unknown) => {
const jitsiTrack = track as JitsiTrack;
if (jitsiTrack.getType() === 'video') {
atualizarListaParticipantes();
}
const participantId = jitsiTrack.getParticipantId();
// Atualizar estado do participante
atualizarListaParticipantes().then(() => {
// Atualizar indicadores visuais
atualizarIndicadoresParticipante(participantId);
});
});
// Novo track remoto
@@ -458,9 +527,34 @@
}
);
// Monitorar qualidade de conexão (se disponível)
if ('getConnectionQuality' in conference && typeof conference.getConnectionQuality === 'function') {
setInterval(() => {
try {
// Tentar obter estatísticas de conexão
const stats = (conference as unknown as { getConnectionQuality(): string }).getConnectionQuality();
if (stats === 'high' || stats === 'veryhigh') {
qualidadeConexao = 'excelente';
} else if (stats === 'medium') {
qualidadeConexao = 'boa';
} else if (stats === 'low') {
qualidadeConexao = 'regular';
} else {
qualidadeConexao = 'ruim';
}
} catch {
// Se não disponível, manter como boa
if (qualidadeConexao === 'desconhecida') {
qualidadeConexao = 'boa';
}
}
}, 5000); // Verificar a cada 5 segundos
}
// Conferência iniciada - criar tracks locais
conference.on(JitsiMeetJS.constants.events.conference.CONFERENCE_JOINED, async () => {
console.log('🎉 Conferência iniciada! Criando tracks locais...');
qualidadeConexao = 'boa'; // Inicializar como boa ao entrar na conferência
try {
const estadoAtual = get(callState);
@@ -522,7 +616,13 @@
}
// Atualizar lista de participantes
atualizarListaParticipantes();
atualizarListaParticipantes().then(() => {
// Atualizar todos os nomes e indicadores
remoteVideoElements.forEach((_, participantId) => {
atualizarNomeParticipante(participantId);
atualizarIndicadoresParticipante(participantId);
});
});
} catch (error) {
console.error('Erro ao criar tracks locais:', error);
handleError(
@@ -543,11 +643,30 @@
});
localTracks = [];
// Limpar elementos remotos
remoteVideoElements.forEach((data) => {
const videoElement = data.element.querySelector('video');
if (videoElement) {
data.track.detach(videoElement);
}
data.element.remove();
});
remoteVideoElements.clear();
remoteAudioElements.forEach((audioElement) => {
audioElement.remove();
});
remoteAudioElements.clear();
setStreamLocal(null);
finalizarChamadaStore();
});
}
// Mapa para rastrear elementos de vídeo remotos
let remoteVideoElements = $state<Map<string, { element: HTMLElement; track: JitsiTrack }>>(new Map());
let remoteAudioElements = $state<Map<string, HTMLAudioElement>>(new Map());
// Adicionar track remoto ao container
function adicionarTrackRemoto(track: JitsiTrack): void {
if (!videoContainer) return;
@@ -557,6 +676,11 @@
// Para áudio, criar elemento de áudio invisível
if (trackType === 'audio') {
// Verificar se já existe
if (remoteAudioElements.has(participantId)) {
return;
}
const audioElement = document.createElement('audio');
audioElement.id = `remote-audio-${participantId}`;
audioElement.autoplay = true;
@@ -565,19 +689,115 @@
track.attach(audioElement);
videoContainer.appendChild(audioElement);
remoteAudioElements.set(participantId, audioElement);
return;
}
// Para vídeo, criar elemento de vídeo
// Para vídeo, criar elemento de vídeo com container melhorado
if (trackType === 'video') {
// Verificar se já existe
if (remoteVideoElements.has(participantId)) {
return;
}
// Criar container para o vídeo com indicadores
const container = document.createElement('div');
container.id = `remote-video-container-${participantId}`;
container.className = 'relative aspect-video w-full rounded-lg bg-base-200 overflow-hidden shadow-lg transition-all duration-300';
// Criar elemento de vídeo
const videoElement = document.createElement('video');
videoElement.id = `remote-video-${participantId}`;
videoElement.autoplay = true;
videoElement.playsInline = true;
videoElement.className = 'h-full w-full object-cover rounded-lg';
videoElement.className = 'h-full w-full object-cover';
// Criar overlay com nome do participante
const overlay = document.createElement('div');
overlay.className = 'absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2';
const nomeParticipante = document.createElement('div');
nomeParticipante.className = 'text-white text-sm font-medium';
nomeParticipante.textContent = 'Participante';
// Criar indicadores de áudio/vídeo
const indicators = document.createElement('div');
indicators.className = 'absolute top-2 right-2 flex gap-1';
// Indicador de áudio
const audioIndicator = document.createElement('div');
audioIndicator.id = `audio-indicator-${participantId}`;
audioIndicator.className = 'bg-base-300 opacity-80 rounded-full p-1';
audioIndicator.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.793L4.383 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.383l4-3.617a1 1 0 011.617.793zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z"/></svg>';
// Indicador de vídeo
const videoIndicator = document.createElement('div');
videoIndicator.id = `video-indicator-${participantId}`;
videoIndicator.className = 'bg-base-300 opacity-80 rounded-full p-1';
videoIndicator.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/></svg>';
indicators.appendChild(audioIndicator);
indicators.appendChild(videoIndicator);
overlay.appendChild(nomeParticipante);
container.appendChild(videoElement);
container.appendChild(overlay);
container.appendChild(indicators);
track.attach(videoElement);
videoContainer.appendChild(videoElement);
videoContainer.appendChild(container);
remoteVideoElements.set(participantId, { element: container, track });
// Atualizar nome do participante se disponível
atualizarNomeParticipante(participantId);
}
}
// Atualizar nome do participante no overlay
function atualizarNomeParticipante(participantId: string): void {
const container = remoteVideoElements.get(participantId);
if (!container) return;
const overlay = container.element.querySelector('.absolute.bottom-0');
if (!overlay) return;
const nomeElement = overlay.querySelector('.text-white');
if (!nomeElement) return;
// Buscar nome do participante no estado
const participante = estadoChamada.participantes.find(
p => p.participantId === participantId
);
if (participante) {
nomeElement.textContent = participante.nome;
}
}
// Atualizar indicadores de áudio/vídeo
function atualizarIndicadoresParticipante(participantId: string): void {
const container = remoteVideoElements.get(participantId);
if (!container) return;
const participante = estadoChamada.participantes.find(
p => p.participantId === participantId
);
if (!participante) return;
const audioIndicator = container.element.querySelector(`#audio-indicator-${participantId}`);
const videoIndicator = container.element.querySelector(`#video-indicator-${participantId}`);
if (audioIndicator) {
audioIndicator.className = participante.audioHabilitado
? 'bg-success opacity-80 rounded-full p-1'
: 'bg-error opacity-80 rounded-full p-1';
}
if (videoIndicator) {
videoIndicator.className = participante.videoHabilitado
? 'bg-success opacity-80 rounded-full p-1'
: 'bg-error opacity-80 rounded-full p-1';
}
}
@@ -587,14 +807,24 @@
const participantId = track.getParticipantId();
const trackType = track.getType();
const elementId = trackType === 'audio'
? `remote-audio-${participantId}`
: `remote-video-${participantId}`;
const element = document.getElementById(elementId);
if (element) {
track.detach(element);
element.remove();
if (trackType === 'audio') {
const audioElement = remoteAudioElements.get(participantId);
if (audioElement) {
track.detach(audioElement);
audioElement.remove();
remoteAudioElements.delete(participantId);
}
} else if (trackType === 'video') {
const videoData = remoteVideoElements.get(participantId);
if (videoData) {
const videoElement = videoData.element.querySelector('video');
if (videoElement) {
track.detach(videoElement);
}
videoData.element.remove();
remoteVideoElements.delete(participantId);
}
}
}
@@ -605,13 +835,13 @@
const participants = jitsiConference.getParticipants();
// Mapear participantes para o formato esperado
// Isso pode ser expandido para buscar informações do backend
const participantesAtualizados = Array.from(participants.values()).map((p: unknown) => {
const participant = p as { getId(): string; getDisplayName(): string; isAudioMuted(): boolean; isVideoMuted(): boolean };
const participantesAtualizados = Array.from(participants.values()).map((participant) => {
return {
usuarioId: participant.getId() as Id<'usuarios'>,
nome: participant.getDisplayName() || 'Participante',
audioHabilitado: !participant.isAudioMuted(),
videoHabilitado: !participant.isVideoMuted()
videoHabilitado: !participant.isVideoMuted(),
participantId: participant.getId()
};
});
@@ -937,7 +1167,12 @@
// Polyfill BlobBuilder já deve estar disponível via app.html
// Verificar se está disponível
if (typeof (window as any).BlobBuilder === 'undefined') {
const windowWithBlobBuilder = window as WindowWithBlobBuilder;
if (
typeof windowWithBlobBuilder.BlobBuilder === 'undefined' &&
typeof windowWithBlobBuilder.webkitBlobBuilder === 'undefined' &&
typeof windowWithBlobBuilder.MozBlobBuilder === 'undefined'
) {
console.warn('⚠️ Polyfill BlobBuilder não encontrado no onMount');
}
@@ -1004,12 +1239,24 @@
{/if}
<!-- Container de vídeo -->
<div
bind:this={videoContainer}
class="bg-base-300 flex flex-1 flex-wrap gap-2 p-4"
>
{#if true}
{@const numVideosRemotos = remoteVideoElements.size}
{@const temVideoLocal = tipo === 'video' && estadoChamada.videoHabilitado && localVideo}
{@const totalVideos = (temVideoLocal ? 1 : 0) + numVideosRemotos}
{@const usarGrid = estadoChamada.estaConectado && totalVideos > 0}
{@const numColunas = totalVideos === 1 ? 1 : totalVideos <= 4 ? 2 : 3}
<div
bind:this={videoContainer}
class="bg-base-300 flex-1 gap-4 p-4 overflow-auto"
class:grid={usarGrid}
class:flex={!usarGrid}
class:flex-wrap={!usarGrid}
class:grid-cols-1={usarGrid && numColunas === 1}
class:grid-cols-2={usarGrid && numColunas === 2}
class:grid-cols-3={usarGrid && numColunas === 3}
>
{#if !estadoChamada.estaConectado}
<div class="flex h-full w-full items-center justify-center">
<div class="flex h-full w-full items-center justify-center col-span-full">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-4 text-lg font-medium">Conectando à chamada...</p>
@@ -1021,19 +1268,62 @@
{:else}
<!-- Vídeo Local -->
{#if tipo === 'video' && estadoChamada.videoHabilitado && localVideo}
<div class="aspect-video w-full rounded-lg bg-base-200 overflow-hidden">
<div class="relative aspect-video w-full rounded-lg bg-base-200 overflow-hidden shadow-lg transition-all duration-300">
<video
bind:this={localVideo}
autoplay
muted
playsinline
class="h-full w-full object-cover rounded-lg"
class="h-full w-full object-cover"
></video>
<!-- Overlay com nome local -->
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
<div class="text-white text-sm font-medium">
{meuPerfil?.data?.nome || 'Você'}
</div>
</div>
<!-- Indicadores locais -->
<div class="absolute top-2 right-2 flex gap-1">
<div
class="rounded-full p-1 opacity-80"
class:bg-success={estadoChamada.audioHabilitado}
class:bg-error={!estadoChamada.audioHabilitado}
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.793L4.383 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.383l4-3.617a1 1 0 011.617.793zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z"/>
</svg>
</div>
<div
class="rounded-full p-1 opacity-80"
class:bg-success={estadoChamada.videoHabilitado}
class:bg-error={!estadoChamada.videoHabilitado}
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/>
</svg>
</div>
</div>
</div>
{:else if tipo === 'audio'}
<!-- Placeholder para chamada de áudio -->
<div class="flex h-full w-full items-center justify-center col-span-full">
<div class="text-center">
<div class="bg-primary/20 rounded-full p-8 mx-auto w-32 h-32 flex items-center justify-center mb-4">
<svg class="w-16 h-16 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.793L4.383 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.383l4-3.617a1 1 0 011.617.793zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z"/>
</svg>
</div>
<p class="text-lg font-medium">Chamada de Áudio</p>
<p class="text-sm text-base-content/70 mt-2">
{estadoChamada.participantes.length} participante{estadoChamada.participantes.length !== 1 ? 's' : ''}
</p>
</div>
</div>
{/if}
<!-- Vídeos remotos serão adicionados dinamicamente pelo JavaScript -->
{/if}
</div>
</div>
{/if}
<!-- Controles do anfitrião -->
{#if ehAnfitriao && estadoChamada.participantes.length > 0}

View File

@@ -145,7 +145,7 @@
}
// Funções para chamadas
async function iniciarChamada(tipo: 'audio' | 'video'): Promise<void> {
async function iniciarChamada(tipo: 'audio' | 'video', abrirEmNovaJanela: boolean = false): Promise<void> {
if (chamadaAtual) {
errorTitle = 'Chamada já em andamento';
errorMessage =
@@ -165,6 +165,45 @@
videoHabilitado: tipo === 'video'
});
// Se deve abrir em nova janela
if (abrirEmNovaJanela && browser) {
const { abrirCallWindowEmPopup, verificarSuportePopup } = await import('$lib/utils/callWindowManager');
if (!verificarSuportePopup()) {
errorTitle = 'Popups bloqueados';
errorMessage = 'Seu navegador está bloqueando popups. Por favor, permita popups para este site e tente novamente.';
errorInstructions = 'Verifique as configurações do seu navegador para permitir popups.';
showErrorModal = true;
return;
}
// Buscar informações da chamada para obter roomName
const chamadaInfo = await client.query(api.chamadas.obterChamada, { chamadaId });
if (!chamadaInfo) {
throw new Error('Chamada não encontrada');
}
const meuPerfil = await client.query(api.auth.getCurrentUser, {});
const ehAnfitriao = chamadaInfo.criadoPor === meuPerfil?._id;
// Abrir em popup
const popup = abrirCallWindowEmPopup({
chamadaId: chamadaId as string,
conversaId: conversaId as string,
tipo,
roomName: chamadaInfo.roomName,
ehAnfitriao
});
if (!popup) {
throw new Error('Não foi possível abrir a janela de chamada');
}
// Não definir chamadaAtiva aqui, pois será gerenciada pela janela popup
return;
}
chamadaAtiva = chamadaId;
} catch (error) {
console.error('Erro ao iniciar chamada:', error);
@@ -316,33 +355,99 @@
<!-- Botões de ação -->
<div class="flex items-center gap-1">
<!-- Botões de Chamada -->
{#if !chamadaAtual}
<button
type="button"
class="btn btn-sm btn-circle btn-primary"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('audio');
}}
disabled={iniciandoChamada}
aria-label="Ligação de áudio"
title="Iniciar ligação de áudio"
>
<Phone class="h-5 w-5" strokeWidth={2} />
</button>
<button
type="button"
class="btn btn-sm btn-circle btn-primary"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('video');
}}
disabled={iniciandoChamada}
aria-label="Ligação de vídeo"
title="Iniciar ligação de vídeo"
>
<Video class="h-5 w-5" strokeWidth={2} />
</button>
{#if !chamadaAtual && !chamadaAtiva}
<div class="dropdown dropdown-end">
<button
type="button"
class="btn btn-sm btn-circle btn-primary"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('audio', false);
}}
disabled={iniciandoChamada}
aria-label="Ligação de áudio"
title="Iniciar ligação de áudio"
>
<Phone class="h-5 w-5" strokeWidth={2} />
</button>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[100] w-52 p-2 shadow-lg border border-base-300"
>
<li>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('audio', false);
}}
disabled={iniciandoChamada}
>
<Phone class="h-4 w-4" />
Áudio (nesta janela)
</button>
</li>
<li>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('audio', true);
}}
disabled={iniciandoChamada}
>
<Phone class="h-4 w-4" />
Áudio (nova janela)
</button>
</li>
</ul>
</div>
<div class="dropdown dropdown-end">
<button
type="button"
class="btn btn-sm btn-circle btn-primary"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('video', false);
}}
disabled={iniciandoChamada}
aria-label="Ligação de vídeo"
title="Iniciar ligação de vídeo"
>
<Video class="h-5 w-5" strokeWidth={2} />
</button>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[100] w-52 p-2 shadow-lg border border-base-300"
>
<li>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('video', false);
}}
disabled={iniciandoChamada}
>
<Video class="h-4 w-4" />
Vídeo (nesta janela)
</button>
</li>
<li>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('video', true);
}}
disabled={iniciandoChamada}
>
<Video class="h-4 w-4" />
Vídeo (nova janela)
</button>
</li>
</ul>
</div>
{/if}
<!-- Botão Sair (apenas para grupos e salas de reunião) -->

View File

@@ -108,9 +108,14 @@
}
// Verificar permissões de localização e webcam
async function verificarPermissoes(): Promise<{ localizacao: boolean; webcam: boolean }> {
async function verificarPermissoes(): Promise<{
localizacao: boolean;
webcam: boolean;
permissoesNecessarias: string[];
}> {
let localizacaoAutorizada = false;
let webcamAutorizada = false;
const permissoesNecessarias: string[] = [];
// Verificar permissão de geolocalização
if (navigator.geolocation) {
@@ -126,8 +131,11 @@
localizacaoAutorizada = true;
resolve();
},
() => {
(error) => {
clearTimeout(timeoutId);
if (error.code === error.PERMISSION_DENIED) {
permissoesNecessarias.push('localização');
}
reject(new Error('Permissão de localização negada'));
},
{ timeout: 5000, maximumAge: 0, enableHighAccuracy: false }
@@ -147,10 +155,11 @@
stream.getTracks().forEach(track => track.stop());
} catch (error) {
console.warn('Permissão de webcam não concedida:', error);
permissoesNecessarias.push('câmera');
}
}
return { localizacao: localizacaoAutorizada, webcam: webcamAutorizada };
return { localizacao: localizacaoAutorizada, webcam: webcamAutorizada, permissoesNecessarias };
}
async function registrarPonto() {
@@ -176,7 +185,8 @@
const permissoes = await verificarPermissoes();
if (!permissoes.localizacao || !permissoes.webcam) {
mensagemErroModal = 'Permissões necessárias';
detalhesErroModal = 'Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.';
const permissoesLista = permissoes.permissoesNecessarias.join(', ');
detalhesErroModal = `Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.\n\nPermissões negadas: ${permissoesLista || 'localização e/ou câmera'}`;
mostrarModalErro = true;
return;
}
@@ -188,6 +198,8 @@
try {
// Coletar informações do dispositivo
const informacoesDispositivo = await obterInformacoesDispositivo();
// Nota: A permissão de sensor não é impeditiva - apenas câmera e localização são obrigatórias
coletandoInfo = false;
// Obter tempo sincronizado e aplicar GMT offset (igual ao relógio)
@@ -278,30 +290,80 @@
}, 1000);
} catch (error) {
console.error('Erro ao registrar ponto:', error);
const mensagemErro = error instanceof Error ? error.message : 'Erro ao registrar ponto';
let mensagemErro = 'Erro desconhecido ao registrar ponto';
let detalhesErro = 'Tente novamente em alguns instantes.';
// Verificar se é erro de registro duplicado
if (
mensagemErro.includes('Já existe um registro neste minuto') ||
mensagemErro.includes('já existe um registro')
) {
mensagemErroModal = 'Registro de ponto duplicado';
const tipoLabelErro = config
? getTipoRegistroLabel(proximoTipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(proximoTipo);
detalhesErroModal = `Não é possível registrar o ponto no mesmo minuto.\n\nVocê já possui um registro de ${tipoLabelErro} para este minuto.\n\nPor favor, aguarde pelo menos 1 minuto antes de tentar registrar novamente.`;
mostrarModalErro = true;
} else {
// Outros erros também mostram no modal
mensagemErroModal = 'Erro ao registrar ponto';
detalhesErroModal = mensagemErro;
mostrarModalErro = true;
if (error instanceof Error) {
const erroMessage = error.message || '';
// Erro de registro duplicado
if (
erroMessage.includes('Já existe um registro neste minuto') ||
erroMessage.includes('já existe um registro')
) {
mensagemErro = 'Registro de ponto duplicado';
const tipoLabelErro = config
? getTipoRegistroLabel(proximoTipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(proximoTipo);
detalhesErro = `Não é possível registrar o ponto no mesmo minuto.\n\nVocê já possui um registro de ${tipoLabelErro} para este minuto.\n\nPor favor, aguarde pelo menos 1 minuto antes de tentar registrar novamente.`;
}
// Erro de validação de argumentos
else if (
erroMessage.includes('ArgumentValidationError') ||
erroMessage.includes('Object contains extra field') ||
erroMessage.includes('validation')
) {
mensagemErro = 'Erro na validação dos dados';
detalhesErro = 'Ocorreu um erro ao validar as informações do dispositivo.\n\nPor favor, tente novamente ou recarregue a página.';
}
// Erro de autenticação
else if (
erroMessage.includes('não autenticado') ||
erroMessage.includes('autenticado') ||
erroMessage.includes('auth')
) {
mensagemErro = 'Erro de autenticação';
detalhesErro = 'Sua sessão pode ter expirado. Por favor, faça login novamente.';
}
// Erro de permissão/validação de localização
else if (
erroMessage.includes('localização') ||
erroMessage.includes('Localização') ||
erroMessage.includes('location')
) {
mensagemErro = 'Erro na validação de localização';
detalhesErro = 'Não foi possível validar sua localização.\n\nPor favor, verifique se você autorizou o compartilhamento de localização e tente novamente.';
}
// Erro genérico do servidor
else if (erroMessage.includes('Server Error') || erroMessage.includes('Server')) {
mensagemErro = 'Erro no servidor';
detalhesErro = 'Ocorreu um erro no servidor ao processar seu registro.\n\nPor favor, tente novamente em alguns instantes.';
}
// Outros erros - mostrar mensagem simplificada
else {
mensagemErro = 'Erro ao registrar ponto';
// Se a mensagem de erro for muito técnica, mostrar mensagem genérica
if (
erroMessage.includes('Error:') ||
erroMessage.includes('TypeError') ||
erroMessage.includes('ReferenceError') ||
erroMessage.length > 200
) {
detalhesErro = 'Ocorreu um erro ao processar o registro.\n\nPor favor, tente novamente ou recarregue a página.';
} else {
detalhesErro = erroMessage;
}
}
}
mensagemErroModal = mensagemErro;
detalhesErroModal = detalhesErro;
mostrarModalErro = true;
} finally {
registrando = false;
coletandoInfo = false;

View File

@@ -9,6 +9,7 @@
import AreaChart from './charts/AreaChart.svelte';
import DoughnutChart from './charts/DoughnutChart.svelte';
import BarChart from './charts/BarChart.svelte';
import { obterInformacoesDispositivo } from '$lib/utils/deviceInfo';
const client = useConvexClient();
@@ -45,6 +46,18 @@
serviceWorkerStatus: string;
domNodes: number;
jsHeapSize: number;
// GPS
latitude?: number;
longitude?: number;
gpsPrecision?: number;
gpsConfidence?: number;
// Acelerômetro
accelerometerX?: number;
accelerometerY?: number;
accelerometerZ?: number;
movementDetected?: boolean;
movementMagnitude?: number;
sensorAvailable?: boolean;
};
type NetworkInformationEx = {
@@ -548,7 +561,8 @@
usuariosOnline,
tempoRespostaMedio,
batteryInfo,
indexedDBSize
indexedDBSize,
deviceInfo
] = await Promise.all([
estimateCPU(),
Promise.resolve(getMemoryUsage()),
@@ -557,14 +571,15 @@
getUsuariosOnline(),
getResponseTime(),
getBatteryInfo(),
getIndexedDBSize()
getIndexedDBSize(),
obterInformacoesDispositivo().catch(() => ({}) as Record<string, unknown>) // Capturar erro se falhar
]);
const browserInfo = getBrowserInfo();
const networkInfo = getNetworkInfo();
const wsInfo = getWebSocketStatus();
const newMetrics = {
const newMetrics: Metrics = {
timestamp: Date.now(),
cpuUsage,
memoryUsage,
@@ -594,7 +609,19 @@
indexedDBSize,
serviceWorkerStatus: getServiceWorkerStatus(),
domNodes: getDOMNodeCount(),
jsHeapSize: getJSHeapSize()
jsHeapSize: getJSHeapSize(),
// GPS
latitude: deviceInfo.latitude,
longitude: deviceInfo.longitude,
gpsPrecision: deviceInfo.precisao,
gpsConfidence: deviceInfo.confiabilidadeGPS,
// Acelerômetro
accelerometerX: deviceInfo.acelerometro?.x,
accelerometerY: deviceInfo.acelerometro?.y,
accelerometerZ: deviceInfo.acelerometro?.z,
movementDetected: deviceInfo.acelerometro?.movimentoDetectado,
movementMagnitude: deviceInfo.acelerometro?.magnitude,
sensorAvailable: deviceInfo.sensorDisponivel
};
// Resetar contadores
@@ -1445,6 +1472,181 @@
</div>
</div>
<!-- Seção de GPS e Sensores -->
<div class="mt-8">
<div class="divider">
<h2 class="text-primary flex items-center gap-3 text-2xl font-bold">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
GPS e Sensores
</h2>
</div>
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Latitude -->
{#if metrics.latitude !== undefined}
<div
class="stat bg-base-100 border-primary/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
>
<div class="stat-title font-semibold">Latitude</div>
<div class="stat-value text-primary text-2xl">{metrics.latitude.toFixed(6)}</div>
<div class="stat-desc mt-2">
<div class="badge badge-primary badge-sm">GPS</div>
</div>
</div>
{/if}
<!-- Longitude -->
{#if metrics.longitude !== undefined}
<div
class="stat bg-base-100 border-primary/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
>
<div class="stat-title font-semibold">Longitude</div>
<div class="stat-value text-primary text-2xl">{metrics.longitude.toFixed(6)}</div>
<div class="stat-desc mt-2">
<div class="badge badge-primary badge-sm">GPS</div>
</div>
</div>
{/if}
<!-- Precisão GPS -->
{#if metrics.gpsPrecision !== undefined}
<div
class="stat bg-base-100 border-info/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
>
<div class="stat-title font-semibold">Precisão GPS</div>
<div class="stat-value text-info text-2xl">{metrics.gpsPrecision.toFixed(2)}m</div>
<div class="stat-desc mt-2">
<div class="badge badge-info badge-sm">Precisão</div>
</div>
</div>
{/if}
<!-- Confiança GPS -->
{#if metrics.gpsConfidence !== undefined}
<div
class="stat bg-base-100 border-success/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
>
<div class="stat-title font-semibold">Confiança GPS</div>
<div class="stat-value text-success text-2xl">
{(metrics.gpsConfidence * 100).toFixed(1)}%
</div>
<div class="stat-desc mt-2">
<div class="badge badge-success badge-sm">Confiança</div>
</div>
</div>
{/if}
<!-- Acelerômetro X -->
{#if metrics.accelerometerX !== undefined}
<div
class="stat bg-base-100 border-warning/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
>
<div class="stat-title font-semibold">Acelerômetro X</div>
<div class="stat-value text-warning text-2xl">{metrics.accelerometerX.toFixed(3)}</div>
<div class="stat-desc mt-2">
<div class="badge badge-warning badge-sm">m/s²</div>
</div>
</div>
{/if}
<!-- Acelerômetro Y -->
{#if metrics.accelerometerY !== undefined}
<div
class="stat bg-base-100 border-warning/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
>
<div class="stat-title font-semibold">Acelerômetro Y</div>
<div class="stat-value text-warning text-2xl">{metrics.accelerometerY.toFixed(3)}</div>
<div class="stat-desc mt-2">
<div class="badge badge-warning badge-sm">m/s²</div>
</div>
</div>
{/if}
<!-- Acelerômetro Z -->
{#if metrics.accelerometerZ !== undefined}
<div
class="stat bg-base-100 border-warning/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
>
<div class="stat-title font-semibold">Acelerômetro Z</div>
<div class="stat-value text-warning text-2xl">{metrics.accelerometerZ.toFixed(3)}</div>
<div class="stat-desc mt-2">
<div class="badge badge-warning badge-sm">m/s²</div>
</div>
</div>
{/if}
<!-- Magnitude de Movimento -->
{#if metrics.movementMagnitude !== undefined}
<div
class="stat bg-base-100 border-error/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
>
<div class="stat-title font-semibold">Magnitude de Movimento</div>
<div class="stat-value text-error text-2xl">{metrics.movementMagnitude.toFixed(3)}</div>
<div class="stat-desc mt-2">
<div class="badge badge-error badge-sm">m/s²</div>
</div>
</div>
{/if}
<!-- Movimento Detectado -->
{#if metrics.movementDetected !== undefined}
<div
class="stat bg-base-100 border-accent/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
>
<div class="stat-title font-semibold">Movimento Detectado</div>
<div class="stat-value text-accent text-3xl">
{metrics.movementDetected ? 'Sim' : 'Não'}
</div>
<div class="stat-desc mt-2">
<div
class="badge {metrics.movementDetected ? 'badge-success' : 'badge-warning'} badge-sm"
>
{metrics.movementDetected ? 'Ativo' : 'Inativo'}
</div>
</div>
</div>
{/if}
<!-- Status do Sensor -->
{#if metrics.sensorAvailable !== undefined}
<div
class="stat bg-base-100 border-secondary/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
>
<div class="stat-title font-semibold">Sensor Disponível</div>
<div class="stat-value text-secondary text-3xl">
{metrics.sensorAvailable ? 'Sim' : 'Não'}
</div>
<div class="stat-desc mt-2">
<div
class="badge {metrics.sensorAvailable ? 'badge-success' : 'badge-ghost'} badge-sm"
>
{metrics.sensorAvailable ? 'Disponível' : 'Indisponível'}
</div>
</div>
</div>
{/if}
</div>
</div>
<!-- Seção de Gráficos Interativos -->
{#if metricsHistory.length > 5}
<div class="mt-8">
@@ -1608,14 +1810,16 @@
</svg>
<div>
<h3 class="font-bold">
Monitoramento Ativo (Modo Local) - 23 Métricas Técnicas + 4 Gráficos Interativos
Monitoramento Ativo (Modo Local) - Métricas Técnicas + GPS + Sensores + 4 Gráficos Interativos
</h3>
<div class="text-xs">
<strong>Sistema:</strong> CPU, RAM, Latência, Storage |
<strong>Aplicação:</strong> Usuários, Mensagens, Tempo Resposta, Erros, HTTP Requests |
<strong>Performance:</strong> FPS, Conexão, Navegador, Tela |
<strong>Hardware:</strong> RAM Física, Núcleos CPU, Cache, Bateria, Uptime |
<strong>Avançado:</strong> WebSocket, IndexedDB, Service Worker, DOM Nodes, JS Heap
<strong>Avançado:</strong> WebSocket, IndexedDB, Service Worker, DOM Nodes, JS Heap |
<strong>GPS:</strong> Latitude, Longitude, Precisão, Confiança |
<strong>Sensores:</strong> Acelerômetro (X, Y, Z), Magnitude, Movimento Detectado
<br />
<strong>Gráficos:</strong> Linha (Recursos), Área (Atividade), Donut (Distribuição),
Barras (Métricas)

View File

@@ -4,6 +4,7 @@
import { writable, derived, get } from 'svelte/store';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { JitsiApi } from '$lib/types/jitsi';
export interface ParticipanteChamada {
usuarioId: Id<'usuarios'>;
@@ -32,7 +33,7 @@ export interface EstadoChamada {
cameraId: string | null;
speakerId: string | null;
};
jitsiApi: any | null;
jitsiApi: JitsiApi;
streamLocal: MediaStream | null;
}
@@ -282,7 +283,7 @@ export function atualizarDispositivos(dispositivos: {
/**
* Definir API Jitsi
*/
export function setJitsiApi(api: any | null): void {
export function setJitsiApi(api: JitsiApi): void {
callState.update((state) => ({
...state,
jitsiApi: api

331
apps/web/src/lib/types/jitsi.d.ts vendored Normal file
View File

@@ -0,0 +1,331 @@
/**
* Definições de tipo para lib-jitsi-meet
* Baseado na documentação oficial do Jitsi Meet
*/
export interface JitsiConnection {
connect(): void;
disconnect(): void;
addEventListener(event: string, handler: (data?: unknown) => void): void;
removeEventListener(event: string, handler: (data?: unknown) => void): void;
initJitsiConference(roomName: string, options: Record<string, unknown>): JitsiConference;
}
export interface JitsiConference {
join(): void;
leave(): void;
on(event: string, handler: (...args: unknown[]) => void): void;
off(event: string, handler: (...args: unknown[]) => void): void;
removeEventListener(event: string, handler: (...args: unknown[]) => void): void;
muteAudio(): void;
unmuteAudio(): void;
muteVideo(): void;
unmuteVideo(): void;
getParticipants(): Map<string, JitsiParticipant>;
getLocalTracks(): JitsiTrack[];
setDisplayName(name: string): void;
addTrack(track: JitsiTrack): Promise<void>;
removeTrack(track: JitsiTrack): Promise<void>;
getLocalVideoTrack(): JitsiTrack | null;
getLocalAudioTrack(): JitsiTrack | null;
}
export interface JitsiTrack {
getType(): 'audio' | 'video';
isMuted(): boolean;
mute(): Promise<void>;
unmute(): Promise<void>;
attach(element: HTMLElement): void;
detach(element: HTMLElement): void;
dispose(): Promise<void>;
getParticipantId(): string;
track: MediaStreamTrack;
isLocal(): boolean;
getVideoType(): 'camera' | 'desktop' | undefined;
}
export interface JitsiParticipant {
getId(): string;
getDisplayName(): string;
isAudioMuted(): boolean;
isVideoMuted(): boolean;
getRole(): string;
}
export interface JitsiMeetJSLib {
JitsiConnection: new (
appId: string | null,
token: string | null,
options: JitsiConnectionOptions
) => JitsiConnection;
constants: {
events: {
connection: {
CONNECTION_ESTABLISHED: string;
CONNECTION_FAILED: string;
CONNECTION_DISCONNECTED: string;
WRONG_STATE: string;
};
conference: {
USER_JOINED: string;
USER_LEFT: string;
TRACK_ADDED: string;
TRACK_REMOVED: string;
TRACK_MUTE_CHANGED: string;
CONFERENCE_JOINED: string;
CONFERENCE_LEFT: string;
CONFERENCE_ERROR: string;
DISPLAY_NAME_CHANGED: string;
DOMINANT_SPEAKER_CHANGED: string;
};
};
logLevels: {
ERROR: number;
WARN: number;
INFO: number;
DEBUG: number;
};
};
init(options: JitsiInitOptions): void;
setLogLevel(level: number): void;
createLocalTracks(
options: MediaStreamConstraints,
advancedOptions?: JitsiLocalTrackOptions
): Promise<JitsiTrack[]>;
mediaDevices: {
enumerateDevices(): Promise<MediaDeviceInfo[]>;
isDeviceChangeAvailable(type: 'audio' | 'video'): Promise<boolean>;
};
}
export interface JitsiConnectionOptions {
hosts?: {
domain?: string;
muc?: string;
focus?: string;
};
serviceUrl?: string;
bosh?: string;
websocket?: string;
clientNode?: string;
useStunTurn?: boolean;
iceServers?: RTCIceServer[];
enableLayerSuspension?: boolean;
enableLipSync?: boolean;
disableAudioLevels?: boolean;
disableSimulcast?: boolean;
enableRemb?: boolean;
enableTcc?: boolean;
useRoomAsSharedDocumentName?: boolean;
enableStatsID?: boolean;
channelLastN?: number;
startBitrate?: number;
stereo?: boolean;
forcedVideoCodec?: string;
preferredVideoCodec?: string;
disableH264?: boolean;
disableVP8?: boolean;
disableVP9?: boolean;
enableOpusRed?: boolean;
enableDtmf?: boolean;
openBridgeChannel?: string | boolean;
}
export interface JitsiInitOptions {
disableAudioLevels?: boolean;
disableSimulcast?: boolean;
enableWindowOnErrorHandler?: boolean;
enableRemb?: boolean;
enableTcc?: boolean;
disableThirdPartyRequests?: boolean;
useStunTurn?: boolean;
iceServers?: RTCIceServer[];
}
export interface JitsiLocalTrackOptions {
devices?: string[];
cameraDeviceId?: string;
micDeviceId?: string;
facingMode?: 'user' | 'environment';
resolution?: number;
frameRate?: number;
}
// Extensão do Window para BlobBuilder (polyfill)
export interface WindowWithBlobBuilder extends Window {
BlobBuilder?: {
new (): {
append(data: Blob | string): void;
getBlob(type?: string): Blob;
};
};
webkitBlobBuilder?: {
new (): {
append(data: Blob | string): void;
getBlob(type?: string): Blob;
};
};
MozBlobBuilder?: {
new (): {
append(data: Blob | string): void;
getBlob(type?: string): Blob;
};
};
}
// Tipo para API Jitsi (pode ser Connection ou Conference)
export type JitsiApi = JitsiConnection | JitsiConference | null;
// Declaração de módulo para lib-jitsi-meet
declare module 'lib-jitsi-meet' {
export interface JitsiConnection {
connect(): void;
disconnect(): void;
addEventListener(event: string, handler: (data?: unknown) => void): void;
removeEventListener(event: string, handler: (data?: unknown) => void): void;
initJitsiConference(roomName: string, options: Record<string, unknown>): JitsiConference;
}
export interface JitsiConference {
join(): void;
leave(): void;
on(event: string, handler: (...args: unknown[]) => void): void;
off(event: string, handler: (...args: unknown[]) => void): void;
removeEventListener(event: string, handler: (...args: unknown[]) => void): void;
muteAudio(): void;
unmuteAudio(): void;
muteVideo(): void;
unmuteVideo(): void;
getParticipants(): Map<string, JitsiParticipant>;
getLocalTracks(): JitsiTrack[];
setDisplayName(name: string): void;
addTrack(track: JitsiTrack): Promise<void>;
removeTrack(track: JitsiTrack): Promise<void>;
getLocalVideoTrack(): JitsiTrack | null;
getLocalAudioTrack(): JitsiTrack | null;
}
export interface JitsiTrack {
getType(): 'audio' | 'video';
isMuted(): boolean;
mute(): Promise<void>;
unmute(): Promise<void>;
attach(element: HTMLElement): void;
detach(element: HTMLElement): void;
dispose(): Promise<void>;
getParticipantId(): string;
track: MediaStreamTrack;
isLocal(): boolean;
getVideoType(): 'camera' | 'desktop' | undefined;
}
export interface JitsiParticipant {
getId(): string;
getDisplayName(): string;
isAudioMuted(): boolean;
isVideoMuted(): boolean;
getRole(): string;
}
export interface JitsiMeetJSLib {
JitsiConnection: new (
appId: string | null,
token: string | null,
options: JitsiConnectionOptions
) => JitsiConnection;
constants: {
events: {
connection: {
CONNECTION_ESTABLISHED: string;
CONNECTION_FAILED: string;
CONNECTION_DISCONNECTED: string;
WRONG_STATE: string;
};
conference: {
USER_JOINED: string;
USER_LEFT: string;
TRACK_ADDED: string;
TRACK_REMOVED: string;
TRACK_MUTE_CHANGED: string;
CONFERENCE_JOINED: string;
CONFERENCE_LEFT: string;
CONFERENCE_ERROR: string;
DISPLAY_NAME_CHANGED: string;
DOMINANT_SPEAKER_CHANGED: string;
};
};
logLevels: {
ERROR: number;
WARN: number;
INFO: number;
DEBUG: number;
};
};
init(options: JitsiInitOptions): void;
setLogLevel(level: number): void;
createLocalTracks(
options: MediaStreamConstraints,
advancedOptions?: JitsiLocalTrackOptions
): Promise<JitsiTrack[]>;
mediaDevices: {
enumerateDevices(): Promise<MediaDeviceInfo[]>;
isDeviceChangeAvailable(type: 'audio' | 'video'): Promise<boolean>;
};
}
export interface JitsiConnectionOptions {
hosts?: {
domain?: string;
muc?: string;
focus?: string;
};
serviceUrl?: string;
bosh?: string;
websocket?: string;
clientNode?: string;
useStunTurn?: boolean;
iceServers?: RTCIceServer[];
enableLayerSuspension?: boolean;
enableLipSync?: boolean;
disableAudioLevels?: boolean;
disableSimulcast?: boolean;
enableRemb?: boolean;
enableTcc?: boolean;
useRoomAsSharedDocumentName?: boolean;
enableStatsID?: boolean;
channelLastN?: number;
startBitrate?: number;
stereo?: boolean;
forcedVideoCodec?: string;
preferredVideoCodec?: string;
disableH264?: boolean;
disableVP8?: boolean;
disableVP9?: boolean;
enableOpusRed?: boolean;
enableDtmf?: boolean;
openBridgeChannel?: string | boolean;
}
export interface JitsiInitOptions {
disableAudioLevels?: boolean;
disableSimulcast?: boolean;
enableWindowOnErrorHandler?: boolean;
enableRemb?: boolean;
enableTcc?: boolean;
disableThirdPartyRequests?: boolean;
useStunTurn?: boolean;
iceServers?: RTCIceServer[];
}
export interface JitsiLocalTrackOptions {
devices?: string[];
cameraDeviceId?: string;
micDeviceId?: string;
facingMode?: 'user' | 'environment';
resolution?: number;
frameRate?: number;
}
const JitsiMeetJS: JitsiMeetJSLib;
export default JitsiMeetJS;
}

View File

@@ -0,0 +1,200 @@
/**
* Utilitário para gerenciar abertura de CallWindow em nova janela
*/
export interface CallWindowOptions {
width?: number;
height?: number;
left?: number;
top?: number;
features?: string;
}
export interface CallWindowData {
chamadaId: string;
conversaId: string;
tipo: 'audio' | 'video';
roomName: string;
ehAnfitriao: boolean;
}
const DEFAULT_OPTIONS: Required<CallWindowOptions> = {
width: 1280,
height: 720,
left: undefined,
top: undefined,
features: 'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes'
};
/**
* Calcular posição centralizada da janela
*/
function calcularPosicaoCentralizada(width: number, height: number): { left: number; top: number } {
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
return { left, top };
}
/**
* Abrir CallWindow em nova janela do navegador
*/
export function abrirCallWindowEmPopup(
data: CallWindowData,
options: CallWindowOptions = {}
): Window | null {
const opts = { ...DEFAULT_OPTIONS, ...options };
// Calcular posição se não fornecida
let left = opts.left;
let top = opts.top;
if (left === undefined || top === undefined) {
const posicao = calcularPosicaoCentralizada(opts.width, opts.height);
left = left ?? posicao.left;
top = top ?? posicao.top;
}
// Construir features da janela
const features = [
`width=${opts.width}`,
`height=${opts.height}`,
`left=${left}`,
`top=${top}`,
opts.features
].join(',');
// Criar URL com dados da chamada
const url = new URL('/call', window.location.origin);
url.searchParams.set('chamadaId', data.chamadaId);
url.searchParams.set('conversaId', data.conversaId);
url.searchParams.set('tipo', data.tipo);
url.searchParams.set('roomName', data.roomName);
url.searchParams.set('ehAnfitriao', String(data.ehAnfitriao));
// Abrir janela
const popup = window.open(url.toString(), `call-${data.chamadaId}`, features);
if (!popup) {
console.error('Falha ao abrir popup. Verifique se o bloqueador de popups está desabilitado.');
return null;
}
// Focar na nova janela
popup.focus();
// Configurar comunicação via postMessage
configurarComunicacaoPopup(popup, data);
return popup;
}
/**
* Configurar comunicação entre janelas usando postMessage
*/
function configurarComunicacaoPopup(popup: Window, data: CallWindowData): void {
// Listener para mensagens da janela popup
const messageHandler = (event: MessageEvent) => {
// Verificar origem
if (event.origin !== window.location.origin) {
return;
}
// Verificar se a mensagem é da janela popup
if (event.data?.source === 'call-window-popup') {
switch (event.data.type) {
case 'ready':
console.log('CallWindow popup está pronto');
break;
case 'closed':
console.log('CallWindow popup foi fechado');
window.removeEventListener('message', messageHandler);
break;
case 'error':
console.error('Erro na CallWindow popup:', event.data.error);
break;
}
}
};
window.addEventListener('message', messageHandler);
// Detectar quando a janela é fechada
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
window.removeEventListener('message', messageHandler);
console.log('CallWindow popup foi fechado pelo usuário');
}
}, 1000);
}
/**
* Verificar se popups estão habilitados
*/
export function verificarSuportePopup(): boolean {
if (typeof window === 'undefined') {
return false;
}
// Tentar abrir um popup de teste
const testPopup = window.open('about:blank', '_blank', 'width=1,height=1');
if (!testPopup) {
return false;
}
// Fechar popup de teste
testPopup.close();
return true;
}
/**
* Obter dados da chamada da URL (para uso na página de popup)
*/
export function obterDadosChamadaDaUrl(): CallWindowData | null {
if (typeof window === 'undefined') {
return null;
}
const params = new URLSearchParams(window.location.search);
const chamadaId = params.get('chamadaId');
const conversaId = params.get('conversaId');
const tipo = params.get('tipo');
const roomName = params.get('roomName');
const ehAnfitriao = params.get('ehAnfitriao');
if (!chamadaId || !conversaId || !tipo || !roomName) {
return null;
}
if (tipo !== 'audio' && tipo !== 'video') {
return null;
}
return {
chamadaId,
conversaId,
tipo,
roomName,
ehAnfitriao: ehAnfitriao === 'true'
};
}
/**
* Notificar janela pai sobre eventos
*/
export function notificarJanelaPai(type: string, data?: unknown): void {
if (typeof window === 'undefined' || !window.opener) {
return;
}
window.opener.postMessage(
{
source: 'call-window-popup',
type,
data
},
window.location.origin
);
}

View File

@@ -1,5 +1,21 @@
import { getLocalIP } from './browserInfo';
export interface DadosAcelerometro {
x: number;
y: number;
z: number;
movimentoDetectado: boolean;
magnitude: number;
variacao: number; // Variância entre leituras
timestamp: number;
}
export interface DadosGiroscopio {
alpha: number;
beta: number;
gamma: number;
}
export interface InformacoesDispositivo {
ipAddress?: string;
ipPublico?: string;
@@ -37,6 +53,10 @@ export interface InformacoesDispositivo {
isDesktop?: boolean;
connectionType?: string;
memoryInfo?: string;
acelerometro?: DadosAcelerometro;
giroscopio?: DadosGiroscopio;
sensorDisponivel?: boolean; // Indica se o sensor está disponível no dispositivo
permissaoNegada?: boolean; // Indica se a permissão foi negada pelo usuário
}
/**
@@ -637,6 +657,145 @@ async function obterIPPublico(): Promise<string | undefined> {
return undefined;
}
/**
* Solicita permissão para acesso aos sensores de movimento (iOS 13+)
*/
async function solicitarPermissaoSensor(): Promise<PermissionState> {
if (typeof DeviceMotionEvent === 'undefined' || typeof (DeviceMotionEvent as { requestPermission?: () => Promise<PermissionState> }).requestPermission !== 'function') {
// Permissão não necessária ou já concedida (navegadores modernos)
return 'granted';
}
try {
const requestPermission = (DeviceMotionEvent as { requestPermission: () => Promise<PermissionState> }).requestPermission;
const resultado = await requestPermission();
return resultado;
} catch (error) {
console.warn('Erro ao solicitar permissão de sensor:', error);
return 'denied';
}
}
/**
* Obtém dados de acelerômetro e giroscópio durante um período
* @param duracaoMs Duração da coleta em milissegundos (padrão: 5000ms = 5 segundos)
*/
async function obterDadosAcelerometro(duracaoMs: number = 5000): Promise<{
acelerometro?: DadosAcelerometro;
giroscopio?: DadosGiroscopio;
sensorDisponivel: boolean;
permissaoNegada: boolean;
}> {
// Verificar se DeviceMotionEvent está disponível
if (typeof DeviceMotionEvent === 'undefined' || typeof DeviceOrientationEvent === 'undefined') {
return {
sensorDisponivel: false,
permissaoNegada: false
};
}
// Solicitar permissão (especialmente necessário no iOS 13+)
const permissao = await solicitarPermissaoSensor();
if (permissao === 'denied') {
return {
sensorDisponivel: true,
permissaoNegada: true
};
}
return new Promise((resolve) => {
const leiturasAcelerometro: Array<{ x: number; y: number; z: number; timestamp: number }> = [];
const leiturasGiroscopio: Array<{ alpha: number; beta: number; gamma: number; timestamp: number }> = [];
const timeoutId = setTimeout(() => {
window.removeEventListener('devicemotion', handleDeviceMotion);
window.removeEventListener('deviceorientation', handleDeviceOrientation);
// Processar dados de acelerômetro
let acelerometro: DadosAcelerometro | undefined;
if (leiturasAcelerometro.length > 0) {
const ultimaLeitura = leiturasAcelerometro[leiturasAcelerometro.length - 1]!;
// Calcular magnitude média
const magnitudes = leiturasAcelerometro.map(l =>
Math.sqrt(l.x * l.x + l.y * l.y + l.z * l.z)
);
const magnitude = magnitudes.reduce((sum, m) => sum + m, 0) / magnitudes.length;
// Calcular variância para detectar movimento
const mediaX = leiturasAcelerometro.reduce((sum, l) => sum + l.x, 0) / leiturasAcelerometro.length;
const mediaY = leiturasAcelerometro.reduce((sum, l) => sum + l.y, 0) / leiturasAcelerometro.length;
const mediaZ = leiturasAcelerometro.reduce((sum, l) => sum + l.z, 0) / leiturasAcelerometro.length;
const variacoes = leiturasAcelerometro.map(l =>
Math.pow(l.x - mediaX, 2) + Math.pow(l.y - mediaY, 2) + Math.pow(l.z - mediaZ, 2)
);
const variacao = variacoes.reduce((sum, v) => sum + v, 0) / variacoes.length;
// Detectar movimento: se variância > 0.01, há movimento
const movimentoDetectado = variacao > 0.01;
acelerometro = {
x: ultimaLeitura.x,
y: ultimaLeitura.y,
z: ultimaLeitura.z,
movimentoDetectado,
magnitude,
variacao,
timestamp: ultimaLeitura.timestamp
};
}
// Processar dados de giroscópio
let giroscopio: DadosGiroscopio | undefined;
if (leiturasGiroscopio.length > 0) {
const ultimaLeitura = leiturasGiroscopio[leiturasGiroscopio.length - 1]!;
giroscopio = {
alpha: ultimaLeitura.alpha || 0,
beta: ultimaLeitura.beta || 0,
gamma: ultimaLeitura.gamma || 0
};
}
resolve({
acelerometro,
giroscopio,
sensorDisponivel: true,
permissaoNegada: false
});
}, duracaoMs);
function handleDeviceMotion(event: DeviceMotionEvent) {
if (event.accelerationIncludingGravity) {
const acc = event.accelerationIncludingGravity;
if (acc.x !== null && acc.y !== null && acc.z !== null) {
leiturasAcelerometro.push({
x: acc.x,
y: acc.y,
z: acc.z,
timestamp: Date.now()
});
}
}
}
function handleDeviceOrientation(event: DeviceOrientationEvent) {
if (event.alpha !== null && event.beta !== null && event.gamma !== null) {
leiturasGiroscopio.push({
alpha: event.alpha,
beta: event.beta,
gamma: event.gamma,
timestamp: Date.now()
});
}
}
window.addEventListener('devicemotion', handleDeviceMotion);
window.addEventListener('deviceorientation', handleDeviceOrientation);
});
}
/**
* Obtém todas as informações do dispositivo
*/
@@ -675,13 +834,14 @@ export async function obterInformacoesDispositivo(): Promise<InformacoesDisposit
informacoes.screenResolution = tela.screenResolution;
informacoes.coresTela = tela.coresTela;
// Informações de conexão e memória (assíncronas)
const [connectionType, memoryInfo, ipPublico, ipLocal, localizacao] = await Promise.all([
// Informações de conexão, memória e localização (assíncronas)
const [connectionType, memoryInfo, ipPublico, ipLocal, localizacao, dadosSensores] = await Promise.all([
obterInformacoesConexao(),
Promise.resolve(obterInformacoesMemoria()),
obterIPPublico(),
getLocalIP(),
obterLocalizacao(),
obterDadosAcelerometro(5000), // Coletar dados por 5 segundos
]);
informacoes.connectionType = connectionType;
@@ -703,6 +863,12 @@ export async function obterInformacoesDispositivo(): Promise<InformacoesDisposit
informacoes.estado = localizacao.estado;
informacoes.pais = localizacao.pais;
// Dados de sensores
informacoes.acelerometro = dadosSensores.acelerometro;
informacoes.giroscopio = dadosSensores.giroscopio;
informacoes.sensorDisponivel = dadosSensores.sensorDisponivel;
informacoes.permissaoNegada = dadosSensores.permissaoNegada;
// IP address (usar público se disponível, senão local)
informacoes.ipAddress = ipPublico || ipLocal;

View File

@@ -28,6 +28,8 @@
let carregando = $state(false);
let mostrarModalImpressao = $state(false);
let funcionarioParaImprimir = $state<Id<'funcionarios'> | ''>('');
let mostrarModalDetalhes = $state(false);
let registroDetalhesId = $state<Id<'registrosPonto'> | ''>('');
let chartCanvas: HTMLCanvasElement;
let chartInstance: Chart | null = null;
@@ -190,8 +192,12 @@
// Aguardar um pouco para garantir que o canvas está renderizado
const timeoutId = setTimeout(() => {
criarGrafico();
}, 100);
try {
criarGrafico();
} catch (error) {
console.error('Erro ao criar gráfico no effect:', error);
}
}, 200);
return () => {
clearTimeout(timeoutId);
@@ -201,11 +207,20 @@
// Também tentar criar quando o canvas for montado
onMount(() => {
if (chartCanvas && estatisticas && chartData) {
setTimeout(() => {
criarGrafico();
}, 200);
}
// Tentar criar o gráfico após um pequeno delay para garantir que tudo está renderizado
const timeoutId = setTimeout(() => {
if (chartCanvas && estatisticas && chartData && !chartInstance) {
try {
criarGrafico();
} catch (error) {
console.error('Erro ao criar gráfico no onMount:', error);
}
}
}, 500);
return () => {
clearTimeout(timeoutId);
};
});
onDestroy(() => {
@@ -683,6 +698,12 @@
minuto: number;
dentroDoPrazo: boolean;
dentroRaioPermitido: boolean | null | undefined;
acelerometroX?: number | undefined;
acelerometroY?: number | undefined;
acelerometroZ?: number | undefined;
movimentoDetectado?: boolean | undefined;
magnitudeMovimento?: number | undefined;
sensorDisponivel?: boolean | undefined;
}>
> = {};
@@ -698,6 +719,12 @@
minuto: r.minuto,
dentroDoPrazo: r.dentroDoPrazo,
dentroRaioPermitido: r.dentroRaioPermitido,
acelerometroX: r.acelerometroX,
acelerometroY: r.acelerometroY,
acelerometroZ: r.acelerometroZ,
movimentoDetectado: r.movimentoDetectado,
magnitudeMovimento: r.magnitudeMovimento,
sensorDisponivel: r.sensorDisponivel,
});
}
@@ -744,6 +771,21 @@
linha.push(reg.dentroDoPrazo ? 'Sim' : 'Não');
// Adicionar dados de acelerômetro (se disponível)
if (reg.acelerometroX !== undefined || reg.sensorDisponivel !== undefined) {
if (reg.sensorDisponivel === false && !reg.acelerometroX) {
linha.push('Sensor: Não disponível');
} else if (reg.acelerometroX !== undefined) {
const movimento = reg.movimentoDetectado ? 'Sim' : 'Não';
const magnitude = reg.magnitudeMovimento !== undefined ? reg.magnitudeMovimento.toFixed(2) : 'N/A';
linha.push(`Mov: ${movimento} | Mag: ${magnitude} m/s²`);
} else {
linha.push('Sensor: N/A');
}
} else {
linha.push('-');
}
tableData.push(linha);
}
}
@@ -754,6 +796,7 @@
}
headers.push('Localização');
headers.push('Dentro do Prazo');
headers.push('Acelerômetro');
// Salvar a posição Y antes da tabela
const yPosAntesTabela = yPosition;
@@ -985,6 +1028,20 @@
}
}
function abrirModalDetalhes(registroId: Id<'registrosPonto'>) {
if (!registroId) {
console.error('Erro: registroId inválido');
return;
}
registroDetalhesId = registroId;
mostrarModalDetalhes = true;
}
function fecharModalDetalhes() {
mostrarModalDetalhes = false;
registroDetalhesId = '';
}
async function imprimirDetalhesRegistro(registroId: Id<'registrosPonto'>) {
try {
// Buscar dados completos do registro
@@ -1909,12 +1966,7 @@
</div>
{:else}
<canvas bind:this={chartCanvas} class="w-full h-full"></canvas>
{#if !chartInstance && estatisticas && chartData}
<div class="absolute inset-0 flex items-center justify-center bg-base-200/30 rounded-xl">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{/if}
{/if}
{/if}
</div>
</div>
</div>
@@ -2234,8 +2286,8 @@
<td class="whitespace-nowrap">
<button
class="btn btn-sm btn-outline btn-primary gap-2 hover:btn-primary hover:shadow-md transition-all"
onclick={() => imprimirDetalhesRegistro(registro._id)}
title="Imprimir Detalhes"
onclick={() => abrirModalDetalhes(registro._id)}
title="Ver Detalhes"
>
<FileText class="h-4 w-4" />
Detalhes
@@ -2267,3 +2319,124 @@
/>
{/if}
<!-- Modal de Detalhes do Registro -->
{#if mostrarModalDetalhes && registroDetalhesId}
{@const registroDetalhesQuery = useQuery(api.pontos.obterRegistro, registroDetalhesId ? { registroId: registroDetalhesId } : 'skip')}
{@const registroDetalhes = registroDetalhesQuery?.data}
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && fecharModalDetalhes()}>
<div class="modal-box max-w-4xl max-h-[90vh] overflow-hidden flex flex-col" onclick={(e) => e.stopPropagation()}>
<div class="flex items-center justify-between mb-4 pb-4 border-b border-base-300 flex-shrink-0">
<h3 class="font-bold text-xl">Detalhes do Registro de Ponto</h3>
<button class="btn btn-sm btn-circle btn-ghost" onclick={fecharModalDetalhes}>✕</button>
</div>
<div class="flex-1 overflow-y-auto pr-2">
{#if registroDetalhesQuery === undefined || registroDetalhesQuery?.isLoading}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg text-primary"></span>
<span class="ml-4">Carregando detalhes...</span>
</div>
{:else if registroDetalhesQuery?.error}
<div class="alert alert-error">
<XCircle class="h-6 w-6" />
<div>
<h3 class="font-bold">Erro ao carregar detalhes</h3>
<div class="text-sm mt-1">{registroDetalhesQuery.error?.message || String(registroDetalhesQuery.error) || 'Erro desconhecido'}</div>
</div>
</div>
{:else if !registroDetalhes}
<div class="alert alert-warning">
<FileText class="h-6 w-6" />
<span>Registro não encontrado</span>
</div>
{:else}
<!-- Informações Básicas -->
<div class="card bg-base-200 mb-4">
<div class="card-body">
<h4 class="font-bold mb-2">Informações do Registro</h4>
{#if registroDetalhes.funcionario}
<p><strong>Funcionário:</strong> {registroDetalhes.funcionario.nome}</p>
{#if registroDetalhes.funcionario.matricula}
<p><strong>Matrícula:</strong> {registroDetalhes.funcionario.matricula}</p>
{/if}
{/if}
<p><strong>Data:</strong> {formatarDataDDMMAAAA(registroDetalhes.data)}</p>
<p><strong>Horário:</strong> {formatarHoraPonto(registroDetalhes.hora, registroDetalhes.minuto)}</p>
<p><strong>Status:</strong> <span class="badge {registroDetalhes.dentroDoPrazo ? 'badge-success' : 'badge-error'}">{registroDetalhes.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}</span></p>
</div>
</div>
<!-- Localização GPS -->
{#if registroDetalhes.latitude !== undefined && registroDetalhes.longitude !== undefined}
<div class="card bg-base-200 mb-4">
<div class="card-body">
<h4 class="font-bold mb-2">Localização GPS</h4>
<p><strong>Latitude:</strong> {registroDetalhes.latitude.toFixed(6)}</p>
<p><strong>Longitude:</strong> {registroDetalhes.longitude.toFixed(6)}</p>
{#if registroDetalhes.precisao !== undefined}
<p><strong>Precisão:</strong> {registroDetalhes.precisao.toFixed(2)}m</p>
{/if}
{#if registroDetalhes.endereco || registroDetalhes.cidade}
<p><strong>Endereço:</strong> {registroDetalhes.endereco || ''} {registroDetalhes.cidade ? `, ${registroDetalhes.cidade}` : ''} {registroDetalhes.estado ? ` - ${registroDetalhes.estado}` : ''}</p>
{/if}
{#if registroDetalhes.confiabilidadeGPS !== undefined}
<p><strong>Confiabilidade GPS:</strong> {(registroDetalhes.confiabilidadeGPS * 100).toFixed(1)}%</p>
{/if}
{#if registroDetalhes.scoreConfiancaBackend !== undefined}
<p><strong>Score de Confiança:</strong> {(registroDetalhes.scoreConfiancaBackend * 100).toFixed(1)}%</p>
{/if}
</div>
</div>
{/if}
<!-- Dados de Sensores (Acelerômetro) -->
{#if registroDetalhes.acelerometroX !== undefined || registroDetalhes.sensorDisponivel !== undefined}
<div class="card bg-base-200 mb-4">
<div class="card-body">
<h4 class="font-bold mb-2">Dados de Sensores</h4>
{#if registroDetalhes.sensorDisponivel === false && registroDetalhes.isDesktop !== true}
<p class="text-warning"><strong>Sensor:</strong> Não disponível neste dispositivo</p>
{:else if registroDetalhes.permissaoSensorNegada === true}
<p class="text-error"><strong>Sensor:</strong> Permissão negada</p>
{:else if registroDetalhes.acelerometroX !== undefined}
<p><strong>Sensor:</strong> Disponível</p>
<p><strong>Acelerômetro X:</strong> {registroDetalhes.acelerometroX.toFixed(3)} m/s²</p>
{#if registroDetalhes.acelerometroY !== undefined}
<p><strong>Acelerômetro Y:</strong> {registroDetalhes.acelerometroY.toFixed(3)} m/s²</p>
{/if}
{#if registroDetalhes.acelerometroZ !== undefined}
<p><strong>Acelerômetro Z:</strong> {registroDetalhes.acelerometroZ.toFixed(3)} m/s²</p>
{/if}
{#if registroDetalhes.magnitudeMovimento !== undefined}
<p><strong>Magnitude:</strong> {registroDetalhes.magnitudeMovimento.toFixed(3)} m/s²</p>
{/if}
{#if registroDetalhes.movimentoDetectado !== undefined}
<p><strong>Movimento Detectado:</strong> <span class="badge {registroDetalhes.movimentoDetectado ? 'badge-success' : 'badge-warning'}">{registroDetalhes.movimentoDetectado ? 'Sim' : 'Não'}</span></p>
{/if}
{#if registroDetalhes.variacaoAcelerometro !== undefined}
<p><strong>Variação:</strong> {registroDetalhes.variacaoAcelerometro.toFixed(6)}</p>
{/if}
{:else if registroDetalhes.isDesktop === true}
<p class="text-info"><strong>Sensor:</strong> Não disponível em desktop (normal)</p>
{/if}
</div>
</div>
{/if}
{/if}
</div>
<div class="flex justify-end gap-2 pt-4 mt-4 border-t border-base-300 flex-shrink-0">
{#if registroDetalhes}
<button class="btn btn-primary gap-2" onclick={() => imprimirDetalhesRegistro(registroDetalhesId)}>
<Printer class="h-4 w-4" />
Imprimir PDF
</button>
{/if}
<button class="btn btn-outline" onclick={fecharModalDetalhes}>Fechar</button>
</div>
</div>
<form method="dialog" class="modal-backdrop" onclick={fecharModalDetalhes}></form>
</dialog>
{/if}

View File

@@ -1,10 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { browser } from '$app/environment';
import type { ConstructorOfATypedSvelteComponent } from 'svelte/types/runtime/component';
import CybersecurityWizcard from '$lib/components/ti/CybersecurityWizcard.svelte';
// Usar tipo amplo para evitar conflitos de tipagem do import dinâmico no build
let Comp: ConstructorOfATypedSvelteComponent<typeof CybersecurityWizcard> | null = null;
</script>
<svelte:head>
@@ -27,11 +23,5 @@
<a href={resolve('/ti')} class="btn btn-outline btn-primary">Voltar para TI</a>
</header>
{#if browser && Comp}
<svelte:component this={Comp} />
{:else}
<div class="alert">
<span>Carregando módulo de cibersegurança…</span>
</div>
{/if}
<CybersecurityWizcard />
</section>

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import CallWindow from '$lib/components/call/CallWindow.svelte';
import { obterDadosChamadaDaUrl, notificarJanelaPai } from '$lib/utils/callWindowManager';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
let dadosChamada = $state<{
chamadaId: Id<'chamadas'>;
conversaId: Id<'conversas'>;
tipo: 'audio' | 'video';
roomName: string;
ehAnfitriao: boolean;
} | null>(null);
let erro = $state<string | null>(null);
onMount(() => {
if (!browser) return;
// Obter dados da URL
const dados = obterDadosChamadaDaUrl();
if (!dados) {
erro = 'Dados da chamada não encontrados na URL.';
return;
}
dadosChamada = {
chamadaId: dados.chamadaId as Id<'chamadas'>,
conversaId: dados.conversaId as Id<'conversas'>,
tipo: dados.tipo,
roomName: dados.roomName,
ehAnfitriao: dados.ehAnfitriao
};
// Notificar janela pai que está pronto
notificarJanelaPai('ready');
// Detectar fechamento da janela
window.addEventListener('beforeunload', () => {
notificarJanelaPai('closed');
});
});
function handleClose(): void {
notificarJanelaPai('closed');
window.close();
}
</script>
<div class="h-screen w-screen bg-base-100">
{#if erro}
<div class="flex h-full w-full items-center justify-center">
<div class="text-center">
<div class="alert alert-error max-w-md">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{erro}</span>
</div>
<button class="btn btn-primary mt-4" onclick={handleClose}>Fechar</button>
</div>
</div>
{:else if dadosChamada}
<CallWindow
chamadaId={dadosChamada.chamadaId}
conversaId={dadosChamada.conversaId}
tipo={dadosChamada.tipo}
roomName={dadosChamada.roomName}
ehAnfitriao={dadosChamada.ehAnfitriao}
onClose={handleClose}
/>
{:else}
<div class="flex h-full w-full items-center justify-center">
<span class="loading loading-spinner loading-lg"></span>
</div>
{/if}
</div>