Call audio video jitsi #36

Merged
deyvisonwanderley merged 7 commits from call-audio-video-jitsi into master 2025-11-21 22:54:11 +00:00
63 changed files with 5607 additions and 5690 deletions
Showing only changes of commit 2792424454 - Show all commits

View File

@@ -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<Blob>` - 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:**
- `<script lang="ts">` com tipos explícitos
- Uso de `$state`, `$derived`, `$effect` (Svelte 5)
- Integração com `callStore`
- Eventos do Jitsi tratados tipados
**Bibliotecas para janela flutuante:**
- Usar eventos nativos de mouse/touch para drag
- CSS para redimensionamento com handles
- localStorage para persistir posição/tamanho
### Etapa 10: Criar Componente CallControls
**Arquivo:** `apps/web/src/lib/components/call/CallControls.svelte`
**Controles:**
- Botão toggle áudio
- Botão toggle vídeo
- Botão gravação (se anfitrião)
- Botão configurações
- Botão encerrar chamada
- Contador de duração (HH:MM:SS)
**Props:**
```typescript
interface Props {
audioHabilitado: boolean;
videoHabilitado: boolean;
gravando: boolean;
ehAnfitriao: boolean;
duracaoSegundos: number;
onToggleAudio: () => void;
onToggleVideo: () => void;
onIniciarGravacao: () => void;
onPararGravacao: () => void;
onAbrirConfiguracoes: () => void;
onEncerrar: () => void;
}
```
### Etapa 11: Criar Componente CallSettings
**Arquivo:** `apps/web/src/lib/components/call/CallSettings.svelte`
**Funcionalidades:**
- Listar microfones disponíveis
- Listar webcams disponíveis
- Listar alto-falantes disponíveis
- Preview de vídeo antes de aplicar
- Teste de áudio
- Botões aplicar/cancelar
**Props:**
```typescript
interface Props {
open: boolean;
dispositivoAtual: {
microphoneId: string | null;
cameraId: string | null;
speakerId: string | null;
};
onClose: () => void;
onAplicar: (dispositivos: {
microphoneId: string | null;
cameraId: string | null;
speakerId: string | null;
}) => void;
}
```
### Etapa 12: Criar Componente HostControls
**Arquivo:** `apps/web/src/lib/components/call/HostControls.svelte`
**Funcionalidades (apenas para anfitrião):**
- Lista de participantes
- Toggle áudio por participante
- Toggle vídeo por participante
- Indicador visual de quem está gravando
- Status de cada participante
**Props:**
```typescript
interface Props {
participantes: Array<{
usuarioId: Id<'usuarios'>;
nome: string;
avatar?: string;
audioHabilitado: boolean;
videoHabilitado: boolean;
forcadoPeloAnfitriao?: boolean;
}>;
onToggleParticipanteAudio: (usuarioId: Id<'usuarios'>) => void;
onToggleParticipanteVideo: (usuarioId: Id<'usuarios'>) => void;
}
```
### Etapa 13: Criar Componente RecordingIndicator
**Arquivo:** `apps/web/src/lib/components/call/RecordingIndicator.svelte`
**Características:**
- Banner visível no topo da janela
- Ícone animado de gravação
- Mensagem clara de que está gravando
- Informação de quem iniciou (se disponível)
**Props:**
```typescript
interface Props {
gravando: boolean;
iniciadoPor?: string; // Nome do usuário que iniciou
}
```
### Etapa 14: Criar Utilitário de Janela Flutuante
**Arquivo:** `apps/web/src/lib/utils/floatingWindow.ts`
**Funções:**
- `criarDragHandler(element: HTMLElement, handle: HTMLElement): () => void` - Criar handler de arrastar
- `criarResizeHandler(element: HTMLElement, handles: HTMLElement[]): () => void` - Criar handler de redimensionar
- `salvarPosicaoJanela(id: string, posicao: { x: number; y: number; width: number; height: number }): void` - Salvar no localStorage
- `restaurarPosicaoJanela(id: string): { x: number; y: number; width: number; height: number } | null` - Restaurar do localStorage
**Tipos:**
```typescript
interface PosicaoJanela {
x: number;
y: number;
width: number;
height: number;
}
interface LimitesJanela {
minWidth: number;
minHeight: number;
maxWidth?: number;
maxHeight?: number;
}
```
### Etapa 15: Integrar com ChatWindow
**Arquivo:** `apps/web/src/lib/components/chat/ChatWindow.svelte`
**Modificações:**
- Adicionar botão de chamada de áudio
- Adicionar botão de chamada de vídeo
- Mostrar indicador quando há chamada ativa
- Importar e usar CallWindow quando houver chamada
**Adicionar no topo (junto com outros botões):**
```svelte
<button
type="button"
class="btn btn-sm btn-circle"
onclick={() => iniciarChamada('audio')}
title="Ligação de áudio"
>
<Phone class="h-4 w-4" />
</button>
<button
type="button"
class="btn btn-sm btn-circle"
onclick={() => iniciarChamada('video')}
title="Ligação de vídeo"
>
<Video class="h-4 w-4" />
</button>
```
---
## 🔄 Ordem de Implementação Recomendada
1.**Etapa 1:** Configurar Docker Jitsi (fora do código)
2.**Etapa 2:** Atualizar schema com tabela chamadas
3.**Etapa 3:** Criar backend chamadas.ts com todas as funções
4.**Etapa 4:** Instalar dependências frontend
5.**Etapa 5:** Configurar variáveis de ambiente
6.**Etapa 6:** Criar utilitários Jitsi (jitsi.ts)
7.**Etapa 7:** Criar store de chamadas (callStore.ts)
8.**Etapa 8:** Criar utilitários de gravação (mediaRecorder.ts)
9.**Etapa 9:** Criar CallWindow básico (apenas estrutura)
10.**Etapa 10:** Integrar lib-jitsi-meet no CallWindow
11.**Etapa 11:** Criar CallControls e integrar
12.**Etapa 12:** Implementar contador de duração
13.**Etapa 13:** Implementar janela flutuante (drag & resize)
14.**Etapa 14:** Criar CallSettings e integração de dispositivos
15.**Etapa 15:** Criar HostControls e lógica de anfitrião
16.**Etapa 16:** Implementar gravação local
17.**Etapa 17:** Criar RecordingIndicator
18.**Etapa 18:** Integrar botões no ChatWindow
19.**Etapa 19:** Testes completos
20.**Etapa 20:** Ajustes finais e tratamento de erros
---
## 🛡️ Segurança e Boas Práticas
### TypeScript
-**NUNCA** usar `any`
- ✅ Usar tipos explícitos em todas as funções
- ✅ Usar tipos inferidos do Convex quando possível
- ✅ Criar interfaces para objetos complexos
### Svelte 5
- ✅ Usar `$props()` para props
- ✅ Usar `$state()` para estado reativo
- ✅ Usar `$derived()` para valores derivados
- ✅ Usar `$effect()` para side effects
### Validação
- ✅ Validar permissões no backend antes de mutações
- ✅ Validar entrada de dados
- ✅ Tratar erros adequadamente
- ✅ Logs de segurança (criação/finalização de chamadas)
### Performance
- ✅ Cleanup adequado de event listeners
- ✅ Desconectar Jitsi ao fechar janela
- ✅ Parar gravação ao finalizar chamada
- ✅ Liberar streams de mídia
---
## 📝 Notas Importantes
1. **Room Names:** Gerar room names únicos usando conversaId + timestamp + hash
2. **Persistência:** Salvar posição/tamanho da janela no localStorage
3. **Notificações:** Notificar participantes quando chamada é criada/finalizada
4. **Limpeza:** Sempre limpar recursos ao finalizar chamada
5. **Erros:** Tratar erros de conexão, permissões de mídia, etc.
6. **Acessibilidade:** Adicionar labels, ARIA attributes, suporte a teclado
---
## 🧪 Testes
### Testes Funcionais
- [ ] Criar chamada de áudio individual
- [ ] Criar chamada de vídeo individual
- [ ] Criar chamada em grupo
- [ ] Toggle áudio/vídeo
- [ ] Anfitrião controlar participantes
- [ ] Iniciar/parar gravação
- [ ] Contador de duração
- [ ] Configuração de dispositivos
- [ ] Janela flutuante drag/resize
### Testes de Segurança
- [ ] Não anfitrião não pode controlar outros
- [ ] Não anfitrião não pode iniciar gravação
- [ ] Validação de participantes
- [ ] Rate limiting de criação de chamadas
### Testes de Erros
- [ ] Conexão perdida
- [ ] Sem permissão de mídia
- [ ] Dispositivos não disponíveis
- [ ] Servidor Jitsi offline
---
## 📚 Referências
- [Jitsi Meet Docker](https://github.com/jitsi/docker-jitsi-meet)
- [lib-jitsi-meet Documentation](https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-ljm-api)
- [Svelte 5 Documentation](https://svelte.dev/docs)
- [Convex Documentation](https://docs.convex.dev)
- [WebRTC API](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API)
- [MediaRecorder API](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder)
---
**Data de Criação:** 2025-01-XX
**Versão:** 1.0
**Opção:** Docker Local (Desenvolvimento)

View File

@@ -28,7 +28,6 @@
"vite": "^7.1.2" "vite": "^7.1.2"
}, },
"dependencies": { "dependencies": {
"eslint": "catalog:",
"@convex-dev/better-auth": "^0.9.7", "@convex-dev/better-auth": "^0.9.7",
"@dicebear/collection": "^9.2.4", "@dicebear/collection": "^9.2.4",
"@dicebear/core": "^9.2.4", "@dicebear/core": "^9.2.4",
@@ -47,9 +46,11 @@
"convex-svelte": "^0.0.12", "convex-svelte": "^0.0.12",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"emoji-picker-element": "^1.27.0", "emoji-picker-element": "^1.27.0",
"eslint": "catalog:",
"is-network-error": "^1.3.0", "is-network-error": "^1.3.0",
"jspdf": "^3.0.3", "jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
"lib-jitsi-meet": "^1.0.6",
"lucide-svelte": "^0.552.0", "lucide-svelte": "^0.552.0",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"svelte-sonner": "^1.0.5", "svelte-sonner": "^1.0.5",

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { Mic, MicOff, Video, VideoOff, Record, Square, Settings, PhoneOff, Circle } from 'lucide-svelte';
interface Props {
audioHabilitado: boolean;
videoHabilitado: boolean;
gravando: boolean;
ehAnfitriao: boolean;
duracaoSegundos: number;
onToggleAudio: () => void;
onToggleVideo: () => void;
onIniciarGravacao: () => void;
onPararGravacao: () => void;
onAbrirConfiguracoes: () => void;
onEncerrar: () => void;
}
let {
audioHabilitado,
videoHabilitado,
gravando,
ehAnfitriao,
duracaoSegundos,
onToggleAudio,
onToggleVideo,
onIniciarGravacao,
onPararGravacao,
onAbrirConfiguracoes,
onEncerrar
}: Props = $props();
// Formatar duração para HH:MM:SS
function formatarDuracao(segundos: number): string {
const horas = Math.floor(segundos / 3600);
const minutos = Math.floor((segundos % 3600) / 60);
const segs = segundos % 60;
if (horas > 0) {
return `${horas.toString().padStart(2, '0')}:${minutos.toString().padStart(2, '0')}:${segs.toString().padStart(2, '0')}`;
}
return `${minutos.toString().padStart(2, '0')}:${segs.toString().padStart(2, '0')}`;
}
const duracaoFormatada = $derived(formatarDuracao(duracaoSegundos));
</script>
<div class="bg-base-200 flex items-center justify-between gap-2 px-4 py-3">
<!-- Contador de duração -->
<div class="text-base-content flex items-center gap-2 font-mono text-sm">
<Circle class="text-error h-2 w-2 fill-current" />
<span>{duracaoFormatada}</span>
</div>
<!-- Controles principais -->
<div class="flex items-center gap-2">
<!-- Toggle Áudio -->
<button
type="button"
class="btn btn-circle btn-sm"
class:btn-primary={audioHabilitado}
class:btn-error={!audioHabilitado}
onclick={onToggleAudio}
title={audioHabilitado ? 'Desabilitar áudio' : 'Habilitar áudio'}
aria-label={audioHabilitado ? 'Desabilitar áudio' : 'Habilitar áudio'}
>
{#if audioHabilitado}
<Mic class="h-4 w-4" />
{:else}
<MicOff class="h-4 w-4" />
{/if}
</button>
<!-- Toggle Vídeo -->
<button
type="button"
class="btn btn-circle btn-sm"
class:btn-primary={videoHabilitado}
class:btn-error={!videoHabilitado}
onclick={onToggleVideo}
title={videoHabilitado ? 'Desabilitar vídeo' : 'Habilitar vídeo'}
aria-label={videoHabilitado ? 'Desabilitar vídeo' : 'Habilitar vídeo'}
>
{#if videoHabilitado}
<Video class="h-4 w-4" />
{:else}
<VideoOff class="h-4 w-4" />
{/if}
</button>
<!-- Gravação (apenas anfitrião) -->
{#if ehAnfitriao}
<button
type="button"
class="btn btn-circle btn-sm"
class:btn-primary={!gravando}
class:btn-error={gravando}
onclick={gravando ? onPararGravacao : onIniciarGravacao}
title={gravando ? 'Parar gravação' : 'Iniciar gravação'}
aria-label={gravando ? 'Parar gravação' : 'Iniciar gravação'}
>
{#if gravando}
<Square class="h-4 w-4" />
{:else}
<Record class="h-4 w-4" />
{/if}
</button>
{/if}
<!-- Configurações -->
<button
type="button"
class="btn btn-circle btn-sm btn-ghost"
onclick={onAbrirConfiguracoes}
title="Configurações"
aria-label="Configurações"
>
<Settings class="h-4 w-4" />
</button>
<!-- Encerrar chamada -->
<button
type="button"
class="btn btn-circle btn-sm btn-error"
onclick={onEncerrar}
title="Encerrar chamada"
aria-label="Encerrar chamada"
>
<PhoneOff class="h-4 w-4" />
</button>
</div>
</div>

View File

@@ -0,0 +1,327 @@
<script lang="ts">
import { X, Check, Volume2, VolumeX } from 'lucide-svelte';
import { obterDispositivosDisponiveis, solicitarPermissaoMidia } from '$lib/utils/jitsi';
import type { DispositivoMedia } from '$lib/utils/jitsi';
import { onMount } from 'svelte';
interface Props {
open: boolean;
dispositivoAtual: {
microphoneId: string | null;
cameraId: string | null;
speakerId: string | null;
};
onClose: () => void;
onAplicar: (dispositivos: {
microphoneId: string | null;
cameraId: string | null;
speakerId: string | null;
}) => void;
}
let {
open,
dispositivoAtual,
onClose,
onAplicar
}: Props = $props();
let dispositivos = $state<{
microphones: DispositivoMedia[];
speakers: DispositivoMedia[];
cameras: DispositivoMedia[];
}>({
microphones: [],
speakers: [],
cameras: []
});
let selecionados = $state({
microphoneId: dispositivoAtual.microphoneId || null,
cameraId: dispositivoAtual.cameraId || null,
speakerId: dispositivoAtual.speakerId || null
});
let carregando = $state(false);
let previewStream: MediaStream | null = $state(null);
let previewVideo: HTMLVideoElement | null = $state(null);
let erro = $state<string | null>(null);
// Carregar dispositivos disponíveis
async function carregarDispositivos(): Promise<void> {
carregando = true;
erro = null;
try {
dispositivos = await obterDispositivosDisponiveis();
if (dispositivos.microphones.length === 0 && dispositivos.cameras.length === 0) {
erro = 'Nenhum dispositivo de mídia encontrado. Verifique as permissões do navegador.';
}
} catch (error) {
console.error('Erro ao carregar dispositivos:', error);
erro = 'Erro ao carregar dispositivos de mídia.';
} finally {
carregando = false;
}
}
// Atualizar preview quando mudar dispositivos
async function atualizarPreview(): Promise<void> {
if (previewStream) {
previewStream.getTracks().forEach((track) => track.stop());
previewStream = null;
}
if (!previewVideo) return;
try {
const audio = selecionados.microphoneId !== null;
const video = selecionados.cameraId !== null;
if (audio || video) {
const constraints: MediaStreamConstraints = {
audio: audio
? {
deviceId: selecionados.microphoneId ? { exact: selecionados.microphoneId } : undefined
}
: false,
video: video
? {
deviceId: selecionados.cameraId ? { exact: selecionados.cameraId } : undefined
}
: false
};
previewStream = await solicitarPermissaoMidia(audio, video);
if (previewStream && previewVideo) {
previewVideo.srcObject = previewStream;
}
} else {
previewVideo.srcObject = null;
}
} catch (error) {
console.error('Erro ao atualizar preview:', error);
erro = 'Erro ao acessar dispositivo de mídia.';
}
}
// Testar áudio
async function testarAudio(): Promise<void> {
if (!selecionados.microphoneId) {
erro = 'Selecione um microfone primeiro.';
return;
}
try {
const stream = await solicitarPermissaoMidia(true, false);
if (stream) {
// Criar elemento de áudio temporário para teste
const audio = new Audio();
const audioTracks = stream.getAudioTracks();
if (audioTracks.length > 0) {
// O áudio será reproduzido automaticamente se conectado
setTimeout(() => {
stream.getTracks().forEach((track) => track.stop());
}, 3000);
}
}
} catch (error) {
console.error('Erro ao testar áudio:', error);
erro = 'Erro ao testar microfone.';
}
}
function handleFechar(): void {
// Parar preview ao fechar
if (previewStream) {
previewStream.getTracks().forEach((track) => track.stop());
previewStream = null;
}
if (previewVideo) {
previewVideo.srcObject = null;
}
erro = null;
onClose();
}
function handleAplicar(): void {
onAplicar({
microphoneId: selecionados.microphoneId,
cameraId: selecionados.cameraId,
speakerId: selecionados.speakerId
});
handleFechar();
}
// Carregar dispositivos quando abrir
$effect(() => {
if (typeof window === 'undefined') return;
if (open) {
carregarDispositivos();
} else {
// Limpar preview ao fechar
if (previewStream) {
previewStream.getTracks().forEach((track) => track.stop());
previewStream = null;
}
}
});
// Atualizar preview quando mudar seleção
$effect(() => {
if (typeof window === 'undefined') return;
if (open && (selecionados.microphoneId || selecionados.cameraId)) {
atualizarPreview();
}
});
onMount(() => {
return () => {
// Cleanup ao desmontar
if (previewStream) {
previewStream.getTracks().forEach((track) => track.stop());
}
};
});
</script>
{#if open}
<dialog
class="modal modal-open"
onclick={(e) => e.target === e.currentTarget && handleFechar()}
role="dialog"
aria-labelledby="modal-title"
>
<div class="modal-box max-w-2xl" onclick={(e) => e.stopPropagation()}>
<!-- Header -->
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<h2 id="modal-title" class="text-xl font-semibold">Configurações de Mídia</h2>
<button
type="button"
class="btn btn-sm btn-circle"
onclick={handleFechar}
aria-label="Fechar"
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="max-h-[70vh] space-y-6 overflow-y-auto p-6">
{#if erro}
<div class="alert alert-error">
<span>{erro}</span>
</div>
{/if}
{#if carregando}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<!-- Seleção de Microfone -->
<div>
<label class="text-base-content mb-2 block text-sm font-medium">
Microfone
</label>
<select
class="select select-bordered w-full"
bind:value={selecionados.microphoneId}
onchange={atualizarPreview}
>
<option value={null}>Padrão do Sistema</option>
{#each dispositivos.microphones as microfone}
<option value={microfone.deviceId}>{microfone.label}</option>
{/each}
</select>
{#if selecionados.microphoneId}
<button
type="button"
class="btn btn-sm btn-ghost mt-2"
onclick={testarAudio}
>
<Volume2 class="h-4 w-4" />
Testar
</button>
{/if}
</div>
<!-- Seleção de Câmera -->
<div>
<label class="text-base-content mb-2 block text-sm font-medium">
Câmera
</label>
<select
class="select select-bordered w-full"
bind:value={selecionados.cameraId}
onchange={atualizarPreview}
>
<option value={null}>Padrão do Sistema</option>
{#each dispositivos.cameras as camera}
<option value={camera.deviceId}>{camera.label}</option>
{/each}
</select>
</div>
<!-- Preview de Vídeo -->
{#if selecionados.cameraId}
<div>
<label class="text-base-content mb-2 block text-sm font-medium">
Preview
</label>
<div class="bg-base-300 aspect-video w-full overflow-hidden rounded-lg">
<video
bind:this={previewVideo}
autoplay
muted
playsinline
class="h-full w-full object-cover"
></video>
</div>
</div>
{/if}
<!-- Seleção de Alto-falante (se disponível) -->
{#if dispositivos.speakers.length > 0}
<div>
<label class="text-base-content mb-2 block text-sm font-medium">
Alto-falante
</label>
<select
class="select select-bordered w-full"
bind:value={selecionados.speakerId}
>
<option value={null}>Padrão do Sistema</option>
{#each dispositivos.speakers as speaker}
<option value={speaker.deviceId}>{speaker.label}</option>
{/each}
</select>
</div>
{/if}
{/if}
</div>
<!-- Footer -->
<div class="modal-action border-base-300 border-t px-6 py-4">
<button type="button" class="btn btn-ghost" onclick={handleFechar}>
Cancelar
</button>
<button
type="button"
class="btn btn-primary"
onclick={handleAplicar}
disabled={carregando}
>
<Check class="h-4 w-4" />
Aplicar
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={handleFechar}>fechar</button>
</form>
</dialog>
{/if}

View File

@@ -0,0 +1,670 @@
<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';
// Importação dinâmica do Jitsi apenas no cliente
let JitsiMeetJS: any = $state(null);
import CallControls from './CallControls.svelte';
import CallSettings from './CallSettings.svelte';
import HostControls from './HostControls.svelte';
import RecordingIndicator from './RecordingIndicator.svelte';
import {
callState,
toggleAudio,
toggleVideo,
iniciarGravacao as iniciarGravacaoStore,
pararGravacao as pararGravacaoStore,
atualizarDuracao,
atualizarStatusConexao,
atualizarParticipantes,
setAudioHabilitado,
setVideoHabilitado,
atualizarDispositivos,
setJitsiApi,
setStreamLocal,
finalizarChamada as finalizarChamadaStore
} from '$lib/stores/callStore';
import { obterConfiguracaoJitsi, gerarRoomName, obterUrlSala } from '$lib/utils/jitsi';
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: any = $state(null);
let jitsiConference: any = $state(null);
// Queries
const chamadaQuery = useQuery(api.chamadas.obterChamada, { chamadaId });
const chamada = $derived(chamadaQuery?.data);
// Estado derivado do store
const estadoChamada = $derived(get(callState));
// Configuração Jitsi
const configJitsi = $derived.by(() => obterConfiguracaoJitsi());
// Carregar Jitsi dinamicamente
async function carregarJitsi(): Promise<void> {
if (!browser || JitsiMeetJS) return;
try {
const module = await import('lib-jitsi-meet');
JitsiMeetJS = module.default;
} catch (error) {
console.error('Erro ao carregar lib-jitsi-meet:', error);
alert('Erro ao carregar biblioteca de vídeo');
}
}
// Inicializar Jitsi
async function inicializarJitsi(): Promise<void> {
if (!browser || !JitsiMeetJS) {
await carregarJitsi();
}
if (!JitsiMeetJS) {
console.error('JitsiMeetJS não está disponível');
return;
}
try {
const config = configJitsi();
const options: any = {
hosts: {
domain: config.domain,
muc: `conference.${config.domain}`
},
serviceUrl: `${config.useHttps ? 'https' : 'http'}://${config.domain}/http-bind`,
clientNode: config.appId
};
const connection = new JitsiMeetJS.JitsiConnection(null, null, options);
jitsiConnection = connection;
// Eventos de conexão
connection.addEventListener(JitsiMeetJS.events.connection.CONNECTION_ESTABLISHED, () => {
console.log('✅ Conexão estabelecida');
atualizarStatusConexao(true);
// Iniciar chamada no backend
client.mutation(api.chamadas.iniciarChamada, { chamadaId });
// Criar conferência
const conferenceOptions: any = {
startAudioMuted: !estadoChamada.audioHabilitado,
startVideoMuted: !estadoChamada.videoHabilitado
};
const conference = connection.initJitsiConference(roomName, conferenceOptions);
jitsiConference = conference;
// Eventos da conferência
configurarEventosConferencia(conference);
// Entrar na conferência
conference.join();
});
connection.addEventListener(JitsiMeetJS.events.connection.CONNECTION_FAILED, (error) => {
console.error('❌ Falha na conexão:', error);
atualizarStatusConexao(false);
alert('Erro ao conectar com o servidor de vídeo');
});
connection.addEventListener(JitsiMeetJS.events.connection.CONNECTION_DISCONNECTED, () => {
console.log('🔌 Conexão desconectada');
atualizarStatusConexao(false);
});
// Conectar
connection.connect();
} catch (error) {
console.error('Erro ao inicializar Jitsi:', error);
alert('Erro ao inicializar chamada de vídeo');
}
}
// Configurar eventos da conferência
function configurarEventosConferencia(
conference: any
): void {
if (!JitsiMeetJS) return;
// Participante entrou
conference.on(JitsiMeetJS.events.conference.USER_JOINED, (id: string, user: any) => {
console.log('👤 Participante entrou:', id, user);
// Atualizar lista de participantes
atualizarListaParticipantes();
});
// Participante saiu
conference.on(JitsiMeetJS.events.conference.USER_LEFT, (id: string) => {
console.log('👋 Participante saiu:', id);
atualizarListaParticipantes();
});
// Áudio mutado/desmutado
conference.on(JitsiMeetJS.events.conference.TRACK_MUTE_CHANGED, (track: any) => {
console.log('🎤 Mute mudou:', track);
if (track.getType() === 'audio') {
const participanteId = track.getParticipantId();
const isMuted = track.isMuted();
// Atualizar estado do participante
atualizarListaParticipantes();
}
});
// Vídeo mutado/desmutado
conference.on(JitsiMeetJS.events.conference.TRACK_MUTE_CHANGED, (track: any) => {
if (track.getType() === 'video') {
atualizarListaParticipantes();
}
});
// Novo track remoto
conference.on(
JitsiMeetJS.events.conference.TRACK_ADDED,
(track: any) => {
console.log('📹 Track adicionado:', track);
adicionarTrackRemoto(track);
}
);
// Track removido
conference.on(
JitsiMeetJS.events.conference.TRACK_REMOVED,
(track: any) => {
console.log('📹 Track removido:', track);
removerTrackRemoto(track);
}
);
}
// Adicionar track remoto ao container
function adicionarTrackRemoto(track: any): void {
if (!videoContainer || track.getType() !== 'video') return;
const participantId = track.getParticipantId();
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';
const stream = new MediaStream([track.track]);
videoElement.srcObject = stream;
videoContainer.appendChild(videoElement);
}
// Remover track remoto do container
function removerTrackRemoto(track: any): void {
if (!videoContainer) return;
const participantId = track.getParticipantId();
const videoElement = document.getElementById(`remote-video-${participantId}`);
if (videoElement) {
videoElement.remove();
}
}
// 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 = participants.map((p: any) => ({
usuarioId: p.getId() as Id<'usuarios'>,
nome: p.getDisplayName() || 'Participante',
audioHabilitado: !p.isAudioMuted(),
videoHabilitado: !p.isVideoMuted()
}));
atualizarParticipantes(participantesAtualizados);
}
// Controles
function handleToggleAudio(): void {
if (!jitsiConference) return;
toggleAudio();
const novoEstado = get(callState);
if (novoEstado.audioHabilitado) {
jitsiConference.unmuteAudio();
} else {
jitsiConference.muteAudio();
}
}
function handleToggleVideo(): void {
if (!jitsiConference) return;
toggleVideo();
const novoEstado = get(callState);
if (novoEstado.videoHabilitado) {
jitsiConference.unmuteVideo();
} else {
jitsiConference.muteVideo();
}
}
async function handleIniciarGravacao(): Promise<void> {
if (!jitsiConference || gravador) return;
try {
// Obter stream local
const localTracks = jitsiConference.getLocalTracks();
if (localTracks.length === 0) {
alert('Nenhum stream local disponível para gravação');
return;
}
// Criar MediaStream com todos os tracks
const stream = new MediaStream();
localTracks.forEach((track: any) => {
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 (error) {
console.error('Erro ao iniciar gravação:', error);
alert('Erro ao iniciar gravação');
}
}
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 (error) {
console.error('Erro ao parar gravação:', error);
alert('Erro ao parar gravação');
}
}
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;
}
// Desconectar Jitsi
if (jitsiConference) {
jitsiConference.leave();
jitsiConference = null;
}
if (jitsiConnection) {
jitsiConnection.disconnect();
jitsiConnection = null;
}
// Limpar streams
setStreamLocal(null);
// Finalizar no backend
await client.mutation(api.chamadas.finalizarChamada, { chamadaId });
// 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;
// Carregar Jitsi primeiro
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 -->
<div
bind:this={videoContainer}
class="bg-base-300 flex flex-1 flex-wrap gap-2 p-4"
>
{#if estadoChamada.videoHabilitado && localVideo}
<div class="aspect-video w-full rounded-lg bg-base-200">
<video
bind:this={localVideo}
autoplay
muted
playsinline
class="h-full w-full object-cover rounded-lg"
></video>
</div>
{/if}
</div>
<!-- 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}
</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>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { Mic, MicOff, Video, VideoOff, User, Shield } from 'lucide-svelte';
import UserAvatar from '../chat/UserAvatar.svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
interface ParticipanteHost {
usuarioId: Id<'usuarios'>;
nome: string;
avatar?: string;
audioHabilitado: boolean;
videoHabilitado: boolean;
forcadoPeloAnfitriao?: boolean;
}
interface Props {
participantes: ParticipanteHost[];
onToggleParticipanteAudio: (usuarioId: Id<'usuarios'>) => void;
onToggleParticipanteVideo: (usuarioId: Id<'usuarios'>) => void;
}
let { participantes, onToggleParticipanteAudio, onToggleParticipanteVideo }: Props = $props();
</script>
<div class="bg-base-200 border-base-300 flex flex-col border-t">
<div class="bg-base-300 border-base-300 flex items-center gap-2 border-b px-4 py-2">
<Shield class="text-primary h-4 w-4" />
<span class="text-base-content text-sm font-semibold">Controles do Anfitrião</span>
</div>
<div class="max-h-64 space-y-2 overflow-y-auto p-4">
{#if participantes.length === 0}
<div class="text-base-content/70 flex items-center justify-center py-8 text-sm">
Nenhum participante na chamada
</div>
{:else}
{#each participantes as participante}
<div
class="bg-base-100 flex items-center justify-between rounded-lg p-3 shadow-sm"
>
<!-- Informações do participante -->
<div class="flex items-center gap-3">
<UserAvatar usuarioId={participante.usuarioId} avatar={participante.avatar} />
<div class="flex flex-col">
<span class="text-base-content text-sm font-medium">
{participante.nome}
</span>
{#if participante.forcadoPeloAnfitriao}
<span class="text-base-content/60 text-xs">
Controlado pelo anfitrião
</span>
{/if}
</div>
</div>
<!-- Controles do participante -->
<div class="flex items-center gap-2">
<!-- Toggle Áudio -->
<button
type="button"
class="btn btn-circle btn-xs"
class:btn-primary={participante.audioHabilitado}
class:btn-error={!participante.audioHabilitado}
onclick={() => onToggleParticipanteAudio(participante.usuarioId)}
title={
participante.audioHabilitado
? `Desabilitar áudio de ${participante.nome}`
: `Habilitar áudio de ${participante.nome}`
}
aria-label={
participante.audioHabilitado
? `Desabilitar áudio de ${participante.nome}`
: `Habilitar áudio de ${participante.nome}`
}
>
{#if participante.audioHabilitado}
<Mic class="h-3 w-3" />
{:else}
<MicOff class="h-3 w-3" />
{/if}
</button>
<!-- Toggle Vídeo -->
<button
type="button"
class="btn btn-circle btn-xs"
class:btn-primary={participante.videoHabilitado}
class:btn-error={!participante.videoHabilitado}
onclick={() => onToggleParticipanteVideo(participante.usuarioId)}
title={
participante.videoHabilitado
? `Desabilitar vídeo de ${participante.nome}`
: `Habilitar vídeo de ${participante.nome}`
}
aria-label={
participante.videoHabilitado
? `Desabilitar vídeo de ${participante.nome}`
: `Habilitar vídeo de ${participante.nome}`
}
>
{#if participante.videoHabilitado}
<Video class="h-3 w-3" />
{:else}
<VideoOff class="h-3 w-3" />
{/if}
</button>
</div>
</div>
{/each}
{/if}
</div>
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
interface Props {
gravando: boolean;
iniciadoPor?: string;
}
let { gravando, iniciadoPor }: Props = $props();
</script>
{#if gravando}
<div
class="bg-error/90 text-error-content flex items-center gap-2 px-4 py-2 text-sm font-semibold"
role="alert"
aria-live="polite"
>
<div class="animate-pulse">
<div class="h-3 w-3 rounded-full bg-error-content"></div>
</div>
<span>
{iniciadoPor ? `Gravando iniciada por ${iniciadoPor}` : 'Chamada está sendo gravada'}
</span>
</div>
{/if}

View File

@@ -10,7 +10,20 @@
import ScheduleMessageModal from './ScheduleMessageModal.svelte'; import ScheduleMessageModal from './ScheduleMessageModal.svelte';
import SalaReuniaoManager from './SalaReuniaoManager.svelte'; import SalaReuniaoManager from './SalaReuniaoManager.svelte';
import { getAvatarUrl } from '$lib/utils/avatarGenerator'; 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 { interface Props {
conversaId: string; conversaId: string;
@@ -26,11 +39,20 @@
let showSalaManager = $state(false); let showSalaManager = $state(false);
let showAdminMenu = $state(false); let showAdminMenu = $state(false);
let showNotificacaoModal = $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 conversas = useQuery(api.chat.listarConversas, {});
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { const isAdmin = useQuery(api.chat.verificarSeEhAdmin, {
conversaId: conversaId as Id<'conversas'> conversaId: conversaId as Id<'conversas'>
}); });
const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, {
conversaId: conversaId as Id<'conversas'>
});
const conversa = $derived(() => { const conversa = $derived(() => {
console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId); console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId);
@@ -115,6 +137,54 @@
alert(errorMessage); alert(errorMessage);
} }
} }
// Funções para chamadas
async function iniciarChamada(tipo: 'audio' | 'video'): Promise<void> {
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
);
</script> </script>
<div class="flex h-full flex-col" onclick={() => (showAdminMenu = false)}> <div class="flex h-full flex-col" onclick={() => (showAdminMenu = false)}>
@@ -233,6 +303,36 @@
<!-- Botões de ação --> <!-- Botões de ação -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- Botões de Chamada -->
{#if !chamadaAtual}
<button
type="button"
class="btn btn-sm btn-circle btn-primary"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('audio');
}}
disabled={iniciandoChamada}
aria-label="Ligação de áudio"
title="Iniciar ligação de áudio"
>
<Phone class="h-5 w-5" strokeWidth={2} />
</button>
<button
type="button"
class="btn btn-sm btn-circle btn-primary"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('video');
}}
disabled={iniciandoChamada}
aria-label="Ligação de vídeo"
title="Iniciar ligação de vídeo"
>
<Video class="h-5 w-5" strokeWidth={2} />
</button>
{/if}
<!-- Botão Sair (apenas para grupos e salas de reunião) --> <!-- Botão Sair (apenas para grupos e salas de reunião) -->
{#if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')} {#if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
<button <button
@@ -400,6 +500,21 @@
/> />
{/if} {/if}
<!-- Janela de Chamada -->
{#if browser && chamadaAtiva && chamadaAtual && CallWindowComponent}
<div class="pointer-events-none fixed inset-0 z-[9999]">
{@const Component = CallWindowComponent}
<Component
chamadaId={chamadaAtiva}
conversaId={conversaId as Id<'conversas'>}
tipo={chamadaAtual.tipo}
roomName={chamadaAtual.roomName}
ehAnfitriao={souAnfitriao}
onClose={fecharChamada}
/>
</div>
{/if}
<!-- Modal de Enviar Notificação --> <!-- Modal de Enviar Notificação -->
{#if showNotificacaoModal && conversa()?.tipo === 'sala_reuniao' && isAdmin?.data} {#if showNotificacaoModal && conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
<dialog <dialog

View File

@@ -0,0 +1,322 @@
/**
* Store para gerenciar estado das chamadas de áudio/vídeo
*/
import { writable, derived, get } from 'svelte/store';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
export interface ParticipanteChamada {
usuarioId: Id<'usuarios'>;
nome: string;
avatar?: string;
audioHabilitado: boolean;
videoHabilitado: boolean;
forcadoPeloAnfitriao?: boolean;
participantId?: string; // ID do participante no Jitsi
}
export 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: ParticipanteChamada[];
duracaoSegundos: number;
dispositivos: {
microphoneId: string | null;
cameraId: string | null;
speakerId: string | null;
};
jitsiApi: any | null;
streamLocal: MediaStream | null;
}
const estadoInicial: EstadoChamada = {
chamadaId: null,
conversaId: null,
tipo: null,
roomName: null,
estaConectado: false,
audioHabilitado: true,
videoHabilitado: false,
gravando: false,
ehAnfitriao: false,
participantes: [],
duracaoSegundos: 0,
dispositivos: {
microphoneId: null,
cameraId: null,
speakerId: null
},
jitsiApi: null,
streamLocal: null
};
// Store principal do estado da chamada
export const callState = writable<EstadoChamada>(estadoInicial);
// Store para indicar se há chamada ativa
export const chamadaAtiva = derived(
callState,
($state) => $state.chamadaId !== null
);
// Store para indicar se está conectado
export const estaConectado = derived(
callState,
($state) => $state.estaConectado
);
// Store para indicar se está gravando
export const gravando = derived(
callState,
($state) => $state.gravando
);
// Funções para atualizar o estado
/**
* Inicializar chamada
*/
export function inicializarChamada(
chamadaId: Id<'chamadas'>,
conversaId: Id<'conversas'>,
tipo: 'audio' | 'video',
roomName: string,
ehAnfitriao: boolean,
participantes: ParticipanteChamada[]
): void {
callState.set({
...estadoInicial,
chamadaId,
conversaId,
tipo,
roomName,
ehAnfitriao,
participantes,
videoHabilitado: tipo === 'video'
});
}
/**
* Finalizar chamada e limpar estado
*/
export function finalizarChamada(): void {
const estadoAtual = get(callState);
// Liberar recursos
if (estadoAtual.streamLocal) {
estadoAtual.streamLocal.getTracks().forEach((track) => track.stop());
}
callState.set(estadoInicial);
}
/**
* Atualizar status de conexão
*/
export function atualizarStatusConexao(estaConectado: boolean): void {
callState.update((state) => ({
...state,
estaConectado
}));
}
/**
* Toggle áudio
*/
export function toggleAudio(): void {
callState.update((state) => ({
...state,
audioHabilitado: !state.audioHabilitado
}));
}
/**
* Toggle vídeo
*/
export function toggleVideo(): void {
callState.update((state) => ({
...state,
videoHabilitado: !state.videoHabilitado
}));
}
/**
* Definir áudio habilitado/desabilitado
*/
export function setAudioHabilitado(habilitado: boolean): void {
callState.update((state) => ({
...state,
audioHabilitado: habilitado
}));
}
/**
* Definir vídeo habilitado/desabilitado
*/
export function setVideoHabilitado(habilitado: boolean): void {
callState.update((state) => ({
...state,
videoHabilitado: habilitado
}));
}
/**
* Atualizar lista de participantes
*/
export function atualizarParticipantes(participantes: ParticipanteChamada[]): void {
callState.update((state) => ({
...state,
participantes
}));
}
/**
* Adicionar participante
*/
export function adicionarParticipante(participante: ParticipanteChamada): void {
callState.update((state) => {
// Verificar se já existe
const existe = state.participantes.some(
(p) => p.usuarioId === participante.usuarioId
);
if (existe) {
return state;
}
return {
...state,
participantes: [...state.participantes, participante]
};
});
}
/**
* Remover participante
*/
export function removerParticipante(usuarioId: Id<'usuarios'>): void {
callState.update((state) => ({
...state,
participantes: state.participantes.filter(
(p) => p.usuarioId !== usuarioId
)
}));
}
/**
* Atualizar status de áudio/vídeo de participante
*/
export function atualizarParticipanteMidia(
usuarioId: Id<'usuarios'>,
audioHabilitado?: boolean,
videoHabilitado?: boolean
): void {
callState.update((state) => ({
...state,
participantes: state.participantes.map((p) =>
p.usuarioId === usuarioId
? {
...p,
audioHabilitado: audioHabilitado ?? p.audioHabilitado,
videoHabilitado: videoHabilitado ?? p.videoHabilitado
}
: p
)
}));
}
/**
* Iniciar gravação
*/
export function iniciarGravacao(): void {
callState.update((state) => ({
...state,
gravando: true
}));
}
/**
* Parar gravação
*/
export function pararGravacao(): void {
callState.update((state) => ({
...state,
gravando: false
}));
}
/**
* Atualizar duração da chamada
*/
export function atualizarDuracao(segundos: number): void {
callState.update((state) => ({
...state,
duracaoSegundos: segundos
}));
}
/**
* Atualizar dispositivos selecionados
*/
export function atualizarDispositivos(dispositivos: {
microphoneId?: string | null;
cameraId?: string | null;
speakerId?: string | null;
}): void {
callState.update((state) => ({
...state,
dispositivos: {
...state.dispositivos,
...dispositivos
}
}));
}
/**
* Definir API Jitsi
*/
export function setJitsiApi(api: any | null): void {
callState.update((state) => ({
...state,
jitsiApi: api
}));
}
/**
* Definir stream local
*/
export function setStreamLocal(stream: MediaStream | null): void {
callState.update((state) => {
// Parar stream anterior se existir
if (state.streamLocal) {
state.streamLocal.getTracks().forEach((track) => track.stop());
}
return {
...state,
streamLocal: stream
};
});
}
/**
* Obter estado atual (helper)
*/
export function obterEstadoAtual(): EstadoChamada {
return get(callState);
}
/**
* Resetar estado (para cleanup)
*/
export function resetarEstado(): void {
finalizarChamada();
}

View File

@@ -0,0 +1,366 @@
/**
* Utilitários para criar janela flutuante redimensionável e arrastável
*/
export interface PosicaoJanela {
x: number;
y: number;
width: number;
height: number;
}
export interface LimitesJanela {
minWidth: number;
minHeight: number;
maxWidth?: number;
maxHeight?: number;
}
const DEFAULT_LIMITS: LimitesJanela = {
minWidth: 400,
minHeight: 300,
maxWidth: window.innerWidth,
maxHeight: window.innerHeight
};
/**
* Salvar posição da janela no localStorage
*/
export function salvarPosicaoJanela(
id: string,
posicao: PosicaoJanela
): void {
try {
const key = `floating-window-${id}`;
localStorage.setItem(key, JSON.stringify(posicao));
} catch (error) {
console.warn('Erro ao salvar posição da janela:', error);
}
}
/**
* Restaurar posição da janela do localStorage
*/
export function restaurarPosicaoJanela(id: string): PosicaoJanela | null {
try {
const key = `floating-window-${id}`;
const saved = localStorage.getItem(key);
if (!saved) return null;
const posicao = JSON.parse(saved) as PosicaoJanela;
// Validar se a posição ainda é válida (dentro da tela)
if (
posicao.x >= 0 &&
posicao.y >= 0 &&
posicao.x + posicao.width <= window.innerWidth + 100 &&
posicao.y + posicao.height <= window.innerHeight + 100 &&
posicao.width >= DEFAULT_LIMITS.minWidth &&
posicao.height >= DEFAULT_LIMITS.minHeight
) {
return posicao;
}
return null;
} catch (error) {
console.warn('Erro ao restaurar posição da janela:', error);
return null;
}
}
/**
* Obter posição inicial da janela (centralizada)
*/
export function obterPosicaoInicial(
width: number = 800,
height: number = 600
): PosicaoJanela {
return {
x: (window.innerWidth - width) / 2,
y: (window.innerHeight - height) / 2,
width,
height
};
}
/**
* Criar handler de arrastar para janela
*/
export function criarDragHandler(
element: HTMLElement,
handle: HTMLElement,
onPositionChange?: (x: number, y: number) => void
): () => void {
let isDragging = false;
let startX = 0;
let startY = 0;
let initialX = 0;
let initialY = 0;
function handleMouseDown(e: MouseEvent): void {
if (e.button !== 0) return; // Apenas botão esquerdo
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = element.getBoundingClientRect();
initialX = rect.left;
initialY = rect.top;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
e.preventDefault();
}
function handleMouseMove(e: MouseEvent): void {
if (!isDragging) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
let newX = initialX + deltaX;
let newY = initialY + deltaY;
// Limitar movimento dentro da tela
const maxX = window.innerWidth - element.offsetWidth;
const maxY = window.innerHeight - element.offsetHeight;
newX = Math.max(0, Math.min(newX, maxX));
newY = Math.max(0, Math.min(newY, maxY));
element.style.left = `${newX}px`;
element.style.top = `${newY}px`;
if (onPositionChange) {
onPositionChange(newX, newY);
}
}
function handleMouseUp(): void {
isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
// Suporte para touch (mobile)
function handleTouchStart(e: TouchEvent): void {
if (e.touches.length !== 1) return;
isDragging = true;
const touch = e.touches[0];
startX = touch.clientX;
startY = touch.clientY;
const rect = element.getBoundingClientRect();
initialX = rect.left;
initialY = rect.top;
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
e.preventDefault();
}
function handleTouchMove(e: TouchEvent): void {
if (!isDragging || e.touches.length !== 1) return;
const touch = e.touches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
let newX = initialX + deltaX;
let newY = initialY + deltaY;
const maxX = window.innerWidth - element.offsetWidth;
const maxY = window.innerHeight - element.offsetHeight;
newX = Math.max(0, Math.min(newX, maxX));
newY = Math.max(0, Math.min(newY, maxY));
element.style.left = `${newX}px`;
element.style.top = `${newY}px`;
if (onPositionChange) {
onPositionChange(newX, newY);
}
e.preventDefault();
}
function handleTouchEnd(): void {
isDragging = false;
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
}
handle.addEventListener('mousedown', handleMouseDown);
handle.addEventListener('touchstart', handleTouchStart, { passive: false });
// Retornar função de cleanup
return () => {
handle.removeEventListener('mousedown', handleMouseDown);
handle.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}
/**
* Criar handler de redimensionar para janela
*/
export function criarResizeHandler(
element: HTMLElement,
handles: HTMLElement[],
limites: LimitesJanela = DEFAULT_LIMITS,
onSizeChange?: (width: number, height: number) => void
): () => void {
let isResizing = false;
let currentHandle: HTMLElement | null = null;
let startX = 0;
let startY = 0;
let startWidth = 0;
let startHeight = 0;
let startLeft = 0;
let startTop = 0;
function handleMouseDown(e: MouseEvent, handle: HTMLElement): void {
if (e.button !== 0) return;
isResizing = true;
currentHandle = handle;
startX = e.clientX;
startY = e.clientY;
const rect = element.getBoundingClientRect();
startWidth = rect.width;
startHeight = rect.height;
startLeft = rect.left;
startTop = rect.top;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
e.preventDefault();
e.stopPropagation();
}
function handleMouseMove(e: MouseEvent): void {
if (!isResizing || !currentHandle) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
let newWidth = startWidth;
let newHeight = startHeight;
let newLeft = startLeft;
let newTop = startTop;
// Determinar direção do resize baseado na classe do handle
const classes = currentHandle.className;
// Right
if (classes.includes('resize-right') || classes.includes('resize-e')) {
newWidth = startWidth + deltaX;
}
// Bottom
if (classes.includes('resize-bottom') || classes.includes('resize-s')) {
newHeight = startHeight + deltaY;
}
// Left
if (classes.includes('resize-left') || classes.includes('resize-w')) {
newWidth = startWidth - deltaX;
newLeft = startLeft + deltaX;
}
// Top
if (classes.includes('resize-top') || classes.includes('resize-n')) {
newHeight = startHeight - deltaY;
newTop = startTop + deltaY;
}
// Corner handles
if (classes.includes('resize-se')) {
newWidth = startWidth + deltaX;
newHeight = startHeight + deltaY;
}
if (classes.includes('resize-sw')) {
newWidth = startWidth - deltaX;
newHeight = startHeight + deltaY;
newLeft = startLeft + deltaX;
}
if (classes.includes('resize-ne')) {
newWidth = startWidth + deltaX;
newHeight = startHeight - deltaY;
newTop = startTop + deltaY;
}
if (classes.includes('resize-nw')) {
newWidth = startWidth - deltaX;
newHeight = startHeight - deltaY;
newLeft = startLeft + deltaX;
newTop = startTop + deltaY;
}
// Aplicar limites
const maxWidth = limites.maxWidth || window.innerWidth - newLeft;
const maxHeight = limites.maxHeight || window.innerHeight - newTop;
newWidth = Math.max(limites.minWidth, Math.min(newWidth, maxWidth));
newHeight = Math.max(limites.minHeight, Math.min(newHeight, maxHeight));
// Ajustar posição se necessário
if (newLeft + newWidth > window.innerWidth) {
newLeft = window.innerWidth - newWidth;
}
if (newTop + newHeight > window.innerHeight) {
newTop = window.innerHeight - newHeight;
}
if (newLeft < 0) {
newLeft = 0;
newWidth = Math.min(newWidth, window.innerWidth);
}
if (newTop < 0) {
newTop = 0;
newHeight = Math.min(newHeight, window.innerHeight);
}
element.style.width = `${newWidth}px`;
element.style.height = `${newHeight}px`;
element.style.left = `${newLeft}px`;
element.style.top = `${newTop}px`;
if (onSizeChange) {
onSizeChange(newWidth, newHeight);
}
}
function handleMouseUp(): void {
isResizing = false;
currentHandle = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
const cleanupFunctions: (() => void)[] = [];
// Adicionar listeners para cada handle
for (const handle of handles) {
const handler = (e: MouseEvent) => handleMouseDown(e, handle);
handle.addEventListener('mousedown', handler);
cleanupFunctions.push(() => handle.removeEventListener('mousedown', handler));
}
// Retornar função de cleanup
return () => {
cleanupFunctions.forEach((cleanup) => cleanup());
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}

View File

@@ -0,0 +1,265 @@
/**
* Utilitários para integração com Jitsi Meet
*/
export interface ConfiguracaoJitsi {
domain: string;
appId: string;
roomPrefix: string;
useHttps: boolean;
}
export interface DispositivoMedia {
deviceId: string;
label: string;
kind: 'audioinput' | 'audiooutput' | 'videoinput';
}
export interface DispositivosDisponiveis {
microphones: DispositivoMedia[];
speakers: DispositivoMedia[];
cameras: DispositivoMedia[];
}
/**
* Obter configuração do Jitsi baseada em variáveis de ambiente
*/
export function obterConfiguracaoJitsi(): ConfiguracaoJitsi {
const domain = import.meta.env.VITE_JITSI_DOMAIN || 'localhost:8443';
const appId = import.meta.env.VITE_JITSI_APP_ID || 'sgse-app';
const roomPrefix = import.meta.env.VITE_JITSI_ROOM_PREFIX || 'sgse';
const useHttps = import.meta.env.VITE_JITSI_USE_HTTPS === 'true';
return {
domain,
appId,
roomPrefix,
useHttps
};
}
/**
* Gerar nome único para a sala Jitsi
*/
export function gerarRoomName(conversaId: string, tipo: 'audio' | 'video'): string {
const config = obterConfiguracaoJitsi();
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 9);
const conversaHash = conversaId.replace(/[^a-zA-Z0-9]/g, '').substring(0, 10);
return `${config.roomPrefix}-${tipo}-${conversaHash}-${timestamp}-${random}`;
}
/**
* Obter URL completa da sala Jitsi
*/
export function obterUrlSala(roomName: string): string {
const config = obterConfiguracaoJitsi();
const protocol = config.useHttps ? 'https' : 'http';
return `${protocol}://${config.domain}/${roomName}`;
}
/**
* Validar se dispositivos de mídia estão disponíveis
*/
export async function validarDispositivos(): Promise<{
microfoneDisponivel: boolean;
cameraDisponivel: boolean;
}> {
if (typeof window === 'undefined') {
return {
microfoneDisponivel: false,
cameraDisponivel: false
};
}
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const microfoneDisponivel = devices.some(
(device) => device.kind === 'audioinput'
);
const cameraDisponivel = devices.some(
(device) => device.kind === 'videoinput'
);
return {
microfoneDisponivel,
cameraDisponivel
};
} catch (error) {
console.error('Erro ao validar dispositivos:', error);
return {
microfoneDisponivel: false,
cameraDisponivel: false
};
}
}
/**
* Solicitar permissão de acesso aos dispositivos de mídia
*/
export async function solicitarPermissaoMidia(
audio: boolean = true,
video: boolean = false
): Promise<MediaStream | null> {
if (typeof window === 'undefined') {
return null;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio,
video: video ? { facingMode: 'user' } : false
});
return stream;
} catch (error) {
console.error('Erro ao solicitar permissão de mídia:', error);
return null;
}
}
/**
* Obter lista de dispositivos de mídia disponíveis
*/
export async function obterDispositivosDisponiveis(): Promise<DispositivosDisponiveis> {
if (typeof window === 'undefined') {
return {
microphones: [],
speakers: [],
cameras: []
};
}
try {
// Solicitar permissão primeiro para obter labels dos dispositivos
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
const devices = await navigator.mediaDevices.enumerateDevices();
const microphones: DispositivoMedia[] = devices
.filter((device) => device.kind === 'audioinput')
.map((device) => ({
deviceId: device.deviceId,
label: device.label || `Microfone ${device.deviceId.substring(0, 8)}`,
kind: 'audioinput' as const
}));
const speakers: DispositivoMedia[] = devices
.filter((device) => device.kind === 'audiooutput')
.map((device) => ({
deviceId: device.deviceId,
label: device.label || `Alto-falante ${device.deviceId.substring(0, 8)}`,
kind: 'audiooutput' as const
}));
const cameras: DispositivoMedia[] = devices
.filter((device) => device.kind === 'videoinput')
.map((device) => ({
deviceId: device.deviceId,
label: device.label || `Câmera ${device.deviceId.substring(0, 8)}`,
kind: 'videoinput' as const
}));
return {
microphones,
speakers,
cameras
};
} catch (error) {
console.error('Erro ao obter dispositivos disponíveis:', error);
return {
microphones: [],
speakers: [],
cameras: []
};
}
}
/**
* Configurar dispositivo de áudio de saída (alto-falante)
*/
export async function configurarAltoFalante(
deviceId: string,
audioElement: HTMLAudioElement
): Promise<boolean> {
if (typeof window === 'undefined') {
return false;
}
try {
// @ts-expect-error - setSinkId pode não estar disponível em todos os navegadores
if (audioElement.setSinkId && typeof audioElement.setSinkId === 'function') {
await audioElement.setSinkId(deviceId);
return true;
}
return false;
} catch (error) {
console.error('Erro ao configurar alto-falante:', error);
return false;
}
}
/**
* Verificar se WebRTC está disponível no navegador
*/
export function verificarSuporteWebRTC(): boolean {
if (typeof window === 'undefined') {
return false;
}
return !!(
navigator.mediaDevices &&
navigator.mediaDevices.getUserMedia &&
window.RTCPeerConnection
);
}
/**
* Obter informações do navegador para debug
*/
export function obterInfoNavegador(): {
navegador: string;
versao: string;
webrtcSuportado: boolean;
mediaDevicesDisponivel: boolean;
} {
if (typeof window === 'undefined') {
return {
navegador: 'Servidor',
versao: 'N/A',
webrtcSuportado: false,
mediaDevicesDisponivel: false
};
}
const userAgent = navigator.userAgent;
let navegador = 'Desconhecido';
let versao = 'Desconhecida';
if (userAgent.indexOf('Chrome') > -1) {
navegador = 'Chrome';
const match = userAgent.match(/Chrome\/(\d+)/);
versao = match ? match[1] : 'Desconhecida';
} else if (userAgent.indexOf('Firefox') > -1) {
navegador = 'Firefox';
const match = userAgent.match(/Firefox\/(\d+)/);
versao = match ? match[1] : 'Desconhecida';
} else if (userAgent.indexOf('Safari') > -1) {
navegador = 'Safari';
const match = userAgent.match(/Version\/(\d+)/);
versao = match ? match[1] : 'Desconhecida';
} else if (userAgent.indexOf('Edge') > -1) {
navegador = 'Edge';
const match = userAgent.match(/Edge\/(\d+)/);
versao = match ? match[1] : 'Desconhecida';
}
return {
navegador,
versao,
webrtcSuportado: verificarSuporteWebRTC(),
mediaDevicesDisponivel: !!navigator.mediaDevices
};
}

View File

@@ -0,0 +1,331 @@
/**
* Utilitários para gravação de mídia usando MediaRecorder API
*/
export interface OpcoesGravacao {
audioBitsPerSecond?: number;
videoBitsPerSecond?: number;
mimeType?: string;
}
export interface ResultadoGravacao {
blob: Blob;
duracaoSegundos: number;
nomeArquivo: string;
}
/**
* Verificar se MediaRecorder está disponível no navegador
*/
export function verificarSuporteMediaRecorder(): boolean {
return typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported !== undefined;
}
/**
* Obter tipos MIME suportados para gravação
*/
export function obterTiposMimeSuportados(): {
video: string[];
audio: string[];
} {
if (!verificarSuporteMediaRecorder()) {
return { video: [], audio: [] };
}
const tiposVideo: string[] = [];
const tiposAudio: string[] = [];
// Tipos comuns de vídeo
const tiposVideoComuns = [
'video/webm',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm;codecs=h264',
'video/mp4',
'video/ogg',
'video/x-matroska'
];
// Tipos comuns de áudio
const tiposAudioComuns = [
'audio/webm',
'audio/webm;codecs=opus',
'audio/ogg',
'audio/mp4',
'audio/mpeg'
];
for (const tipo of tiposVideoComuns) {
if (MediaRecorder.isTypeSupported(tipo)) {
tiposVideo.push(tipo);
}
}
for (const tipo of tiposAudioComuns) {
if (MediaRecorder.isTypeSupported(tipo)) {
tiposAudio.push(tipo);
}
}
return { video: tiposVideo, audio: tiposAudio };
}
/**
* Iniciar gravação de áudio apenas
*/
export function iniciarGravacaoAudio(
stream: MediaStream,
opcoes?: OpcoesGravacao
): MediaRecorder | null {
if (!verificarSuporteMediaRecorder()) {
console.error('MediaRecorder não está disponível neste navegador');
return null;
}
try {
const tiposAudio = obterTiposMimeSuportados().audio;
const mimeType = opcoes?.mimeType || tiposAudio[0] || 'audio/webm';
const recorder = new MediaRecorder(stream, {
mimeType,
audioBitsPerSecond: opcoes?.audioBitsPerSecond || 128000
});
return recorder;
} catch (error) {
console.error('Erro ao iniciar gravação de áudio:', error);
return null;
}
}
/**
* Iniciar gravação de vídeo (áudio + vídeo)
*/
export function iniciarGravacaoVideo(
stream: MediaStream,
opcoes?: OpcoesGravacao
): MediaRecorder | null {
if (!verificarSuporteMediaRecorder()) {
console.error('MediaRecorder não está disponível neste navegador');
return null;
}
try {
const tiposVideo = obterTiposMimeSuportados().video;
const mimeType = opcoes?.mimeType || tiposVideo[0] || 'video/webm';
const recorder = new MediaRecorder(stream, {
mimeType,
audioBitsPerSecond: opcoes?.audioBitsPerSecond || 128000,
videoBitsPerSecond: opcoes?.videoBitsPerSecond || 2500000
});
return recorder;
} catch (error) {
console.error('Erro ao iniciar gravação de vídeo:', error);
return null;
}
}
/**
* Parar gravação e retornar blob
*/
export function pararGravacao(recorder: MediaRecorder): Promise<Blob> {
return new Promise((resolve, reject) => {
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
chunks.push(event.data);
}
};
recorder.onstop = () => {
const blob = new Blob(chunks, { type: recorder.mimeType });
resolve(blob);
};
recorder.onerror = (event) => {
console.error('Erro na gravação:', event);
reject(new Error('Erro ao parar gravação'));
};
if (recorder.state === 'recording') {
recorder.stop();
} else {
reject(new Error('Recorder não está gravando'));
}
});
}
/**
* Salvar gravação localmente
*/
export function salvarGravacao(
blob: Blob,
nomeArquivo: string
): void {
try {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = nomeArquivo;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Erro ao salvar gravação:', error);
throw error;
}
}
/**
* Gerar nome de arquivo para gravação
*/
export function gerarNomeArquivo(
tipo: 'audio' | 'video',
roomName: string,
timestamp?: number
): string {
const agora = timestamp || Date.now();
const data = new Date(agora);
const dataFormatada = data.toISOString().replace(/[:.]/g, '-').split('T')[0];
const horaFormatada = data.toLocaleTimeString('pt-BR', { hour12: false }).replace(/:/g, '-');
const extensao = tipo === 'audio' ? 'webm' : 'webm';
return `gravacao-${tipo}-${roomName}-${dataFormatada}-${horaFormatada}.${extensao}`;
}
/**
* Obter tamanho do blob em formato legível
*/
export function formatarTamanhoBlob(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
/**
* Calcular duração de gravação (em segundos)
*/
export function calcularDuracaoGravacao(
inicioTimestamp: number,
fimTimestamp?: number
): number {
const fim = fimTimestamp || Date.now();
return Math.floor((fim - inicioTimestamp) / 1000);
}
/**
* Gravar com controle completo
*/
export class GravadorMedia {
private recorder: MediaRecorder | null = null;
private stream: MediaStream | null = null;
private inicioTimestamp: number = 0;
private chunks: BlobPart[] = [];
constructor(
private streamOriginal: MediaStream,
private tipo: 'audio' | 'video',
private opcoes?: OpcoesGravacao
) {
this.stream = streamOriginal;
}
iniciar(): boolean {
if (this.recorder && this.recorder.state === 'recording') {
console.warn('Gravação já está em andamento');
return false;
}
try {
this.recorder =
this.tipo === 'audio'
? iniciarGravacaoAudio(this.stream!, this.opcoes)
: iniciarGravacaoVideo(this.stream!, this.opcoes);
if (!this.recorder) {
return false;
}
this.chunks = [];
this.inicioTimestamp = Date.now();
this.recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
this.chunks.push(event.data);
}
};
this.recorder.start(1000); // Coletar dados a cada segundo
return true;
} catch (error) {
console.error('Erro ao iniciar gravação:', error);
return false;
}
}
parar(): Promise<Blob> {
return new Promise((resolve, reject) => {
if (!this.recorder) {
reject(new Error('Recorder não foi inicializado'));
return;
}
if (this.recorder.state === 'inactive') {
// Se já parou, retornar blob dos chunks
if (this.chunks.length > 0) {
const blob = new Blob(this.chunks, { type: this.recorder.mimeType });
resolve(blob);
} else {
reject(new Error('Nenhum dado gravado'));
}
return;
}
this.recorder.onstop = () => {
const blob = new Blob(this.chunks, { type: this.recorder!.mimeType });
resolve(blob);
};
this.recorder.onerror = (event) => {
console.error('Erro na gravação:', event);
reject(new Error('Erro ao parar gravação'));
};
this.recorder.stop();
});
}
obterDuracaoSegundos(): number {
if (this.inicioTimestamp === 0) return 0;
return calcularDuracaoGravacao(this.inicioTimestamp);
}
estaGravando(): boolean {
return this.recorder?.state === 'recording';
}
liberar(): void {
if (this.recorder && this.recorder.state === 'recording') {
this.recorder.stop();
}
// Parar todas as tracks do stream
if (this.stream) {
this.stream.getTracks().forEach((track) => track.stop());
}
this.recorder = null;
this.stream = null;
this.chunks = [];
this.inicioTimestamp = 0;
}
}

View File

@@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "sgse-app", "name": "sgse-app",
@@ -49,6 +48,7 @@
"is-network-error": "^1.3.0", "is-network-error": "^1.3.0",
"jspdf": "^3.0.3", "jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
"lib-jitsi-meet": "^1.0.6",
"lucide-svelte": "^0.552.0", "lucide-svelte": "^0.552.0",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"svelte-sonner": "^1.0.5", "svelte-sonner": "^1.0.5",
@@ -1006,6 +1006,8 @@
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lib-jitsi-meet": ["lib-jitsi-meet@1.0.6", "", {}, "sha512-Hnp8F7btmIFBGh5hgli1uTzb7c7IgWBgTMFu4GnSasE8sx23RcTerXBjH+XZcsGsxnoW3pFKlU77za1a0o3qhw=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],

View File

@@ -0,0 +1,578 @@
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
import { Id } from './_generated/dataModel';
import type { QueryCtx, MutationCtx } from './_generated/server';
import { getCurrentUserFunction } from './auth';
// ========== HELPERS ==========
/**
* Helper function para obter usuário autenticado
*/
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
const usuarioAtual = await getCurrentUserFunction(ctx);
if (!usuarioAtual) {
console.warn('⚠️ [chamadas] Usuário não autenticado');
}
return usuarioAtual || null;
}
/**
* Gerar nome único para a sala Jitsi
*/
function gerarRoomName(conversaId: Id<'conversas'>, tipo: 'audio' | 'video'): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 9);
return `sgse-${tipo}-${conversaId.replace('conversas|', '')}-${timestamp}-${random}`;
}
/**
* Verificar se usuário é anfitrião da chamada
*/
async function verificarAnfitriao(
ctx: QueryCtx | MutationCtx,
chamadaId: Id<'chamadas'>,
usuarioId: Id<'usuarios'>
): Promise<boolean> {
const chamada = await ctx.db.get(chamadaId);
if (!chamada) return false;
return chamada.criadoPor === usuarioId;
}
/**
* Verificar se usuário participa da conversa
*/
async function verificarParticipanteConversa(
ctx: QueryCtx | MutationCtx,
conversaId: Id<'conversas'>,
usuarioId: Id<'usuarios'>
): Promise<boolean> {
const conversa = await ctx.db.get(conversaId);
if (!conversa) return false;
return conversa.participantes.includes(usuarioId);
}
// ========== MUTATIONS ==========
/**
* Criar nova chamada de áudio ou vídeo
*/
export const criarChamada = mutation({
args: {
conversaId: v.id('conversas'),
tipo: v.union(v.literal('audio'), v.literal('video')),
audioHabilitado: v.optional(v.boolean()),
videoHabilitado: v.optional(v.boolean())
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error('Não autenticado');
// Verificar se usuário participa da conversa
const participa = await verificarParticipanteConversa(ctx, args.conversaId, usuarioAtual._id);
if (!participa) {
throw new Error('Você não participa desta conversa');
}
// Verificar se já existe chamada ativa
const chamadasAtivas = await ctx.db
.query('chamadas')
.withIndex('by_conversa_ativa', (q) => q.eq('conversaId', args.conversaId))
.filter((q) =>
q.or(
q.eq(q.field('status'), 'aguardando'),
q.eq(q.field('status'), 'em_andamento')
)
)
.collect();
if (chamadasAtivas.length > 0) {
// Retornar chamada ativa existente
return chamadasAtivas[0]._id;
}
// Obter participantes da conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) throw new Error('Conversa não encontrada');
// Gerar nome único da sala
const roomName = gerarRoomName(args.conversaId, args.tipo);
// Criar chamada
const chamadaId = await ctx.db.insert('chamadas', {
conversaId: args.conversaId,
tipo: args.tipo,
roomName,
criadoPor: usuarioAtual._id,
participantes: conversa.participantes,
status: 'aguardando',
gravando: false,
configuracoes: {
audioHabilitado: args.audioHabilitado ?? true,
videoHabilitado: args.videoHabilitado ?? (args.tipo === 'video'),
participantesConfig: conversa.participantes.map((participanteId) => ({
usuarioId: participanteId,
audioHabilitado: participanteId === usuarioAtual._id ? (args.audioHabilitado ?? true) : true,
videoHabilitado: participanteId === usuarioAtual._id ? (args.videoHabilitado ?? (args.tipo === 'video')) : (args.tipo === 'video'),
forcadoPeloAnfitriao: false
}))
},
criadoEm: Date.now()
});
return chamadaId;
}
});
/**
* Iniciar chamada (marcar como em andamento)
*/
export const iniciarChamada = mutation({
args: {
chamadaId: v.id('chamadas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error('Não autenticado');
const chamada = await ctx.db.get(args.chamadaId);
if (!chamada) throw new Error('Chamada não encontrada');
// Verificar se usuário participa da chamada
if (!chamada.participantes.includes(usuarioAtual._id)) {
throw new Error('Você não participa desta chamada');
}
// Se já estiver em andamento, retornar
if (chamada.status === 'em_andamento') {
return null;
}
// Atualizar status
await ctx.db.patch(args.chamadaId, {
status: 'em_andamento',
iniciadaEm: Date.now()
});
return null;
}
});
/**
* Finalizar chamada e calcular duração
*/
export const finalizarChamada = mutation({
args: {
chamadaId: v.id('chamadas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error('Não autenticado');
const chamada = await ctx.db.get(args.chamadaId);
if (!chamada) throw new Error('Chamada não encontrada');
// Verificar se usuário participa da chamada
if (!chamada.participantes.includes(usuarioAtual._id)) {
throw new Error('Você não participa desta chamada');
}
// Se já estiver finalizada, retornar
if (chamada.status === 'finalizada' || chamada.status === 'cancelada') {
return null;
}
// Calcular duração
const finalizadaEm = Date.now();
const iniciadaEm = chamada.iniciadaEm || chamada.criadoEm;
const duracaoSegundos = Math.floor((finalizadaEm - iniciadaEm) / 1000);
// Se estiver gravando, parar gravação
const gravando = chamada.gravando;
// Atualizar status
await ctx.db.patch(args.chamadaId, {
status: 'finalizada',
finalizadaEm,
duracaoSegundos,
gravando: false,
gravacaoFinalizadaEm: gravando ? finalizadaEm : chamada.gravacaoFinalizadaEm
});
return null;
}
});
/**
* Cancelar chamada (antes de iniciar)
*/
export const cancelarChamada = mutation({
args: {
chamadaId: v.id('chamadas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error('Não autenticado');
const chamada = await ctx.db.get(args.chamadaId);
if (!chamada) throw new Error('Chamada não encontrada');
// Apenas anfitrião pode cancelar
const ehAnfitriao = await verificarAnfitriao(ctx, args.chamadaId, usuarioAtual._id);
if (!ehAnfitriao) {
throw new Error('Apenas o anfitrião pode cancelar a chamada');
}
// Se já estiver finalizada, retornar
if (chamada.status === 'finalizada' || chamada.status === 'cancelada') {
return null;
}
// Atualizar status
await ctx.db.patch(args.chamadaId, {
status: 'cancelada',
finalizadaEm: Date.now()
});
return null;
}
});
/**
* Adicionar participante à chamada
*/
export const adicionarParticipante = mutation({
args: {
chamadaId: v.id('chamadas'),
usuarioId: v.id('usuarios')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error('Não autenticado');
const chamada = await ctx.db.get(args.chamadaId);
if (!chamada) throw new Error('Chamada não encontrada');
// Verificar se usuário já participa
if (chamada.participantes.includes(args.usuarioId)) {
return null;
}
// Verificar se usuário participa da conversa
const participa = await verificarParticipanteConversa(ctx, chamada.conversaId, args.usuarioId);
if (!participa) {
throw new Error('Usuário não participa desta conversa');
}
// Atualizar participantes
const novosParticipantes = [...chamada.participantes, args.usuarioId];
// Atualizar configurações
const configParticipantes = chamada.configuracoes?.participantesConfig || [];
const novaConfig = [...configParticipantes, {
usuarioId: args.usuarioId,
audioHabilitado: chamada.configuracoes?.audioHabilitado ?? true,
videoHabilitado: chamada.configuracoes?.videoHabilitado ?? (chamada.tipo === 'video'),
forcadoPeloAnfitriao: false
}];
await ctx.db.patch(args.chamadaId, {
participantes: novosParticipantes,
configuracoes: {
...(chamada.configuracoes || {
audioHabilitado: true,
videoHabilitado: chamada.tipo === 'video'
}),
participantesConfig: novaConfig
}
});
return null;
}
});
/**
* Remover participante da chamada (apenas anfitrião)
*/
export const removerParticipante = mutation({
args: {
chamadaId: v.id('chamadas'),
usuarioId: v.id('usuarios')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error('Não autenticado');
const chamada = await ctx.db.get(args.chamadaId);
if (!chamada) throw new Error('Chamada não encontrada');
// Apenas anfitrião pode remover participantes
const ehAnfitriao = await verificarAnfitriao(ctx, args.chamadaId, usuarioAtual._id);
if (!ehAnfitriao) {
throw new Error('Apenas o anfitrião pode remover participantes');
}
// Não pode remover o anfitrião
if (args.usuarioId === chamada.criadoPor) {
throw new Error('Não é possível remover o anfitrião');
}
// Atualizar participantes
const novosParticipantes = chamada.participantes.filter((id) => id !== args.usuarioId);
// Atualizar configurações
const configParticipantes = chamada.configuracoes?.participantesConfig || [];
const novaConfig = configParticipantes.filter((config) => config.usuarioId !== args.usuarioId);
await ctx.db.patch(args.chamadaId, {
participantes: novosParticipantes,
configuracoes: {
...(chamada.configuracoes || {
audioHabilitado: true,
videoHabilitado: chamada.tipo === 'video'
}),
participantesConfig: novaConfig
}
});
return null;
}
});
/**
* Toggle áudio/vídeo de participante (anfitrião controla)
*/
export const toggleAudioVideoParticipante = mutation({
args: {
chamadaId: v.id('chamadas'),
participanteId: v.id('usuarios'),
tipo: v.union(v.literal('audio'), v.literal('video')),
habilitado: v.boolean()
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error('Não autenticado');
const chamada = await ctx.db.get(args.chamadaId);
if (!chamada) throw new Error('Chamada não encontrada');
// Apenas anfitrião pode controlar outros participantes
const ehAnfitriao = await verificarAnfitriao(ctx, args.chamadaId, usuarioAtual._id);
const ehOProprioParticipante = args.participanteId === usuarioAtual._id;
if (!ehAnfitriao && !ehOProprioParticipante) {
throw new Error('Apenas o anfitrião pode controlar outros participantes');
}
// Atualizar configurações do participante
const configParticipantes = chamada.configuracoes?.participantesConfig || [];
const participanteIndex = configParticipantes.findIndex(
(config) => config.usuarioId === args.participanteId
);
if (participanteIndex === -1) {
// Adicionar configuração se não existir
configParticipantes.push({
usuarioId: args.participanteId,
audioHabilitado: args.tipo === 'audio' ? args.habilitado : (chamada.configuracoes?.audioHabilitado ?? true),
videoHabilitado: args.tipo === 'video' ? args.habilitado : (chamada.configuracoes?.videoHabilitado ?? (chamada.tipo === 'video')),
forcadoPeloAnfitriao: !ehOProprioParticipante && args.habilitado === false
});
} else {
// Atualizar configuração existente
configParticipantes[participanteIndex] = {
...configParticipantes[participanteIndex],
audioHabilitado: args.tipo === 'audio' ? args.habilitado : configParticipantes[participanteIndex].audioHabilitado,
videoHabilitado: args.tipo === 'video' ? args.habilitado : configParticipantes[participanteIndex].videoHabilitado,
forcadoPeloAnfitriao: !ehOProprioParticipante && !args.habilitado ? true : configParticipantes[participanteIndex].forcadoPeloAnfitriao
};
}
await ctx.db.patch(args.chamadaId, {
configuracoes: {
...(chamada.configuracoes || {
audioHabilitado: true,
videoHabilitado: chamada.tipo === 'video'
}),
participantesConfig: configParticipantes
}
});
return null;
}
});
/**
* Iniciar gravação (apenas anfitrião)
*/
export const iniciarGravacao = mutation({
args: {
chamadaId: v.id('chamadas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error('Não autenticado');
const chamada = await ctx.db.get(args.chamadaId);
if (!chamada) throw new Error('Chamada não encontrada');
// Apenas anfitrião pode iniciar gravação
const ehAnfitriao = await verificarAnfitriao(ctx, args.chamadaId, usuarioAtual._id);
if (!ehAnfitriao) {
throw new Error('Apenas o anfitrião pode iniciar a gravação');
}
// Se já estiver gravando, retornar
if (chamada.gravando) {
return null;
}
// Atualizar status de gravação
await ctx.db.patch(args.chamadaId, {
gravando: true,
gravacaoIniciadaPor: usuarioAtual._id,
gravacaoIniciadaEm: Date.now()
});
return null;
}
});
/**
* Finalizar gravação (apenas anfitrião)
*/
export const finalizarGravacao = mutation({
args: {
chamadaId: v.id('chamadas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error('Não autenticado');
const chamada = await ctx.db.get(args.chamadaId);
if (!chamada) throw new Error('Chamada não encontrada');
// Apenas anfitrião pode finalizar gravação
const ehAnfitriao = await verificarAnfitriao(ctx, args.chamadaId, usuarioAtual._id);
if (!ehAnfitriao) {
throw new Error('Apenas o anfitrião pode finalizar a gravação');
}
// Se não estiver gravando, retornar
if (!chamada.gravando) {
return null;
}
// Atualizar status de gravação
await ctx.db.patch(args.chamadaId, {
gravando: false,
gravacaoFinalizadaEm: Date.now()
});
return null;
}
});
// ========== QUERIES ==========
/**
* Obter chamada ativa de uma conversa
*/
export const obterChamadaAtiva = query({
args: {
conversaId: v.id('conversas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return null;
// Verificar se usuário participa da conversa
const participa = await verificarParticipanteConversa(ctx, args.conversaId, usuarioAtual._id);
if (!participa) return null;
// Buscar chamada ativa
const chamadasAtivas = await ctx.db
.query('chamadas')
.withIndex('by_conversa_ativa', (q) => q.eq('conversaId', args.conversaId))
.filter((q) =>
q.or(
q.eq(q.field('status'), 'aguardando'),
q.eq(q.field('status'), 'em_andamento')
)
)
.collect();
if (chamadasAtivas.length === 0) {
return null;
}
// Retornar a chamada mais recente
return chamadasAtivas.sort((a, b) => (b.criadoEm || 0) - (a.criadoEm || 0))[0];
}
});
/**
* Listar histórico de chamadas de uma conversa
*/
export const listarChamadas = query({
args: {
conversaId: v.id('conversas'),
limit: v.optional(v.number())
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return [];
// Verificar se usuário participa da conversa
const participa = await verificarParticipanteConversa(ctx, args.conversaId, usuarioAtual._id);
if (!participa) return [];
// Buscar chamadas
const chamadas = await ctx.db
.query('chamadas')
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId))
.order('desc')
.take(args.limit || 50);
return chamadas;
}
});
/**
* Verificar se usuário é anfitrião da chamada
*/
export const verificarAnfitriao = query({
args: {
chamadaId: v.id('chamadas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return false;
return await verificarAnfitriao(ctx, args.chamadaId, usuarioAtual._id);
}
});
/**
* Obter informações detalhadas da chamada
*/
export const obterChamada = query({
args: {
chamadaId: v.id('chamadas')
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return null;
const chamada = await ctx.db.get(args.chamadaId);
if (!chamada) return null;
// Verificar se usuário participa da chamada
if (!chamada.participantes.includes(usuarioAtual._id)) {
return null;
}
return chamada;
}
});

View File

@@ -827,6 +827,44 @@ export default defineSchema({
.index("by_conversa_usuario", ["conversaId", "usuarioId"]) .index("by_conversa_usuario", ["conversaId", "usuarioId"])
.index("by_usuario", ["usuarioId"]), .index("by_usuario", ["usuarioId"]),
// Sistema de Chamadas de Áudio/Vídeo
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"]),
notificacoes: defineTable({ notificacoes: defineTable({
usuarioId: v.id("usuarios"), usuarioId: v.id("usuarios"),
tipo: v.union( tipo: v.union(