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}