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:
132
apps/web/src/lib/components/call/CallControls.svelte
Normal file
132
apps/web/src/lib/components/call/CallControls.svelte
Normal 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>
|
||||
|
||||
327
apps/web/src/lib/components/call/CallSettings.svelte
Normal file
327
apps/web/src/lib/components/call/CallSettings.svelte
Normal 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}
|
||||
|
||||
670
apps/web/src/lib/components/call/CallWindow.svelte
Normal file
670
apps/web/src/lib/components/call/CallWindow.svelte
Normal 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>
|
||||
|
||||
112
apps/web/src/lib/components/call/HostControls.svelte
Normal file
112
apps/web/src/lib/components/call/HostControls.svelte
Normal 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>
|
||||
|
||||
23
apps/web/src/lib/components/call/RecordingIndicator.svelte
Normal file
23
apps/web/src/lib/components/call/RecordingIndicator.svelte
Normal 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}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user