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,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}