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,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 SalaReuniaoManager from './SalaReuniaoManager.svelte';
import { getAvatarUrl } from '$lib/utils/avatarGenerator';
import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import {
Bell,
X,
ArrowLeft,
LogOut,
MoreVertical,
Users,
Clock,
XCircle,
Phone,
Video
} from 'lucide-svelte';
interface Props {
conversaId: string;
@@ -26,11 +39,20 @@
let showSalaManager = $state(false);
let showAdminMenu = $state(false);
let showNotificacaoModal = $state(false);
let iniciandoChamada = $state(false);
// Importação dinâmica do CallWindow apenas no cliente
let CallWindowComponent: any = $state(null);
const chamadaAtual = $derived(chamadaAtivaQuery?.data);
const conversas = useQuery(api.chat.listarConversas, {});
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, {
conversaId: conversaId as Id<'conversas'>
});
const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, {
conversaId: conversaId as Id<'conversas'>
});
const conversa = $derived(() => {
console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId);
@@ -115,6 +137,54 @@
alert(errorMessage);
}
}
// Funções para chamadas
async function iniciarChamada(tipo: 'audio' | 'video'): Promise<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>
<div class="flex h-full flex-col" onclick={() => (showAdminMenu = false)}>
@@ -233,6 +303,36 @@
<!-- Botões de ação -->
<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) -->
{#if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
<button
@@ -400,6 +500,21 @@
/>
{/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 -->
{#if showNotificacaoModal && conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
<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;
}
}