feat: enhance call functionality and improve type safety

- Updated CallControls to replace the Record icon with Radio for better representation during recording states.
- Refactored CallWindow to introduce Jitsi connection and conference interfaces, improving type safety and clarity in handling Jitsi events.
- Streamlined event handling for connection and conference states, ensuring robust management of audio/video calls.
- Enhanced ChatWindow to directly import CallWindow, simplifying the component structure and improving call handling logic.
- Improved utility functions for window management to ensure compatibility with server-side rendering.
This commit is contained in:
2025-11-21 16:21:01 -03:00
parent c5e83464ba
commit 8fc3cf08c4
7 changed files with 194 additions and 70 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Mic, MicOff, Video, VideoOff, Record, Square, Settings, PhoneOff, Circle } from 'lucide-svelte';
import { Mic, MicOff, Video, VideoOff, Radio, Square, Settings, PhoneOff, Circle } from 'lucide-svelte';
interface Props {
audioHabilitado: boolean;
@@ -101,7 +101,7 @@
{#if gravando}
<Square class="h-4 w-4" />
{:else}
<Record class="h-4 w-4" />
<Radio class="h-4 w-4 fill-current" />
{/if}
</button>
{/if}
@@ -130,3 +130,4 @@
</div>
</div>

View File

@@ -6,8 +6,70 @@
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { X, GripVertical, GripHorizontal } from 'lucide-svelte';
// Tipos para Jitsi (evitando 'any')
interface JitsiConnection {
connect(): void;
disconnect(): void;
addEventListener(event: string, handler: (data?: unknown) => void): void;
initJitsiConference(roomName: string, options: Record<string, unknown>): JitsiConference;
}
interface JitsiConference {
join(): void;
leave(): void;
on(event: string, handler: (...args: unknown[]) => void): void;
removeEventListener(event: string, handler: (...args: unknown[]) => void): void;
muteAudio(): void;
unmuteAudio(): void;
muteVideo(): void;
unmuteVideo(): void;
getParticipants(): Map<string, unknown>;
getLocalTracks(): JitsiTrack[];
setDisplayName(name: string): void;
}
interface JitsiTrack {
getType(): 'audio' | 'video';
isMuted(): boolean;
mute(): Promise<void>;
unmute(): Promise<void>;
attach(element: HTMLElement): void;
detach(element: HTMLElement): void;
dispose(): Promise<void>;
getParticipantId(): string;
track: MediaStreamTrack;
}
interface JitsiMeetJSLib {
JitsiConnection: new (appId: string | null, token: string | null, options: Record<string, unknown>) => JitsiConnection;
constants: {
events: {
connection: {
CONNECTION_ESTABLISHED: string;
CONNECTION_FAILED: string;
CONNECTION_DISCONNECTED: string;
};
conference: {
USER_JOINED: string;
USER_LEFT: string;
TRACK_ADDED: string;
TRACK_REMOVED: string;
TRACK_MUTE_CHANGED: string;
CONFERENCE_JOINED: string;
CONFERENCE_LEFT: string;
};
};
logLevels: {
ERROR: number;
};
};
init(options: Record<string, unknown>): void;
setLogLevel(level: number): void;
createLocalTracks(options: Record<string, unknown>): Promise<JitsiTrack[]>;
}
// Importação dinâmica do Jitsi apenas no cliente
let JitsiMeetJS: any = $state(null);
let JitsiMeetJS: JitsiMeetJSLib | null = $state(null);
import CallControls from './CallControls.svelte';
import CallSettings from './CallSettings.svelte';
@@ -28,10 +90,11 @@
atualizarDispositivos,
setJitsiApi,
setStreamLocal,
finalizarChamada as finalizarChamadaStore
finalizarChamada as finalizarChamadaStore,
inicializarChamada
} from '$lib/stores/callStore';
import { obterConfiguracaoJitsi, gerarRoomName, obterUrlSala } from '$lib/utils/jitsi';
import { obterConfiguracaoJitsi } from '$lib/utils/jitsi';
import { GravadorMedia, gerarNomeArquivo, salvarGravacao } from '$lib/utils/mediaRecorder';
import {
criarDragHandler,
@@ -73,12 +136,13 @@
let duracaoTimer: ReturnType<typeof setInterval> | null = $state(null);
let gravador: GravadorMedia | null = $state(null);
let jitsiConnection: any = $state(null);
let jitsiConference: any = $state(null);
let jitsiConnection: JitsiConnection | null = $state(null);
let jitsiConference: JitsiConference | null = $state(null);
// Queries
const chamadaQuery = useQuery(api.chamadas.obterChamada, { chamadaId });
const chamada = $derived(chamadaQuery?.data);
const meuPerfil = useQuery(api.auth.getCurrentUser, {});
// Estado derivado do store
const estadoChamada = $derived(get(callState));
@@ -92,13 +156,41 @@
try {
const module = await import('lib-jitsi-meet');
JitsiMeetJS = module.default;
JitsiMeetJS = module.default as unknown as JitsiMeetJSLib;
// Inicializar Jitsi
JitsiMeetJS.init({
disableAudioLevels: true,
disableSimulcast: false,
enableWindowOnErrorHandler: true
});
JitsiMeetJS.setLogLevel(JitsiMeetJS.constants.logLevels.ERROR);
} catch (error) {
console.error('Erro ao carregar lib-jitsi-meet:', error);
alert('Erro ao carregar biblioteca de vídeo');
}
}
// Inicializar store
function inicializarStore(): void {
if (chamada && meuPerfil?.data) {
inicializarChamada(
chamadaId,
conversaId,
tipo,
roomName,
ehAnfitriao,
chamada.participantes.map(pId => ({
usuarioId: pId,
nome: 'Participante', // Será atualizado depois
audioHabilitado: true,
videoHabilitado: tipo === 'video'
}))
);
}
}
// Inicializar Jitsi
async function inicializarJitsi(): Promise<void> {
if (!browser || !JitsiMeetJS) {
@@ -112,7 +204,7 @@
try {
const config = configJitsi();
const options: any = {
const options: Record<string, unknown> = {
hosts: {
domain: config.domain,
muc: `conference.${config.domain}`
@@ -123,9 +215,10 @@
const connection = new JitsiMeetJS.JitsiConnection(null, null, options);
jitsiConnection = connection;
setJitsiApi(connection);
// Eventos de conexão
connection.addEventListener(JitsiMeetJS.events.connection.CONNECTION_ESTABLISHED, () => {
connection.addEventListener(JitsiMeetJS.constants.events.connection.CONNECTION_ESTABLISHED, () => {
console.log('✅ Conexão estabelecida');
atualizarStatusConexao(true);
@@ -133,13 +226,15 @@
client.mutation(api.chamadas.iniciarChamada, { chamadaId });
// Criar conferência
const conferenceOptions: any = {
startAudioMuted: !estadoChamada.audioHabilitado,
startVideoMuted: !estadoChamada.videoHabilitado
const estadoAtual = get(callState);
const conferenceOptions: Record<string, unknown> = {
startAudioMuted: !estadoAtual.audioHabilitado,
startVideoMuted: !estadoAtual.videoHabilitado
};
const conference = connection.initJitsiConference(roomName, conferenceOptions);
jitsiConference = conference;
setJitsiApi(conference);
// Eventos da conferência
configurarEventosConferencia(conference);
@@ -148,13 +243,13 @@
conference.join();
});
connection.addEventListener(JitsiMeetJS.events.connection.CONNECTION_FAILED, (error) => {
connection.addEventListener(JitsiMeetJS.constants.events.connection.CONNECTION_FAILED, (error: unknown) => {
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, () => {
connection.addEventListener(JitsiMeetJS.constants.events.connection.CONNECTION_DISCONNECTED, () => {
console.log('🔌 Conexão desconectada');
atualizarStatusConexao(false);
});
@@ -169,62 +264,64 @@
// Configurar eventos da conferência
function configurarEventosConferencia(
conference: any
conference: JitsiConference
): void {
if (!JitsiMeetJS) return;
// Participante entrou
conference.on(JitsiMeetJS.events.conference.USER_JOINED, (id: string, user: any) => {
conference.on(JitsiMeetJS.constants.events.conference.USER_JOINED, (id: unknown, user: unknown) => {
console.log('👤 Participante entrou:', id, user);
// Atualizar lista de participantes
atualizarListaParticipantes();
});
// Participante saiu
conference.on(JitsiMeetJS.events.conference.USER_LEFT, (id: string) => {
conference.on(JitsiMeetJS.constants.events.conference.USER_LEFT, (id: unknown) => {
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();
conference.on(JitsiMeetJS.constants.events.conference.TRACK_MUTE_CHANGED, (track: unknown) => {
const jitsiTrack = track as JitsiTrack;
console.log('🎤 Mute mudou:', jitsiTrack);
if (jitsiTrack.getType() === 'audio') {
// Atualizar estado do participante
atualizarListaParticipantes();
}
});
// Vídeo mutado/desmutado
conference.on(JitsiMeetJS.events.conference.TRACK_MUTE_CHANGED, (track: any) => {
if (track.getType() === 'video') {
conference.on(JitsiMeetJS.constants.events.conference.TRACK_MUTE_CHANGED, (track: unknown) => {
const jitsiTrack = track as JitsiTrack;
if (jitsiTrack.getType() === 'video') {
atualizarListaParticipantes();
}
});
// Novo track remoto
conference.on(
JitsiMeetJS.events.conference.TRACK_ADDED,
(track: any) => {
console.log('📹 Track adicionado:', track);
adicionarTrackRemoto(track);
JitsiMeetJS.constants.events.conference.TRACK_ADDED,
(track: unknown) => {
const jitsiTrack = track as JitsiTrack;
console.log('📹 Track adicionado:', jitsiTrack);
adicionarTrackRemoto(jitsiTrack);
}
);
// Track removido
conference.on(
JitsiMeetJS.events.conference.TRACK_REMOVED,
(track: any) => {
console.log('📹 Track removido:', track);
removerTrackRemoto(track);
JitsiMeetJS.constants.events.conference.TRACK_REMOVED,
(track: unknown) => {
const jitsiTrack = track as JitsiTrack;
console.log('📹 Track removido:', jitsiTrack);
removerTrackRemoto(jitsiTrack);
}
);
}
// Adicionar track remoto ao container
function adicionarTrackRemoto(track: any): void {
function adicionarTrackRemoto(track: JitsiTrack): void {
if (!videoContainer || track.getType() !== 'video') return;
const participantId = track.getParticipantId();
@@ -241,7 +338,7 @@
}
// Remover track remoto do container
function removerTrackRemoto(track: any): void {
function removerTrackRemoto(track: JitsiTrack): void {
if (!videoContainer) return;
const participantId = track.getParticipantId();
const videoElement = document.getElementById(`remote-video-${participantId}`);
@@ -257,12 +354,15 @@
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()
}));
const participantesAtualizados = Array.from(participants.values()).map((p: unknown) => {
const participant = p as { getId(): string; getDisplayName(): string; isAudioMuted(): boolean; isVideoMuted(): boolean };
return {
usuarioId: participant.getId() as Id<'usuarios'>,
nome: participant.getDisplayName() || 'Participante',
audioHabilitado: !participant.isAudioMuted(),
videoHabilitado: !participant.isVideoMuted()
};
});
atualizarParticipantes(participantesAtualizados);
}
@@ -303,7 +403,7 @@
// Criar MediaStream com todos os tracks
const stream = new MediaStream();
localTracks.forEach((track: any) => {
localTracks.forEach((track: JitsiTrack) => {
stream.addTrack(track.track);
});
@@ -475,7 +575,10 @@
onMount(async () => {
if (!browser) return;
// Carregar Jitsi primeiro
// Inicializar store primeiro
inicializarStore();
// Carregar Jitsi
await carregarJitsi();
// Configurar janela flutuante

View File

@@ -110,3 +110,4 @@
</div>
</div>

View File

@@ -9,9 +9,9 @@
import UserAvatar from './UserAvatar.svelte';
import ScheduleMessageModal from './ScheduleMessageModal.svelte';
import SalaReuniaoManager from './SalaReuniaoManager.svelte';
import CallWindow from '../call/CallWindow.svelte';
import { getAvatarUrl } from '$lib/utils/avatarGenerator';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import {
Bell,
X,
@@ -40,19 +40,17 @@
let showAdminMenu = $state(false);
let showNotificacaoModal = $state(false);
let iniciandoChamada = $state(false);
let chamadaAtiva = $state<Id<'chamadas'> | null>(null);
// Importação dinâmica do CallWindow apenas no cliente
let CallWindowComponent: any = $state(null);
const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, {
conversaId: conversaId as Id<'conversas'>
});
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);
@@ -168,17 +166,6 @@
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, {});
@@ -501,10 +488,9 @@
{/if}
<!-- Janela de Chamada -->
{#if browser && chamadaAtiva && chamadaAtual && CallWindowComponent}
{@const Component = CallWindowComponent}
{#if browser && chamadaAtiva && chamadaAtual}
<div class="pointer-events-none fixed inset-0 z-[9999]">
<Component
<CallWindow
chamadaId={chamadaAtiva}
conversaId={conversaId as Id<'conversas'>}
tipo={chamadaAtual.tipo}

View File

@@ -16,12 +16,24 @@ export interface LimitesJanela {
maxHeight?: number;
}
const DEFAULT_LIMITS: LimitesJanela = {
minWidth: 400,
minHeight: 300,
maxWidth: window.innerWidth,
maxHeight: window.innerHeight
};
function getDefaultLimits(): LimitesJanela {
if (typeof window === 'undefined') {
return {
minWidth: 400,
minHeight: 300,
maxWidth: 1920,
maxHeight: 1080
};
}
return {
minWidth: 400,
minHeight: 300,
maxWidth: window.innerWidth,
maxHeight: window.innerHeight
};
}
const DEFAULT_LIMITS: LimitesJanela = getDefaultLimits();
/**
* Salvar posição da janela no localStorage
@@ -30,6 +42,9 @@ export function salvarPosicaoJanela(
id: string,
posicao: PosicaoJanela
): void {
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
return;
}
try {
const key = `floating-window-${id}`;
localStorage.setItem(key, JSON.stringify(posicao));
@@ -42,6 +57,9 @@ export function salvarPosicaoJanela(
* Restaurar posição da janela do localStorage
*/
export function restaurarPosicaoJanela(id: string): PosicaoJanela | null {
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
return null;
}
try {
const key = `floating-window-${id}`;
const saved = localStorage.getItem(key);
@@ -75,6 +93,14 @@ export function obterPosicaoInicial(
width: number = 800,
height: number = 600
): PosicaoJanela {
if (typeof window === 'undefined') {
return {
x: 100,
y: 100,
width,
height
};
}
return {
x: (window.innerWidth - width) / 2,
y: (window.innerHeight - height) / 2,
@@ -124,6 +150,7 @@ export function criarDragHandler(
let newY = initialY + deltaY;
// Limitar movimento dentro da tela
if (typeof window === 'undefined') return;
const maxX = window.innerWidth - element.offsetWidth;
const maxY = window.innerHeight - element.offsetHeight;
@@ -173,6 +200,7 @@ export function criarDragHandler(
let newX = initialX + deltaX;
let newY = initialY + deltaY;
if (typeof window === 'undefined') return;
const maxX = window.innerWidth - element.offsetWidth;
const maxY = window.innerHeight - element.offsetHeight;
@@ -306,6 +334,8 @@ export function criarResizeHandler(
newTop = startTop + deltaY;
}
if (typeof window === 'undefined') return;
// Aplicar limites
const maxWidth = limites.maxWidth || window.innerWidth - newLeft;
const maxHeight = limites.maxHeight || window.innerHeight - newTop;
@@ -364,3 +394,4 @@ export function criarResizeHandler(
};
}

View File

@@ -329,3 +329,4 @@ export class GravadorMedia {
}
}

View File

@@ -576,3 +576,4 @@ export const obterChamada = query({
}
});