Call audio video jitsi #36

Merged
deyvisonwanderley merged 7 commits from call-audio-video-jitsi into master 2025-11-21 22:54:11 +00:00
63 changed files with 5731 additions and 5690 deletions
Showing only changes of commit 8fc3cf08c4 - Show all commits

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <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 { interface Props {
audioHabilitado: boolean; audioHabilitado: boolean;
@@ -101,7 +101,7 @@
{#if gravando} {#if gravando}
<Square class="h-4 w-4" /> <Square class="h-4 w-4" />
{:else} {:else}
<Record class="h-4 w-4" /> <Radio class="h-4 w-4 fill-current" />
{/if} {/if}
</button> </button>
{/if} {/if}
@@ -130,3 +130,4 @@
</div> </div>
</div> </div>

View File

@@ -6,8 +6,70 @@
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { X, GripVertical, GripHorizontal } from 'lucide-svelte'; 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 // 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 CallControls from './CallControls.svelte';
import CallSettings from './CallSettings.svelte'; import CallSettings from './CallSettings.svelte';
@@ -28,10 +90,11 @@
atualizarDispositivos, atualizarDispositivos,
setJitsiApi, setJitsiApi,
setStreamLocal, setStreamLocal,
finalizarChamada as finalizarChamadaStore finalizarChamada as finalizarChamadaStore,
inicializarChamada
} from '$lib/stores/callStore'; } 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 { GravadorMedia, gerarNomeArquivo, salvarGravacao } from '$lib/utils/mediaRecorder';
import { import {
criarDragHandler, criarDragHandler,
@@ -73,12 +136,13 @@
let duracaoTimer: ReturnType<typeof setInterval> | null = $state(null); let duracaoTimer: ReturnType<typeof setInterval> | null = $state(null);
let gravador: GravadorMedia | null = $state(null); let gravador: GravadorMedia | null = $state(null);
let jitsiConnection: any = $state(null); let jitsiConnection: JitsiConnection | null = $state(null);
let jitsiConference: any = $state(null); let jitsiConference: JitsiConference | null = $state(null);
// Queries // Queries
const chamadaQuery = useQuery(api.chamadas.obterChamada, { chamadaId }); const chamadaQuery = useQuery(api.chamadas.obterChamada, { chamadaId });
const chamada = $derived(chamadaQuery?.data); const chamada = $derived(chamadaQuery?.data);
const meuPerfil = useQuery(api.auth.getCurrentUser, {});
// Estado derivado do store // Estado derivado do store
const estadoChamada = $derived(get(callState)); const estadoChamada = $derived(get(callState));
@@ -92,13 +156,41 @@
try { try {
const module = await import('lib-jitsi-meet'); 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) { } catch (error) {
console.error('Erro ao carregar lib-jitsi-meet:', error); console.error('Erro ao carregar lib-jitsi-meet:', error);
alert('Erro ao carregar biblioteca de vídeo'); 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 // Inicializar Jitsi
async function inicializarJitsi(): Promise<void> { async function inicializarJitsi(): Promise<void> {
if (!browser || !JitsiMeetJS) { if (!browser || !JitsiMeetJS) {
@@ -112,7 +204,7 @@
try { try {
const config = configJitsi(); const config = configJitsi();
const options: any = { const options: Record<string, unknown> = {
hosts: { hosts: {
domain: config.domain, domain: config.domain,
muc: `conference.${config.domain}` muc: `conference.${config.domain}`
@@ -123,9 +215,10 @@
const connection = new JitsiMeetJS.JitsiConnection(null, null, options); const connection = new JitsiMeetJS.JitsiConnection(null, null, options);
jitsiConnection = connection; jitsiConnection = connection;
setJitsiApi(connection);
// Eventos de conexão // Eventos de conexão
connection.addEventListener(JitsiMeetJS.events.connection.CONNECTION_ESTABLISHED, () => { connection.addEventListener(JitsiMeetJS.constants.events.connection.CONNECTION_ESTABLISHED, () => {
console.log('✅ Conexão estabelecida'); console.log('✅ Conexão estabelecida');
atualizarStatusConexao(true); atualizarStatusConexao(true);
@@ -133,13 +226,15 @@
client.mutation(api.chamadas.iniciarChamada, { chamadaId }); client.mutation(api.chamadas.iniciarChamada, { chamadaId });
// Criar conferência // Criar conferência
const conferenceOptions: any = { const estadoAtual = get(callState);
startAudioMuted: !estadoChamada.audioHabilitado, const conferenceOptions: Record<string, unknown> = {
startVideoMuted: !estadoChamada.videoHabilitado startAudioMuted: !estadoAtual.audioHabilitado,
startVideoMuted: !estadoAtual.videoHabilitado
}; };
const conference = connection.initJitsiConference(roomName, conferenceOptions); const conference = connection.initJitsiConference(roomName, conferenceOptions);
jitsiConference = conference; jitsiConference = conference;
setJitsiApi(conference);
// Eventos da conferência // Eventos da conferência
configurarEventosConferencia(conference); configurarEventosConferencia(conference);
@@ -148,13 +243,13 @@
conference.join(); 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); console.error('❌ Falha na conexão:', error);
atualizarStatusConexao(false); atualizarStatusConexao(false);
alert('Erro ao conectar com o servidor de vídeo'); 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'); console.log('🔌 Conexão desconectada');
atualizarStatusConexao(false); atualizarStatusConexao(false);
}); });
@@ -169,62 +264,64 @@
// Configurar eventos da conferência // Configurar eventos da conferência
function configurarEventosConferencia( function configurarEventosConferencia(
conference: any conference: JitsiConference
): void { ): void {
if (!JitsiMeetJS) return; if (!JitsiMeetJS) return;
// Participante entrou // 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); console.log('👤 Participante entrou:', id, user);
// Atualizar lista de participantes // Atualizar lista de participantes
atualizarListaParticipantes(); atualizarListaParticipantes();
}); });
// Participante saiu // 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); console.log('👋 Participante saiu:', id);
atualizarListaParticipantes(); atualizarListaParticipantes();
}); });
// Áudio mutado/desmutado // Áudio mutado/desmutado
conference.on(JitsiMeetJS.events.conference.TRACK_MUTE_CHANGED, (track: any) => { conference.on(JitsiMeetJS.constants.events.conference.TRACK_MUTE_CHANGED, (track: unknown) => {
console.log('🎤 Mute mudou:', track); const jitsiTrack = track as JitsiTrack;
if (track.getType() === 'audio') { console.log('🎤 Mute mudou:', jitsiTrack);
const participanteId = track.getParticipantId(); if (jitsiTrack.getType() === 'audio') {
const isMuted = track.isMuted();
// Atualizar estado do participante // Atualizar estado do participante
atualizarListaParticipantes(); atualizarListaParticipantes();
} }
}); });
// Vídeo mutado/desmutado // Vídeo mutado/desmutado
conference.on(JitsiMeetJS.events.conference.TRACK_MUTE_CHANGED, (track: any) => { conference.on(JitsiMeetJS.constants.events.conference.TRACK_MUTE_CHANGED, (track: unknown) => {
if (track.getType() === 'video') { const jitsiTrack = track as JitsiTrack;
if (jitsiTrack.getType() === 'video') {
atualizarListaParticipantes(); atualizarListaParticipantes();
} }
}); });
// Novo track remoto // Novo track remoto
conference.on( conference.on(
JitsiMeetJS.events.conference.TRACK_ADDED, JitsiMeetJS.constants.events.conference.TRACK_ADDED,
(track: any) => { (track: unknown) => {
console.log('📹 Track adicionado:', track); const jitsiTrack = track as JitsiTrack;
adicionarTrackRemoto(track); console.log('📹 Track adicionado:', jitsiTrack);
adicionarTrackRemoto(jitsiTrack);
} }
); );
// Track removido // Track removido
conference.on( conference.on(
JitsiMeetJS.events.conference.TRACK_REMOVED, JitsiMeetJS.constants.events.conference.TRACK_REMOVED,
(track: any) => { (track: unknown) => {
console.log('📹 Track removido:', track); const jitsiTrack = track as JitsiTrack;
removerTrackRemoto(track); console.log('📹 Track removido:', jitsiTrack);
removerTrackRemoto(jitsiTrack);
} }
); );
} }
// Adicionar track remoto ao container // Adicionar track remoto ao container
function adicionarTrackRemoto(track: any): void { function adicionarTrackRemoto(track: JitsiTrack): void {
if (!videoContainer || track.getType() !== 'video') return; if (!videoContainer || track.getType() !== 'video') return;
const participantId = track.getParticipantId(); const participantId = track.getParticipantId();
@@ -241,7 +338,7 @@
} }
// Remover track remoto do container // Remover track remoto do container
function removerTrackRemoto(track: any): void { function removerTrackRemoto(track: JitsiTrack): void {
if (!videoContainer) return; if (!videoContainer) return;
const participantId = track.getParticipantId(); const participantId = track.getParticipantId();
const videoElement = document.getElementById(`remote-video-${participantId}`); const videoElement = document.getElementById(`remote-video-${participantId}`);
@@ -257,12 +354,15 @@
const participants = jitsiConference.getParticipants(); const participants = jitsiConference.getParticipants();
// Mapear participantes para o formato esperado // Mapear participantes para o formato esperado
// Isso pode ser expandido para buscar informações do backend // Isso pode ser expandido para buscar informações do backend
const participantesAtualizados = participants.map((p: any) => ({ const participantesAtualizados = Array.from(participants.values()).map((p: unknown) => {
usuarioId: p.getId() as Id<'usuarios'>, const participant = p as { getId(): string; getDisplayName(): string; isAudioMuted(): boolean; isVideoMuted(): boolean };
nome: p.getDisplayName() || 'Participante', return {
audioHabilitado: !p.isAudioMuted(), usuarioId: participant.getId() as Id<'usuarios'>,
videoHabilitado: !p.isVideoMuted() nome: participant.getDisplayName() || 'Participante',
})); audioHabilitado: !participant.isAudioMuted(),
videoHabilitado: !participant.isVideoMuted()
};
});
atualizarParticipantes(participantesAtualizados); atualizarParticipantes(participantesAtualizados);
} }
@@ -303,7 +403,7 @@
// Criar MediaStream com todos os tracks // Criar MediaStream com todos os tracks
const stream = new MediaStream(); const stream = new MediaStream();
localTracks.forEach((track: any) => { localTracks.forEach((track: JitsiTrack) => {
stream.addTrack(track.track); stream.addTrack(track.track);
}); });
@@ -475,7 +575,10 @@
onMount(async () => { onMount(async () => {
if (!browser) return; if (!browser) return;
// Carregar Jitsi primeiro // Inicializar store primeiro
inicializarStore();
// Carregar Jitsi
await carregarJitsi(); await carregarJitsi();
// Configurar janela flutuante // Configurar janela flutuante

View File

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

View File

@@ -9,9 +9,9 @@
import UserAvatar from './UserAvatar.svelte'; import UserAvatar from './UserAvatar.svelte';
import ScheduleMessageModal from './ScheduleMessageModal.svelte'; import ScheduleMessageModal from './ScheduleMessageModal.svelte';
import SalaReuniaoManager from './SalaReuniaoManager.svelte'; import SalaReuniaoManager from './SalaReuniaoManager.svelte';
import CallWindow from '../call/CallWindow.svelte';
import { getAvatarUrl } from '$lib/utils/avatarGenerator'; import { getAvatarUrl } from '$lib/utils/avatarGenerator';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { import {
Bell, Bell,
X, X,
@@ -40,19 +40,17 @@
let showAdminMenu = $state(false); let showAdminMenu = $state(false);
let showNotificacaoModal = $state(false); let showNotificacaoModal = $state(false);
let iniciandoChamada = $state(false); let iniciandoChamada = $state(false);
let chamadaAtiva = $state<Id<'chamadas'> | null>(null);
// Importação dinâmica do CallWindow apenas no cliente const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, {
let CallWindowComponent: any = $state(null); conversaId: conversaId as Id<'conversas'>
});
const chamadaAtual = $derived(chamadaAtivaQuery?.data); const chamadaAtual = $derived(chamadaAtivaQuery?.data);
const conversas = useQuery(api.chat.listarConversas, {}); const conversas = useQuery(api.chat.listarConversas, {});
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { const isAdmin = useQuery(api.chat.verificarSeEhAdmin, {
conversaId: conversaId as Id<'conversas'> conversaId: conversaId as Id<'conversas'>
}); });
const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, {
conversaId: conversaId as Id<'conversas'>
});
const conversa = $derived(() => { const conversa = $derived(() => {
console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId); console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId);
@@ -168,17 +166,6 @@
chamadaAtiva = null; 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 // Verificar se usuário é anfitrião da chamada atual
const meuPerfil = useQuery(api.auth.getCurrentUser, {}); const meuPerfil = useQuery(api.auth.getCurrentUser, {});
@@ -501,10 +488,9 @@
{/if} {/if}
<!-- Janela de Chamada --> <!-- Janela de Chamada -->
{#if browser && chamadaAtiva && chamadaAtual && CallWindowComponent} {#if browser && chamadaAtiva && chamadaAtual}
{@const Component = CallWindowComponent}
<div class="pointer-events-none fixed inset-0 z-[9999]"> <div class="pointer-events-none fixed inset-0 z-[9999]">
<Component <CallWindow
chamadaId={chamadaAtiva} chamadaId={chamadaAtiva}
conversaId={conversaId as Id<'conversas'>} conversaId={conversaId as Id<'conversas'>}
tipo={chamadaAtual.tipo} tipo={chamadaAtual.tipo}

View File

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