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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user