From 27924244546b79175065f4d492393e9175ad97bc Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Fri, 21 Nov 2025 13:17:44 -0300 Subject: [PATCH] feat: implement audio/video call functionality in chat - Added a new schema for managing audio/video calls, including fields for call type, room name, and participant management. - Enhanced ChatWindow component to support initiating audio and video calls with dynamic loading of the CallWindow component. - Updated package dependencies to include 'lib-jitsi-meet' for call handling. - Refactored existing code to accommodate new call features and improve user experience. --- PLANO_IMPLEMENTACAO_JITSI.md | 701 ++++++++++++++++++ apps/web/package.json | 3 +- .../lib/components/call/CallControls.svelte | 132 ++++ .../lib/components/call/CallSettings.svelte | 327 ++++++++ .../src/lib/components/call/CallWindow.svelte | 670 +++++++++++++++++ .../lib/components/call/HostControls.svelte | 112 +++ .../components/call/RecordingIndicator.svelte | 23 + .../src/lib/components/chat/ChatWindow.svelte | 117 ++- apps/web/src/lib/stores/callStore.ts | 322 ++++++++ apps/web/src/lib/utils/floatingWindow.ts | 366 +++++++++ apps/web/src/lib/utils/jitsi.ts | 265 +++++++ apps/web/src/lib/utils/mediaRecorder.ts | 331 +++++++++ bun.lock | 4 +- packages/backend/convex/chamadas.ts | 578 +++++++++++++++ packages/backend/convex/schema.ts | 38 + 15 files changed, 3986 insertions(+), 3 deletions(-) create mode 100644 PLANO_IMPLEMENTACAO_JITSI.md create mode 100644 apps/web/src/lib/components/call/CallControls.svelte create mode 100644 apps/web/src/lib/components/call/CallSettings.svelte create mode 100644 apps/web/src/lib/components/call/CallWindow.svelte create mode 100644 apps/web/src/lib/components/call/HostControls.svelte create mode 100644 apps/web/src/lib/components/call/RecordingIndicator.svelte create mode 100644 apps/web/src/lib/stores/callStore.ts create mode 100644 apps/web/src/lib/utils/floatingWindow.ts create mode 100644 apps/web/src/lib/utils/jitsi.ts create mode 100644 apps/web/src/lib/utils/mediaRecorder.ts create mode 100644 packages/backend/convex/chamadas.ts diff --git a/PLANO_IMPLEMENTACAO_JITSI.md b/PLANO_IMPLEMENTACAO_JITSI.md new file mode 100644 index 0000000..4b05f03 --- /dev/null +++ b/PLANO_IMPLEMENTACAO_JITSI.md @@ -0,0 +1,701 @@ +# Plano de Implementação - Chamadas de Áudio e Vídeo com Jitsi Meet + +## Opção Escolhida: Docker Local (Desenvolvimento) + +--- + +## 📋 Etapas Fora do Código - Configuração Docker + +### Etapa 1: Preparar Ambiente Docker + +**Requisitos:** + +- Docker Desktop instalado e rodando +- Mínimo 4GB RAM disponível +- Portas livres: 8000, 8443, 10000-20000/udp + +**Passos:** + +1. **Criar diretório para configuração Docker Jitsi:** + +```bash +mkdir -p ~/jitsi-docker +cd ~/jitsi-docker +``` + +2. **Clonar repositório oficial:** + +```bash +git clone https://github.com/jitsi/docker-jitsi-meet.git +cd docker-jitsi-meet +``` + +3. **Configurar variáveis de ambiente:** + +```bash +cp env.example .env +``` + +4. **Editar arquivo `.env` com as seguintes configurações:** + +```env +# Configuração básica para desenvolvimento local +CONFIG=~/.jitsi-meet-cfg +TZ=America/Recife + +# Desabilitar Let's Encrypt (não necessário para localhost) +ENABLE_LETSENCRYPT=0 + +# Portas HTTP/HTTPS +HTTP_PORT=8000 +HTTPS_PORT=8443 + +# Domínio local +PUBLIC_URL=http://localhost:8000 +DOMAIN=localhost + +# Desabilitar autenticação para facilitar testes +ENABLE_AUTH=0 +ENABLE_GUESTS=1 + +# Desabilitar transcrissão (não necessário para desenvolvimento) +ENABLE_TRANSCRIPTION=0 + +# Desabilitar gravação no servidor (usaremos gravação local) +ENABLE_RECORDING=0 + +# Configurações de vídeo (ajustar conforme necessidade) +ENABLE_PREJOIN_PAGE=0 +START_AUDIO_MUTED=0 +START_VIDEO_MUTED=0 + +# Configurações de segurança +ENABLE_XMPP_WEBSOCKET=0 +ENABLE_P2P=1 + +# Limites +MAX_NUMBER_OF_PARTICIPANTS=10 +RESOLUTION_WIDTH=1280 +RESOLUTION_HEIGHT=720 +``` + +5. **Criar diretórios necessários:** + +```bash +mkdir -p ~/.jitsi-meet-cfg/{web/letsencrypt,transcripts,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb} +``` + +6. **Iniciar containers:** + +```bash +docker-compose up -d +``` + +7. **Verificar status:** + +```bash +docker-compose ps +``` + +8. **Ver logs se necessário:** + +```bash +docker-compose logs -f +``` + +9. **Testar acesso:** + +- Acessar: http://localhost:8000 +- Criar uma sala de teste e verificar se funciona + +**Troubleshooting:** + +- Se houver erro de permissão nos diretórios: `sudo chown -R $USER:$USER ~/.jitsi-meet-cfg` +- Se portas estiverem em uso, alterar HTTP_PORT e HTTPS_PORT no .env +- Para parar: `docker-compose down` +- Para reiniciar: `docker-compose restart` + +--- + +## 📦 Etapas no Código - Backend Convex + +### Etapa 2: Atualizar Schema + +**Arquivo:** `packages/backend/convex/schema.ts` + +**Adicionar nova tabela `chamadas`:** + +```typescript +chamadas: defineTable({ + conversaId: v.id('conversas'), + tipo: v.union(v.literal('audio'), v.literal('video')), + roomName: v.string(), // Nome único da sala Jitsi + criadoPor: v.id('usuarios'), // Anfitrião/criador + participantes: v.array(v.id('usuarios')), + status: v.union( + v.literal('aguardando'), + v.literal('em_andamento'), + v.literal('finalizada'), + v.literal('cancelada') + ), + iniciadaEm: v.optional(v.number()), + finalizadaEm: v.optional(v.number()), + duracaoSegundos: v.optional(v.number()), + gravando: v.boolean(), + gravacaoIniciadaPor: v.optional(v.id('usuarios')), + gravacaoIniciadaEm: v.optional(v.number()), + gravacaoFinalizadaEm: v.optional(v.number()), + configuracoes: v.optional( + v.object({ + audioHabilitado: v.boolean(), + videoHabilitado: v.boolean(), + participantesConfig: v.optional( + v.array( + v.object({ + usuarioId: v.id('usuarios'), + audioHabilitado: v.boolean(), + videoHabilitado: v.boolean(), + forcadoPeloAnfitriao: v.optional(v.boolean()) // Se foi forçado pelo anfitrião + }) + ) + ) + }) + ), + criadoEm: v.number() +}) + .index('by_conversa', ['conversaId', 'status']) + .index('by_conversa_ativa', ['conversaId', 'status']) + .index('by_criado_por', ['criadoPor']) + .index('by_status', ['status']) + .index('by_room_name', ['roomName']); +``` + +### Etapa 3: Criar Backend de Chamadas + +**Arquivo:** `packages/backend/convex/chamadas.ts` + +**Funções a implementar:** + +#### Mutations: + +1. `criarChamada` - Criar nova chamada +2. `iniciarChamada` - Marcar como em andamento +3. `finalizarChamada` - Finalizar e calcular duração +4. `adicionarParticipante` - Adicionar participante +5. `removerParticipante` - Remover participante +6. `toggleAudioVideo` - Anfitrião controla áudio/vídeo de participante +7. `atualizarConfiguracaoParticipante` - Atualizar configuração individual +8. `iniciarGravacao` - Marcar início de gravação +9. `finalizarGravacao` - Marcar fim de gravação + +#### Queries: + +1. `obterChamadaAtiva` - Buscar chamada ativa de uma conversa +2. `listarChamadas` - Listar histórico +3. `verificarAnfitriao` - Verificar se usuário é anfitrião +4. `obterParticipantesChamada` - Listar participantes + +**Tipos TypeScript (sem usar `any`):** + +```typescript +import type { Id } from './_generated/dataModel'; +import type { QueryCtx, MutationCtx } from './_generated/server'; + +type ChamadaTipo = 'audio' | 'video'; +type ChamadaStatus = 'aguardando' | 'em_andamento' | 'finalizada' | 'cancelada'; + +interface ParticipanteConfig { + usuarioId: Id<'usuarios'>; + audioHabilitado: boolean; + videoHabilitado: boolean; + forcadoPeloAnfitriao?: boolean; +} + +interface ConfiguracoesChamada { + audioHabilitado: boolean; + videoHabilitado: boolean; + participantesConfig?: ParticipanteConfig[]; +} +``` + +--- + +## 🎨 Etapas no Código - Frontend Svelte + +### Etapa 4: Instalar Dependências + +**Arquivo:** `apps/web/package.json` + +```bash +cd apps/web +bun add lib-jitsi-meet +``` + +**Dependências adicionais necessárias:** + +- `lib-jitsi-meet` - Biblioteca oficial Jitsi +- (Possivelmente tipos) `@types/lib-jitsi-meet` se disponível + +### Etapa 5: Configurar Variáveis de Ambiente + +**Arquivo:** `apps/web/.env` + +```env +# Jitsi Meet Configuration (Docker Local) +VITE_JITSI_DOMAIN=localhost:8443 +VITE_JITSI_APP_ID=sgse-app +VITE_JITSI_ROOM_PREFIX=sgse +VITE_JITSI_USE_HTTPS=false +``` + +### Etapa 6: Criar Utilitários Jitsi + +**Arquivo:** `apps/web/src/lib/utils/jitsi.ts` + +**Funções:** + +- `gerarRoomName(conversaId: string, tipo: "audio" | "video"): string` - Gerar nome único da sala +- `obterConfiguracaoJitsi()` - Retornar configuração do Jitsi baseada em .env +- `validarDispositivos()` - Validar disponibilidade de microfone/webcam +- `obterDispositivosDisponiveis()` - Listar dispositivos de mídia + +**Tipos (sem `any`):** + +```typescript +interface ConfiguracaoJitsi { + domain: string; + appId: string; + roomPrefix: string; + useHttps: boolean; +} + +interface DispositivoMedia { + deviceId: string; + label: string; + kind: 'audioinput' | 'audiooutput' | 'videoinput'; +} + +interface DispositivosDisponiveis { + microphones: DispositivoMedia[]; + speakers: DispositivoMedia[]; + cameras: DispositivoMedia[]; +} +``` + +### Etapa 7: Criar Store de Chamadas + +**Arquivo:** `apps/web/src/lib/stores/callStore.ts` + +**Estado gerenciado:** + +- Chamada ativa (se houver) +- Estado de mídia (áudio/vídeo ligado/desligado) +- Dispositivos selecionados +- Status de gravação +- Lista de participantes +- Duração da chamada +- É anfitrião ou não + +**Tipos:** + +```typescript +interface EstadoChamada { + chamadaId: Id<'chamadas'> | null; + conversaId: Id<'conversas'> | null; + tipo: 'audio' | 'video' | null; + roomName: string | null; + estaConectado: boolean; + audioHabilitado: boolean; + videoHabilitado: boolean; + gravando: boolean; + ehAnfitriao: boolean; + participantes: Array<{ + usuarioId: Id<'usuarios'>; + nome: string; + avatar?: string; + audioHabilitado: boolean; + videoHabilitado: boolean; + }>; + duracaoSegundos: number; + dispositivos: { + microphoneId: string | null; + cameraId: string | null; + speakerId: string | null; + }; +} + +interface EventosChamada { + 'participant-joined': (participant: ParticipanteJitsi) => void; + 'participant-left': (participantId: string) => void; + 'audio-mute-status-changed': (isMuted: boolean) => void; + 'video-mute-status-changed': (isMuted: boolean) => void; + 'connection-failed': (error: Error) => void; + 'connection-disconnected': () => void; +} +``` + +**Métodos principais:** + +- `iniciarChamada(conversaId, tipo)` +- `finalizarChamada()` +- `toggleAudio()` +- `toggleVideo()` +- `iniciarGravacao()` +- `finalizarGravacao()` +- `atualizarDispositivos()` + +### Etapa 8: Criar Utilitários de Gravação + +**Arquivo:** `apps/web/src/lib/utils/mediaRecorder.ts` + +**Funções:** + +- `iniciarGravacaoAudio(stream: MediaStream): MediaRecorder` - Gravar apenas áudio +- `iniciarGravacaoVideo(stream: MediaStream): MediaRecorder` - Gravar áudio + vídeo +- `pararGravacao(recorder: MediaRecorder): Promise` - Parar e retornar blob +- `salvarGravacao(blob: Blob, nomeArquivo: string): void` - Salvar localmente +- `obterDuracaoGravacao(recorder: MediaRecorder): number` - Obter duração + +**Tipos:** + +```typescript +interface OpcoesGravacao { + audioBitsPerSecond?: number; + videoBitsPerSecond?: number; + mimeType?: string; +} + +interface ResultadoGravacao { + blob: Blob; + duracaoSegundos: number; + nomeArquivo: string; +} +``` + +### Etapa 9: Criar Componente CallWindow + +**Arquivo:** `apps/web/src/lib/components/call/CallWindow.svelte` + +**Características:** + +- Janela flutuante redimensionável e arrastável +- Integração com lib-jitsi-meet +- Container para vídeo dos participantes +- Barra de controles +- Indicador de gravação +- Contador de duração + +**Props (TypeScript estrito):** + +```typescript +interface Props { + chamadaId: Id<'chamadas'>; + conversaId: Id<'conversas'>; + tipo: 'audio' | 'video'; + roomName: string; + ehAnfitriao: boolean; + onClose: () => void; +} +``` + +**Estrutura:** + +- ` + +
+ +
+ + {duracaoFormatada} +
+ + +
+ + + + + + + + {#if ehAnfitriao} + + {/if} + + + + + + +
+
+ diff --git a/apps/web/src/lib/components/call/CallSettings.svelte b/apps/web/src/lib/components/call/CallSettings.svelte new file mode 100644 index 0000000..0245540 --- /dev/null +++ b/apps/web/src/lib/components/call/CallSettings.svelte @@ -0,0 +1,327 @@ + + +{#if open} + e.target === e.currentTarget && handleFechar()} + role="dialog" + aria-labelledby="modal-title" + > + + + + +{/if} + diff --git a/apps/web/src/lib/components/call/CallWindow.svelte b/apps/web/src/lib/components/call/CallWindow.svelte new file mode 100644 index 0000000..8997d0e --- /dev/null +++ b/apps/web/src/lib/components/call/CallWindow.svelte @@ -0,0 +1,670 @@ + + + + + + diff --git a/apps/web/src/lib/components/call/HostControls.svelte b/apps/web/src/lib/components/call/HostControls.svelte new file mode 100644 index 0000000..914243a --- /dev/null +++ b/apps/web/src/lib/components/call/HostControls.svelte @@ -0,0 +1,112 @@ + + +
+
+ + Controles do Anfitrião +
+ +
+ {#if participantes.length === 0} +
+ Nenhum participante na chamada +
+ {:else} + {#each participantes as participante} +
+ +
+ +
+ + {participante.nome} + + {#if participante.forcadoPeloAnfitriao} + + Controlado pelo anfitrião + + {/if} +
+
+ + +
+ + + + + +
+
+ {/each} + {/if} +
+
+ diff --git a/apps/web/src/lib/components/call/RecordingIndicator.svelte b/apps/web/src/lib/components/call/RecordingIndicator.svelte new file mode 100644 index 0000000..da25a23 --- /dev/null +++ b/apps/web/src/lib/components/call/RecordingIndicator.svelte @@ -0,0 +1,23 @@ + + +{#if gravando} + +{/if} diff --git a/apps/web/src/lib/components/chat/ChatWindow.svelte b/apps/web/src/lib/components/chat/ChatWindow.svelte index f3eb486..94cf83c 100644 --- a/apps/web/src/lib/components/chat/ChatWindow.svelte +++ b/apps/web/src/lib/components/chat/ChatWindow.svelte @@ -10,7 +10,20 @@ import ScheduleMessageModal from './ScheduleMessageModal.svelte'; import SalaReuniaoManager from './SalaReuniaoManager.svelte'; import { getAvatarUrl } from '$lib/utils/avatarGenerator'; - import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte'; + import { browser } from '$app/environment'; + import { onMount } from 'svelte'; + import { + Bell, + X, + ArrowLeft, + LogOut, + MoreVertical, + Users, + Clock, + XCircle, + Phone, + Video + } from 'lucide-svelte'; interface Props { conversaId: string; @@ -26,11 +39,20 @@ let showSalaManager = $state(false); let showAdminMenu = $state(false); let showNotificacaoModal = $state(false); + let iniciandoChamada = $state(false); + + // Importação dinâmica do CallWindow apenas no cliente + let CallWindowComponent: any = $state(null); + + const chamadaAtual = $derived(chamadaAtivaQuery?.data); const conversas = useQuery(api.chat.listarConversas, {}); const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId: conversaId as Id<'conversas'> }); + const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, { + conversaId: conversaId as Id<'conversas'> + }); const conversa = $derived(() => { console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId); @@ -115,6 +137,54 @@ alert(errorMessage); } } + + // Funções para chamadas + async function iniciarChamada(tipo: 'audio' | 'video'): Promise { + if (chamadaAtual) { + alert('Já existe uma chamada ativa nesta conversa'); + return; + } + + try { + iniciandoChamada = true; + const chamadaId = await client.mutation(api.chamadas.criarChamada, { + conversaId: conversaId as Id<'conversas'>, + tipo, + audioHabilitado: true, + videoHabilitado: tipo === 'video' + }); + + chamadaAtiva = chamadaId; + } catch (error) { + console.error('Erro ao iniciar chamada:', error); + const errorMessage = error instanceof Error ? error.message : 'Erro ao iniciar chamada'; + alert(errorMessage); + } finally { + iniciandoChamada = false; + } + } + + function fecharChamada(): void { + chamadaAtiva = null; + } + + // Carregar CallWindow dinamicamente apenas no cliente + onMount(async () => { + if (browser && !CallWindowComponent) { + try { + const module = await import('../call/CallWindow.svelte'); + CallWindowComponent = module.default; + } catch (error) { + console.error('Erro ao carregar CallWindow:', error); + } + } + }); + + // Verificar se usuário é anfitrião da chamada atual + const meuPerfil = useQuery(api.auth.getCurrentUser, {}); + const souAnfitriao = $derived( + chamadaAtual && meuPerfil?.data ? chamadaAtual.criadoPor === meuPerfil.data._id : false + );
(showAdminMenu = false)}> @@ -233,6 +303,36 @@
+ + {#if !chamadaAtual} + + + {/if} + {#if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}