- Added support for user-selected themes, allowing users to customize the appearance of the application. - Introduced a new `temaPreferido` field in the user schema to store the preferred theme. - Updated various components to apply the selected theme dynamically based on user preferences. - Enhanced the UI to include a theme selection interface, enabling users to preview and save their theme choices. - Implemented a polyfill for BlobBuilder to ensure compatibility across browsers, improving the functionality of the application.
1495 lines
47 KiB
Svelte
1495 lines
47 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { browser } from '$app/environment';
|
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
|
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';
|
|
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);
|
|
|
|
import CallControls from './CallControls.svelte';
|
|
import CallSettings from './CallSettings.svelte';
|
|
import HostControls from './HostControls.svelte';
|
|
import RecordingIndicator from './RecordingIndicator.svelte';
|
|
import ErrorModal from '../ErrorModal.svelte';
|
|
|
|
import {
|
|
callState,
|
|
toggleAudio,
|
|
toggleVideo,
|
|
iniciarGravacao as iniciarGravacaoStore,
|
|
pararGravacao as pararGravacaoStore,
|
|
atualizarDuracao,
|
|
atualizarStatusConexao,
|
|
atualizarParticipantes,
|
|
setAudioHabilitado,
|
|
setVideoHabilitado,
|
|
atualizarDispositivos,
|
|
setJitsiApi,
|
|
setStreamLocal,
|
|
finalizarChamada as finalizarChamadaStore,
|
|
inicializarChamada
|
|
} from '$lib/stores/callStore';
|
|
|
|
import { obterConfiguracaoJitsi, obterHostEPorta } from '$lib/utils/jitsi';
|
|
import { traduzirErro } from '$lib/utils/erroHelpers';
|
|
import { GravadorMedia, gerarNomeArquivo, salvarGravacao } from '$lib/utils/mediaRecorder';
|
|
import {
|
|
criarDragHandler,
|
|
criarResizeHandler,
|
|
salvarPosicaoJanela,
|
|
restaurarPosicaoJanela,
|
|
obterPosicaoInicial
|
|
} from '$lib/utils/floatingWindow';
|
|
|
|
import { get } from 'svelte/store';
|
|
|
|
interface Props {
|
|
chamadaId: Id<'chamadas'>;
|
|
conversaId: Id<'conversas'>;
|
|
tipo: 'audio' | 'video';
|
|
roomName: string;
|
|
ehAnfitriao: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
let {
|
|
chamadaId,
|
|
conversaId,
|
|
tipo,
|
|
roomName,
|
|
ehAnfitriao,
|
|
onClose
|
|
}: Props = $props();
|
|
|
|
const client = useConvexClient();
|
|
|
|
// Estados
|
|
let janelaElement: HTMLDivElement | null = $state(null);
|
|
let dragHandle: HTMLDivElement | null = $state(null);
|
|
let resizeHandles: HTMLDivElement[] = $state([]);
|
|
let videoContainer: HTMLDivElement | null = $state(null);
|
|
let localVideo: HTMLVideoElement | null = $state(null);
|
|
let showSettings = $state(false);
|
|
let duracaoTimer: ReturnType<typeof setInterval> | null = $state(null);
|
|
let gravador: GravadorMedia | null = $state(null);
|
|
|
|
let jitsiConnection: JitsiConnection | null = $state(null);
|
|
let jitsiConference: JitsiConference | null = $state(null);
|
|
let localTracks: JitsiTrack[] = $state([]);
|
|
|
|
// Estados de erro
|
|
let showErrorModal = $state(false);
|
|
let errorTitle = $state('Erro na Chamada');
|
|
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);
|
|
const meuPerfil = useQuery(api.auth.getCurrentUser, {});
|
|
const configJitsiBackend = useQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
|
|
|
// Estado derivado do store
|
|
const estadoChamada = $derived(get(callState));
|
|
|
|
// Configuração Jitsi (busca do backend primeiro, depois fallback para env vars)
|
|
const configJitsi = $derived.by(() => obterConfiguracaoJitsi(configJitsiBackend?.data || null));
|
|
|
|
// 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;
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Garantir que BlobBuilder está disponível antes de importar lib-jitsi-meet
|
|
function garantirBlobBuilderPolyfill(): void {
|
|
if (!browser) return;
|
|
|
|
const windowWithBlobBuilder = window as WindowWithBlobBuilder;
|
|
|
|
// Verificar se já existe
|
|
if (
|
|
typeof windowWithBlobBuilder.BlobBuilder !== 'undefined' ||
|
|
typeof windowWithBlobBuilder.webkitBlobBuilder !== 'undefined' ||
|
|
typeof windowWithBlobBuilder.MozBlobBuilder !== 'undefined'
|
|
) {
|
|
return; // Já está disponível
|
|
}
|
|
|
|
// Criar polyfill inline se não estiver disponível
|
|
console.log('🔧 Criando polyfill BlobBuilder inline...');
|
|
|
|
function BlobBuilderPolyfill() {
|
|
if (!(this instanceof BlobBuilderPolyfill)) {
|
|
return new BlobBuilderPolyfill();
|
|
}
|
|
this.parts = [];
|
|
}
|
|
|
|
BlobBuilderPolyfill.prototype.append = function(data: Blob | string) {
|
|
if (data instanceof Blob) {
|
|
this.parts.push(data);
|
|
} else if (typeof data === 'string') {
|
|
this.parts.push(data);
|
|
} else {
|
|
this.parts.push(new Blob([data]));
|
|
}
|
|
};
|
|
|
|
BlobBuilderPolyfill.prototype.getBlob = function(contentType?: string) {
|
|
return new Blob(this.parts, contentType ? { type: contentType } : undefined);
|
|
};
|
|
|
|
// Aplicar em todos os locais possíveis
|
|
(window as unknown as Record<string, unknown>).BlobBuilder = BlobBuilderPolyfill;
|
|
(window as unknown as Record<string, unknown>).WebKitBlobBuilder = BlobBuilderPolyfill;
|
|
(window as unknown as Record<string, unknown>).MozBlobBuilder = BlobBuilderPolyfill;
|
|
(window as unknown as Record<string, unknown>).MSBlobBuilder = BlobBuilderPolyfill;
|
|
|
|
if (typeof globalThis !== 'undefined') {
|
|
(globalThis as unknown as Record<string, unknown>).BlobBuilder = BlobBuilderPolyfill;
|
|
(globalThis as unknown as Record<string, unknown>).WebKitBlobBuilder = BlobBuilderPolyfill;
|
|
(globalThis as unknown as Record<string, unknown>).MozBlobBuilder = BlobBuilderPolyfill;
|
|
}
|
|
|
|
console.log('✅ Polyfill BlobBuilder aplicado inline');
|
|
}
|
|
|
|
// Carregar Jitsi dinamicamente
|
|
async function carregarJitsi(): Promise<void> {
|
|
if (!browser || JitsiMeetJS) return;
|
|
|
|
try {
|
|
console.log('🔄 Tentando carregar lib-jitsi-meet...');
|
|
|
|
// Garantir que BlobBuilder está disponível ANTES de importar
|
|
garantirBlobBuilderPolyfill();
|
|
|
|
// Tentar carregar o módulo lib-jitsi-meet dinamicamente
|
|
// Usar import dinâmico para evitar problemas de SSR e permitir carregamento apenas no browser
|
|
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,
|
|
hasJitsiMeetJS: !!module.JitsiMeetJS,
|
|
keys: Object.keys(module)
|
|
});
|
|
|
|
// Tentar múltiplas formas de acessar o JitsiMeetJS
|
|
// A biblioteca pode exportar de diferentes formas dependendo da versão
|
|
let jitsiModule: unknown = null;
|
|
|
|
// Tentativa 1: export default
|
|
if (module.default) {
|
|
if (typeof module.default === 'object' && 'init' in module.default) {
|
|
jitsiModule = module.default;
|
|
console.log('✅ Encontrado em module.default');
|
|
}
|
|
}
|
|
|
|
// Tentativa 2: export nomeado JitsiMeetJS
|
|
if (!jitsiModule && module.JitsiMeetJS) {
|
|
jitsiModule = module.JitsiMeetJS;
|
|
console.log('✅ Encontrado em module.JitsiMeetJS');
|
|
}
|
|
|
|
// Tentativa 3: o próprio módulo pode ser o JitsiMeetJS
|
|
if (!jitsiModule && typeof module === 'object' && 'init' in module) {
|
|
jitsiModule = module;
|
|
console.log('✅ Encontrado no próprio módulo');
|
|
}
|
|
|
|
if (!jitsiModule) {
|
|
throw new Error(
|
|
'Não foi possível encontrar JitsiMeetJS no módulo. ' +
|
|
'Verifique se lib-jitsi-meet está instalado corretamente.'
|
|
);
|
|
}
|
|
|
|
JitsiMeetJS = jitsiModule as unknown as JitsiMeetJSLib;
|
|
|
|
// Verificar se JitsiMeetJS foi inicializado corretamente
|
|
if (!JitsiMeetJS || !JitsiMeetJS.init || typeof JitsiMeetJS.init !== 'function') {
|
|
throw new Error('JitsiMeetJS não possui método init válido');
|
|
}
|
|
|
|
// Verificar se JitsiConnection existe
|
|
if (!JitsiMeetJS.JitsiConnection) {
|
|
throw new Error('JitsiConnection não está disponível no módulo carregado');
|
|
}
|
|
|
|
console.log('🔧 Inicializando Jitsi Meet JS...');
|
|
|
|
// Inicializar Jitsi
|
|
JitsiMeetJS.init({
|
|
disableAudioLevels: false,
|
|
disableSimulcast: false,
|
|
enableWindowOnErrorHandler: true,
|
|
enableRemb: true,
|
|
enableTcc: true,
|
|
disableThirdPartyRequests: false
|
|
});
|
|
|
|
// Configurar nível de log
|
|
if (JitsiMeetJS.setLogLevel && typeof JitsiMeetJS.setLogLevel === 'function') {
|
|
if (JitsiMeetJS.constants && JitsiMeetJS.constants.logLevels) {
|
|
JitsiMeetJS.setLogLevel(JitsiMeetJS.constants.logLevels.INFO);
|
|
}
|
|
}
|
|
|
|
console.log('✅ Jitsi Meet JS carregado e inicializado com sucesso');
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
console.error('❌ Erro ao carregar lib-jitsi-meet:', error);
|
|
console.error('Detalhes do erro:', {
|
|
message: errorMessage,
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
error
|
|
});
|
|
|
|
// Verificar se é um erro de módulo não encontrado
|
|
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.'
|
|
);
|
|
} else {
|
|
handleError(
|
|
'Erro ao carregar biblioteca de vídeo',
|
|
`Não foi possível carregar a biblioteca necessária para chamadas de vídeo. Erro: ${errorMessage}. Por favor, recarregue a página e tente novamente.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inicializar store
|
|
function inicializarStore(): void {
|
|
if (chamada && meuPerfil?.data) {
|
|
inicializarChamada(
|
|
chamadaId,
|
|
conversaId,
|
|
tipo,
|
|
roomName,
|
|
ehAnfitriao,
|
|
chamada.participantes.map(pId => ({
|
|
usuarioId: pId,
|
|
nome: 'Participante', // Será atualizado depois
|
|
audioHabilitado: true,
|
|
videoHabilitado: tipo === 'video'
|
|
}))
|
|
);
|
|
}
|
|
}
|
|
|
|
// Inicializar Jitsi
|
|
async function inicializarJitsi(): Promise<void> {
|
|
if (!browser || !JitsiMeetJS) {
|
|
await carregarJitsi();
|
|
}
|
|
|
|
if (!JitsiMeetJS) {
|
|
console.error('JitsiMeetJS não está disponível');
|
|
handleError(
|
|
'Biblioteca de vídeo não disponível',
|
|
'A biblioteca Jitsi não pôde ser carregada. Por favor, recarregue a página e tente novamente.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const config = configJitsi();
|
|
const { host, porta } = obterHostEPorta(config.domain);
|
|
const protocol = config.useHttps ? 'https' : 'http';
|
|
|
|
// 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,
|
|
muc: mucDomain,
|
|
focus: `focus.${host}`
|
|
},
|
|
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 (conforme documentação oficial):', {
|
|
host,
|
|
porta,
|
|
protocol,
|
|
baseUrl,
|
|
serviceUrl: options.serviceUrl,
|
|
muc: options.hosts?.muc,
|
|
focus: options.hosts?.focus
|
|
});
|
|
|
|
const connection = new JitsiMeetJS.JitsiConnection(null, null, options);
|
|
jitsiConnection = connection;
|
|
setJitsiApi(connection);
|
|
|
|
// Eventos de conexão
|
|
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 com opções recomendadas pela documentação oficial
|
|
const estadoAtual = get(callState);
|
|
const conferenceOptions: Record<string, unknown> = {
|
|
startAudioMuted: !estadoAtual.audioHabilitado,
|
|
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);
|
|
jitsiConference = conference;
|
|
setJitsiApi(conference);
|
|
|
|
// Eventos da conferência
|
|
configurarEventosConferencia(conference);
|
|
|
|
// Entrar na conferência
|
|
conference.join();
|
|
});
|
|
|
|
connection.addEventListener(JitsiMeetJS.constants.events.connection.CONNECTION_FAILED, (error: unknown) => {
|
|
console.error('❌ Falha na conexão:', error);
|
|
atualizarStatusConexao(false);
|
|
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
|
|
// 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
|
|
console.log('🔄 Tentando conectar ao servidor Jitsi...');
|
|
connection.connect();
|
|
} catch (error) {
|
|
console.error('Erro ao inicializar Jitsi:', error);
|
|
handleError(
|
|
'Erro ao inicializar chamada',
|
|
'Não foi possível inicializar a chamada de vídeo. Verifique suas permissões de microfone e câmera.',
|
|
error instanceof Error ? error.message : String(error)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Configurar eventos da conferência
|
|
function configurarEventosConferencia(
|
|
conference: JitsiConference
|
|
): void {
|
|
if (!JitsiMeetJS) return;
|
|
|
|
// Participante entrou
|
|
conference.on(JitsiMeetJS.constants.events.conference.USER_JOINED, (id: unknown, user: unknown) => {
|
|
console.log('👤 Participante entrou:', id, user);
|
|
// Atualizar lista de participantes
|
|
atualizarListaParticipantes().then(() => {
|
|
// Atualizar nomes nos overlays
|
|
if (typeof id === 'string') {
|
|
atualizarNomeParticipante(id);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Participante saiu
|
|
conference.on(JitsiMeetJS.constants.events.conference.USER_LEFT, (id: unknown) => {
|
|
console.log('👋 Participante saiu:', id);
|
|
atualizarListaParticipantes();
|
|
});
|
|
|
|
// Á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);
|
|
const participantId = jitsiTrack.getParticipantId();
|
|
|
|
// Atualizar estado do participante
|
|
atualizarListaParticipantes().then(() => {
|
|
// Atualizar indicadores visuais
|
|
atualizarIndicadoresParticipante(participantId);
|
|
});
|
|
});
|
|
|
|
// Novo track remoto
|
|
conference.on(
|
|
JitsiMeetJS.constants.events.conference.TRACK_ADDED,
|
|
(track: unknown) => {
|
|
const jitsiTrack = track as JitsiTrack;
|
|
console.log('📹 Track adicionado:', jitsiTrack);
|
|
adicionarTrackRemoto(jitsiTrack);
|
|
}
|
|
);
|
|
|
|
// Track removido
|
|
conference.on(
|
|
JitsiMeetJS.constants.events.conference.TRACK_REMOVED,
|
|
(track: unknown) => {
|
|
const jitsiTrack = track as JitsiTrack;
|
|
console.log('📹 Track removido:', jitsiTrack);
|
|
removerTrackRemoto(jitsiTrack);
|
|
}
|
|
);
|
|
|
|
// 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);
|
|
|
|
// Construir constraints para áudio e vídeo
|
|
const constraints: {
|
|
audio?: boolean | Record<string, unknown>;
|
|
video?: boolean | Record<string, unknown>;
|
|
} = {
|
|
audio: estadoAtual.audioHabilitado ? {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true
|
|
} : false,
|
|
video: estadoAtual.videoHabilitado ? {
|
|
facingMode: 'user',
|
|
width: { ideal: 1280 },
|
|
height: { ideal: 720 }
|
|
} : false
|
|
};
|
|
|
|
console.log('📹 Criando tracks locais com constraints:', constraints);
|
|
|
|
// Criar tracks locais
|
|
const tracks = await JitsiMeetJS.createLocalTracks(constraints, {
|
|
devices: [],
|
|
cameraDeviceId: estadoChamada.dispositivos.cameraId || undefined,
|
|
micDeviceId: estadoChamada.dispositivos.microphoneId || undefined
|
|
});
|
|
|
|
console.log('✅ Tracks locais criados:', tracks.length);
|
|
|
|
// Armazenar tracks
|
|
localTracks = tracks;
|
|
|
|
// Adicionar cada track à conferência
|
|
for (const track of tracks) {
|
|
try {
|
|
await conference.addTrack(track);
|
|
console.log(`✅ Track ${track.getType()} adicionado à conferência`);
|
|
|
|
// Se for vídeo, anexar ao elemento local
|
|
if (track.getType() === 'video' && localVideo) {
|
|
track.attach(localVideo);
|
|
console.log('✅ Vídeo local anexado ao elemento');
|
|
}
|
|
} catch (trackError) {
|
|
console.error(`Erro ao adicionar track ${track.getType()}:`, trackError);
|
|
}
|
|
}
|
|
|
|
// Criar MediaStream para gravação (se necessário)
|
|
const stream = new MediaStream(tracks.map(t => t.track));
|
|
setStreamLocal(stream);
|
|
|
|
// Definir nome do display
|
|
if (meuPerfil?.data?.nome) {
|
|
conference.setDisplayName(meuPerfil.data.nome);
|
|
}
|
|
|
|
// Atualizar lista de participantes
|
|
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(
|
|
'Erro ao acessar mídia',
|
|
'Não foi possível acessar seu microfone ou câmera. Verifique as permissões do navegador e tente novamente.',
|
|
error instanceof Error ? error.message : String(error)
|
|
);
|
|
}
|
|
});
|
|
|
|
// Conferência finalizada
|
|
conference.on(JitsiMeetJS.constants.events.conference.CONFERENCE_LEFT, () => {
|
|
console.log('🚪 Conferência finalizada');
|
|
|
|
// Limpar tracks locais
|
|
localTracks.forEach(track => {
|
|
track.dispose().catch(err => console.error('Erro ao liberar track:', err));
|
|
});
|
|
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;
|
|
|
|
const participantId = track.getParticipantId();
|
|
const trackType = track.getType();
|
|
|
|
// 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;
|
|
audioElement.playsInline = true;
|
|
audioElement.style.display = 'none';
|
|
|
|
track.attach(audioElement);
|
|
videoContainer.appendChild(audioElement);
|
|
remoteAudioElements.set(participantId, audioElement);
|
|
return;
|
|
}
|
|
|
|
// 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';
|
|
|
|
// 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(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';
|
|
}
|
|
}
|
|
|
|
// Remover track remoto do container
|
|
function removerTrackRemoto(track: JitsiTrack): void {
|
|
if (!videoContainer) return;
|
|
|
|
const participantId = track.getParticipantId();
|
|
const trackType = track.getType();
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Atualizar lista de participantes
|
|
async function atualizarListaParticipantes(): Promise<void> {
|
|
if (!jitsiConference) return;
|
|
|
|
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((participant) => {
|
|
return {
|
|
usuarioId: participant.getId() as Id<'usuarios'>,
|
|
nome: participant.getDisplayName() || 'Participante',
|
|
audioHabilitado: !participant.isAudioMuted(),
|
|
videoHabilitado: !participant.isVideoMuted(),
|
|
participantId: participant.getId()
|
|
};
|
|
});
|
|
|
|
atualizarParticipantes(participantesAtualizados);
|
|
}
|
|
|
|
// Controles
|
|
async function handleToggleAudio(): Promise<void> {
|
|
if (!jitsiConference) return;
|
|
|
|
const estadoAtual = get(callState);
|
|
const novoEstadoAudio = !estadoAtual.audioHabilitado;
|
|
|
|
// Atualizar estado no store
|
|
setAudioHabilitado(novoEstadoAudio);
|
|
|
|
// Atualizar track local
|
|
const audioTrack = localTracks.find(t => t.getType() === 'audio');
|
|
if (audioTrack) {
|
|
if (novoEstadoAudio) {
|
|
await audioTrack.unmute();
|
|
} else {
|
|
await audioTrack.mute();
|
|
}
|
|
} else {
|
|
// Se não há track, tentar criar um novo
|
|
if (novoEstadoAudio) {
|
|
try {
|
|
const tracks = await JitsiMeetJS!.createLocalTracks({ audio: true });
|
|
if (tracks.length > 0) {
|
|
const newTrack = tracks[0];
|
|
await jitsiConference!.addTrack(newTrack);
|
|
localTracks = [...localTracks, newTrack];
|
|
}
|
|
} catch (error) {
|
|
console.error('Erro ao criar track de áudio:', error);
|
|
handleError('Erro ao habilitar áudio', 'Não foi possível acessar o microfone.');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Notificar backend se for anfitrião
|
|
if (ehAnfitriao && meuPerfil?.data) {
|
|
await client.mutation(api.chamadas.toggleAudioVideoParticipante, {
|
|
chamadaId,
|
|
participanteId: meuPerfil.data._id,
|
|
tipo: 'audio',
|
|
habilitado: novoEstadoAudio
|
|
}).catch(err => console.error('Erro ao atualizar backend:', err));
|
|
}
|
|
}
|
|
|
|
async function handleToggleVideo(): Promise<void> {
|
|
if (!jitsiConference) return;
|
|
|
|
const estadoAtual = get(callState);
|
|
const novoEstadoVideo = !estadoAtual.videoHabilitado;
|
|
|
|
// Atualizar estado no store
|
|
setVideoHabilitado(novoEstadoVideo);
|
|
|
|
// Atualizar track local
|
|
const videoTrack = localTracks.find(t => t.getType() === 'video');
|
|
if (videoTrack) {
|
|
if (novoEstadoVideo) {
|
|
await videoTrack.unmute();
|
|
if (localVideo) {
|
|
videoTrack.attach(localVideo);
|
|
}
|
|
} else {
|
|
await videoTrack.mute();
|
|
}
|
|
} else {
|
|
// Se não há track, tentar criar um novo
|
|
if (novoEstadoVideo) {
|
|
try {
|
|
const tracks = await JitsiMeetJS!.createLocalTracks({
|
|
video: { facingMode: 'user' }
|
|
});
|
|
if (tracks.length > 0) {
|
|
const newTrack = tracks[0];
|
|
await jitsiConference!.addTrack(newTrack);
|
|
localTracks = [...localTracks, newTrack];
|
|
if (localVideo) {
|
|
newTrack.attach(localVideo);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Erro ao criar track de vídeo:', error);
|
|
handleError('Erro ao habilitar vídeo', 'Não foi possível acessar a câmera.');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Notificar backend se for anfitrião
|
|
if (ehAnfitriao && meuPerfil?.data) {
|
|
await client.mutation(api.chamadas.toggleAudioVideoParticipante, {
|
|
chamadaId,
|
|
participanteId: meuPerfil.data._id,
|
|
tipo: 'video',
|
|
habilitado: novoEstadoVideo
|
|
}).catch(err => console.error('Erro ao atualizar backend:', err));
|
|
}
|
|
}
|
|
|
|
async function handleIniciarGravacao(): Promise<void> {
|
|
if (!jitsiConference || gravador) return;
|
|
|
|
try {
|
|
// Usar tracks locais armazenados
|
|
if (localTracks.length === 0) {
|
|
handleError('Nenhum stream disponível', 'Não há áudio ou vídeo para gravar.');
|
|
return;
|
|
}
|
|
|
|
// Criar MediaStream com todos os tracks
|
|
const stream = new MediaStream();
|
|
localTracks.forEach((track) => {
|
|
stream.addTrack(track.track);
|
|
});
|
|
|
|
// Criar gravador
|
|
gravador = new GravadorMedia(stream, tipo);
|
|
const iniciou = gravador.iniciar();
|
|
|
|
if (iniciou) {
|
|
iniciarGravacaoStore();
|
|
// Notificar backend
|
|
await client.mutation(api.chamadas.iniciarGravacao, { chamadaId }).catch(err => {
|
|
console.error('Erro ao notificar backend sobre gravação:', err);
|
|
});
|
|
} else {
|
|
handleError('Erro ao iniciar gravação', 'Não foi possível iniciar a gravação.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Erro ao iniciar gravação:', error);
|
|
handleError(
|
|
'Erro ao iniciar gravação',
|
|
'Não foi possível iniciar a gravação da chamada.',
|
|
error instanceof Error ? error.message : String(error)
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handlePararGravacao(): Promise<void> {
|
|
if (!gravador) return;
|
|
|
|
try {
|
|
const blob = await gravador.parar();
|
|
const nomeArquivo = gerarNomeArquivo(tipo, roomName);
|
|
salvarGravacao(blob, nomeArquivo);
|
|
|
|
pararGravacaoStore();
|
|
gravador.liberar();
|
|
gravador = null;
|
|
|
|
// Notificar backend
|
|
await client.mutation(api.chamadas.finalizarGravacao, { chamadaId }).catch(err => {
|
|
console.error('Erro ao notificar backend sobre finalização de gravação:', err);
|
|
});
|
|
} catch (error) {
|
|
console.error('Erro ao parar gravação:', error);
|
|
handleError(
|
|
'Erro ao parar gravação',
|
|
'Não foi possível finalizar a gravação.',
|
|
error instanceof Error ? error.message : String(error)
|
|
);
|
|
}
|
|
}
|
|
|
|
function handleAbrirConfiguracoes(): void {
|
|
showSettings = true;
|
|
}
|
|
|
|
function handleAplicarConfiguracoes(dispositivos: {
|
|
microphoneId: string | null;
|
|
cameraId: string | null;
|
|
speakerId: string | null;
|
|
}): void {
|
|
atualizarDispositivos(dispositivos);
|
|
// Aplicar novos dispositivos na conferência
|
|
if (jitsiConference) {
|
|
// Isso requer reconfigurar os tracks
|
|
// Por enquanto, apenas salvar as preferências
|
|
}
|
|
}
|
|
|
|
async function handleEncerrar(): Promise<void> {
|
|
if (confirm('Tem certeza que deseja encerrar a chamada?')) {
|
|
await finalizar();
|
|
}
|
|
}
|
|
|
|
async function finalizar(): Promise<void> {
|
|
// Parar gravação se estiver gravando
|
|
if (gravador) {
|
|
await handlePararGravacao();
|
|
}
|
|
|
|
// Parar timer
|
|
if (duracaoTimer) {
|
|
clearInterval(duracaoTimer);
|
|
duracaoTimer = null;
|
|
}
|
|
|
|
// Limpar tracks locais
|
|
for (const track of localTracks) {
|
|
try {
|
|
await track.dispose();
|
|
} catch (err) {
|
|
console.error('Erro ao liberar track:', err);
|
|
}
|
|
}
|
|
localTracks = [];
|
|
|
|
// Desconectar Jitsi
|
|
if (jitsiConference) {
|
|
try {
|
|
jitsiConference.leave();
|
|
} catch (err) {
|
|
console.error('Erro ao sair da conferência:', err);
|
|
}
|
|
jitsiConference = null;
|
|
}
|
|
|
|
if (jitsiConnection) {
|
|
try {
|
|
jitsiConnection.disconnect();
|
|
} catch (err) {
|
|
console.error('Erro ao desconectar:', err);
|
|
}
|
|
jitsiConnection = null;
|
|
}
|
|
|
|
// Limpar streams
|
|
setStreamLocal(null);
|
|
|
|
// Finalizar no backend
|
|
await client.mutation(api.chamadas.finalizarChamada, { chamadaId }).catch(err => {
|
|
console.error('Erro ao finalizar chamada no backend:', err);
|
|
});
|
|
|
|
// Limpar store
|
|
finalizarChamadaStore();
|
|
|
|
// Fechar janela
|
|
onClose();
|
|
}
|
|
|
|
// Timer de duração
|
|
function iniciarTimer(): void {
|
|
if (duracaoTimer) return;
|
|
|
|
duracaoTimer = setInterval(() => {
|
|
const estado = get(callState);
|
|
if (estado.chamadaId) {
|
|
const novaDuracao = estado.duracaoSegundos + 1;
|
|
atualizarDuracao(novaDuracao);
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
// Configurar janela flutuante
|
|
function configurarJanelaFlutuante(): void {
|
|
if (!janelaElement || !dragHandle) return;
|
|
|
|
// Restaurar posição ou usar inicial
|
|
const posicaoSalva = restaurarPosicaoJanela(chamadaId);
|
|
const posicaoInicial = posicaoSalva || obterPosicaoInicial(800, 600);
|
|
|
|
if (janelaElement) {
|
|
janelaElement.style.position = 'fixed';
|
|
janelaElement.style.left = `${posicaoInicial.x}px`;
|
|
janelaElement.style.top = `${posicaoInicial.y}px`;
|
|
janelaElement.style.width = `${posicaoInicial.width}px`;
|
|
janelaElement.style.height = `${posicaoInicial.height}px`;
|
|
janelaElement.style.zIndex = '1000';
|
|
}
|
|
|
|
// Criar handlers
|
|
if (dragHandle) {
|
|
criarDragHandler(janelaElement, dragHandle, (x, y) => {
|
|
if (janelaElement) {
|
|
salvarPosicaoJanela(chamadaId, {
|
|
x,
|
|
y,
|
|
width: janelaElement.offsetWidth,
|
|
height: janelaElement.offsetHeight
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Handles de resize
|
|
const handles: HTMLDivElement[] = [];
|
|
for (let i = 0; i < 8; i++) {
|
|
const handle = document.createElement('div');
|
|
handle.className = `absolute resize-handle resize-${['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'][i]}`;
|
|
handles.push(handle);
|
|
}
|
|
|
|
if (janelaElement) {
|
|
criarResizeHandler(
|
|
janelaElement,
|
|
handles,
|
|
{ minWidth: 400, minHeight: 300 },
|
|
(width, height) => {
|
|
const rect = janelaElement!.getBoundingClientRect();
|
|
salvarPosicaoJanela(chamadaId, {
|
|
x: rect.left,
|
|
y: rect.top,
|
|
width,
|
|
height
|
|
});
|
|
}
|
|
);
|
|
resizeHandles = handles;
|
|
}
|
|
}
|
|
|
|
onMount(async () => {
|
|
if (!browser) return;
|
|
|
|
// Garantir que BlobBuilder está disponível antes de qualquer coisa
|
|
garantirBlobBuilderPolyfill();
|
|
|
|
// Inicializar store primeiro
|
|
inicializarStore();
|
|
|
|
// Carregar Jitsi
|
|
await carregarJitsi();
|
|
|
|
// Configurar janela flutuante
|
|
configurarJanelaFlutuante();
|
|
|
|
// Inicializar Jitsi
|
|
await inicializarJitsi();
|
|
|
|
// Iniciar timer
|
|
iniciarTimer();
|
|
|
|
return () => {
|
|
// Cleanup
|
|
finalizar();
|
|
};
|
|
});
|
|
|
|
onDestroy(() => {
|
|
finalizar();
|
|
});
|
|
</script>
|
|
|
|
<div
|
|
bind:this={janelaElement}
|
|
class="bg-base-100 pointer-events-auto flex flex-col rounded-lg shadow-2xl"
|
|
role="dialog"
|
|
aria-labelledby="call-window-title"
|
|
aria-modal="true"
|
|
>
|
|
<!-- Header com drag handle -->
|
|
<div
|
|
bind:this={dragHandle}
|
|
class="bg-base-200 flex cursor-move items-center justify-between rounded-t-lg px-4 py-2"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<GripVertical class="text-base-content/50 h-4 w-4" />
|
|
<h3 id="call-window-title" class="text-base-content text-sm font-semibold">
|
|
Chamada {tipo === 'audio' ? 'de Áudio' : 'de Vídeo'}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm btn-circle btn-ghost"
|
|
onclick={finalizar}
|
|
aria-label="Fechar"
|
|
>
|
|
<X class="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Indicador de gravação -->
|
|
{#if estadoChamada.gravando}
|
|
<RecordingIndicator
|
|
gravando={estadoChamada.gravando}
|
|
iniciadoPor={ehAnfitriao ? 'Você' : undefined}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Container de vídeo -->
|
|
{#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 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>
|
|
<p class="mt-2 text-sm text-base-content/70">
|
|
Aguarde enquanto estabelecemos a conexão
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<!-- Vídeo Local -->
|
|
{#if tipo === 'video' && estadoChamada.videoHabilitado && localVideo}
|
|
<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"
|
|
></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>
|
|
{/if}
|
|
|
|
<!-- Controles do anfitrião -->
|
|
{#if ehAnfitriao && estadoChamada.participantes.length > 0}
|
|
<HostControls
|
|
participantes={estadoChamada.participantes}
|
|
onToggleParticipanteAudio={(usuarioId) => {
|
|
// Implementar toggle de áudio do participante
|
|
console.log('Toggle audio participante:', usuarioId);
|
|
}}
|
|
onToggleParticipanteVideo={(usuarioId) => {
|
|
// Implementar toggle de vídeo do participante
|
|
console.log('Toggle video participante:', usuarioId);
|
|
}}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Controles -->
|
|
<CallControls
|
|
audioHabilitado={estadoChamada.audioHabilitado}
|
|
videoHabilitado={estadoChamada.videoHabilitado}
|
|
gravando={estadoChamada.gravando}
|
|
ehAnfitriao={ehAnfitriao}
|
|
duracaoSegundos={estadoChamada.duracaoSegundos}
|
|
onToggleAudio={handleToggleAudio}
|
|
onToggleVideo={handleToggleVideo}
|
|
onIniciarGravacao={handleIniciarGravacao}
|
|
onPararGravacao={handlePararGravacao}
|
|
onAbrirConfiguracoes={handleAbrirConfiguracoes}
|
|
onEncerrar={handleEncerrar}
|
|
/>
|
|
|
|
<!-- Modal de configurações -->
|
|
{#if showSettings}
|
|
<CallSettings
|
|
open={showSettings}
|
|
dispositivoAtual={estadoChamada.dispositivos}
|
|
onClose={() => (showSettings = false)}
|
|
onAplicar={handleAplicarConfiguracoes}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Modal de Erro -->
|
|
<ErrorModal
|
|
open={showErrorModal}
|
|
title={errorTitle}
|
|
message={errorMessage}
|
|
details={errorDetails}
|
|
onClose={() => {
|
|
showErrorModal = false;
|
|
errorMessage = '';
|
|
errorDetails = undefined;
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<style>
|
|
.resize-handle {
|
|
background: transparent;
|
|
border: 2px solid transparent;
|
|
}
|
|
|
|
.resize-n,
|
|
.resize-s {
|
|
height: 4px;
|
|
width: 100%;
|
|
cursor: ns-resize;
|
|
}
|
|
|
|
.resize-e,
|
|
.resize-w {
|
|
height: 100%;
|
|
width: 4px;
|
|
cursor: ew-resize;
|
|
}
|
|
|
|
.resize-nw,
|
|
.resize-se {
|
|
height: 8px;
|
|
width: 8px;
|
|
cursor: nwse-resize;
|
|
}
|
|
|
|
.resize-ne,
|
|
.resize-sw {
|
|
height: 8px;
|
|
width: 8px;
|
|
cursor: nesw-resize;
|
|
}
|
|
|
|
.resize-n {
|
|
top: 0;
|
|
left: 0;
|
|
}
|
|
|
|
.resize-ne {
|
|
top: 0;
|
|
right: 0;
|
|
}
|
|
|
|
.resize-e {
|
|
top: 0;
|
|
right: 0;
|
|
}
|
|
|
|
.resize-se {
|
|
bottom: 0;
|
|
right: 0;
|
|
}
|
|
|
|
.resize-s {
|
|
bottom: 0;
|
|
left: 0;
|
|
}
|
|
|
|
.resize-sw {
|
|
bottom: 0;
|
|
left: 0;
|
|
}
|
|
|
|
.resize-w {
|
|
top: 0;
|
|
left: 0;
|
|
}
|
|
|
|
.resize-nw {
|
|
top: 0;
|
|
left: 0;
|
|
}
|
|
</style>
|
|
|