feat: implement audio/video call functionality in chat

- Added a new schema for managing audio/video calls, including fields for call type, room name, and participant management.
- Enhanced ChatWindow component to support initiating audio and video calls with dynamic loading of the CallWindow component.
- Updated package dependencies to include 'lib-jitsi-meet' for call handling.
- Refactored existing code to accommodate new call features and improve user experience.
This commit is contained in:
2025-11-21 13:17:44 -03:00
parent bc1e08914b
commit 2792424454
15 changed files with 3986 additions and 3 deletions

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>