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:
366
apps/web/src/lib/utils/floatingWindow.ts
Normal file
366
apps/web/src/lib/utils/floatingWindow.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Utilitários para criar janela flutuante redimensionável e arrastável
|
||||
*/
|
||||
|
||||
export interface PosicaoJanela {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface LimitesJanela {
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMITS: LimitesJanela = {
|
||||
minWidth: 400,
|
||||
minHeight: 300,
|
||||
maxWidth: window.innerWidth,
|
||||
maxHeight: window.innerHeight
|
||||
};
|
||||
|
||||
/**
|
||||
* Salvar posição da janela no localStorage
|
||||
*/
|
||||
export function salvarPosicaoJanela(
|
||||
id: string,
|
||||
posicao: PosicaoJanela
|
||||
): void {
|
||||
try {
|
||||
const key = `floating-window-${id}`;
|
||||
localStorage.setItem(key, JSON.stringify(posicao));
|
||||
} catch (error) {
|
||||
console.warn('Erro ao salvar posição da janela:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaurar posição da janela do localStorage
|
||||
*/
|
||||
export function restaurarPosicaoJanela(id: string): PosicaoJanela | null {
|
||||
try {
|
||||
const key = `floating-window-${id}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (!saved) return null;
|
||||
|
||||
const posicao = JSON.parse(saved) as PosicaoJanela;
|
||||
|
||||
// Validar se a posição ainda é válida (dentro da tela)
|
||||
if (
|
||||
posicao.x >= 0 &&
|
||||
posicao.y >= 0 &&
|
||||
posicao.x + posicao.width <= window.innerWidth + 100 &&
|
||||
posicao.y + posicao.height <= window.innerHeight + 100 &&
|
||||
posicao.width >= DEFAULT_LIMITS.minWidth &&
|
||||
posicao.height >= DEFAULT_LIMITS.minHeight
|
||||
) {
|
||||
return posicao;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn('Erro ao restaurar posição da janela:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter posição inicial da janela (centralizada)
|
||||
*/
|
||||
export function obterPosicaoInicial(
|
||||
width: number = 800,
|
||||
height: number = 600
|
||||
): PosicaoJanela {
|
||||
return {
|
||||
x: (window.innerWidth - width) / 2,
|
||||
y: (window.innerHeight - height) / 2,
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Criar handler de arrastar para janela
|
||||
*/
|
||||
export function criarDragHandler(
|
||||
element: HTMLElement,
|
||||
handle: HTMLElement,
|
||||
onPositionChange?: (x: number, y: number) => void
|
||||
): () => void {
|
||||
let isDragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let initialX = 0;
|
||||
let initialY = 0;
|
||||
|
||||
function handleMouseDown(e: MouseEvent): void {
|
||||
if (e.button !== 0) return; // Apenas botão esquerdo
|
||||
|
||||
isDragging = true;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
initialX = rect.left;
|
||||
initialY = rect.top;
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent): void {
|
||||
if (!isDragging) return;
|
||||
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
|
||||
let newX = initialX + deltaX;
|
||||
let newY = initialY + deltaY;
|
||||
|
||||
// Limitar movimento dentro da tela
|
||||
const maxX = window.innerWidth - element.offsetWidth;
|
||||
const maxY = window.innerHeight - element.offsetHeight;
|
||||
|
||||
newX = Math.max(0, Math.min(newX, maxX));
|
||||
newY = Math.max(0, Math.min(newY, maxY));
|
||||
|
||||
element.style.left = `${newX}px`;
|
||||
element.style.top = `${newY}px`;
|
||||
|
||||
if (onPositionChange) {
|
||||
onPositionChange(newX, newY);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp(): void {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
// Suporte para touch (mobile)
|
||||
function handleTouchStart(e: TouchEvent): void {
|
||||
if (e.touches.length !== 1) return;
|
||||
|
||||
isDragging = true;
|
||||
const touch = e.touches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
initialX = rect.left;
|
||||
initialY = rect.top;
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent): void {
|
||||
if (!isDragging || e.touches.length !== 1) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const deltaX = touch.clientX - startX;
|
||||
const deltaY = touch.clientY - startY;
|
||||
|
||||
let newX = initialX + deltaX;
|
||||
let newY = initialY + deltaY;
|
||||
|
||||
const maxX = window.innerWidth - element.offsetWidth;
|
||||
const maxY = window.innerHeight - element.offsetHeight;
|
||||
|
||||
newX = Math.max(0, Math.min(newX, maxX));
|
||||
newY = Math.max(0, Math.min(newY, maxY));
|
||||
|
||||
element.style.left = `${newX}px`;
|
||||
element.style.top = `${newY}px`;
|
||||
|
||||
if (onPositionChange) {
|
||||
onPositionChange(newX, newY);
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleTouchEnd(): void {
|
||||
isDragging = false;
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
}
|
||||
|
||||
handle.addEventListener('mousedown', handleMouseDown);
|
||||
handle.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||
|
||||
// Retornar função de cleanup
|
||||
return () => {
|
||||
handle.removeEventListener('mousedown', handleMouseDown);
|
||||
handle.removeEventListener('touchstart', handleTouchStart);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Criar handler de redimensionar para janela
|
||||
*/
|
||||
export function criarResizeHandler(
|
||||
element: HTMLElement,
|
||||
handles: HTMLElement[],
|
||||
limites: LimitesJanela = DEFAULT_LIMITS,
|
||||
onSizeChange?: (width: number, height: number) => void
|
||||
): () => void {
|
||||
let isResizing = false;
|
||||
let currentHandle: HTMLElement | null = null;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startWidth = 0;
|
||||
let startHeight = 0;
|
||||
let startLeft = 0;
|
||||
let startTop = 0;
|
||||
|
||||
function handleMouseDown(e: MouseEvent, handle: HTMLElement): void {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
isResizing = true;
|
||||
currentHandle = handle;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
startWidth = rect.width;
|
||||
startHeight = rect.height;
|
||||
startLeft = rect.left;
|
||||
startTop = rect.top;
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent): void {
|
||||
if (!isResizing || !currentHandle) return;
|
||||
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
|
||||
let newWidth = startWidth;
|
||||
let newHeight = startHeight;
|
||||
let newLeft = startLeft;
|
||||
let newTop = startTop;
|
||||
|
||||
// Determinar direção do resize baseado na classe do handle
|
||||
const classes = currentHandle.className;
|
||||
|
||||
// Right
|
||||
if (classes.includes('resize-right') || classes.includes('resize-e')) {
|
||||
newWidth = startWidth + deltaX;
|
||||
}
|
||||
|
||||
// Bottom
|
||||
if (classes.includes('resize-bottom') || classes.includes('resize-s')) {
|
||||
newHeight = startHeight + deltaY;
|
||||
}
|
||||
|
||||
// Left
|
||||
if (classes.includes('resize-left') || classes.includes('resize-w')) {
|
||||
newWidth = startWidth - deltaX;
|
||||
newLeft = startLeft + deltaX;
|
||||
}
|
||||
|
||||
// Top
|
||||
if (classes.includes('resize-top') || classes.includes('resize-n')) {
|
||||
newHeight = startHeight - deltaY;
|
||||
newTop = startTop + deltaY;
|
||||
}
|
||||
|
||||
// Corner handles
|
||||
if (classes.includes('resize-se')) {
|
||||
newWidth = startWidth + deltaX;
|
||||
newHeight = startHeight + deltaY;
|
||||
}
|
||||
if (classes.includes('resize-sw')) {
|
||||
newWidth = startWidth - deltaX;
|
||||
newHeight = startHeight + deltaY;
|
||||
newLeft = startLeft + deltaX;
|
||||
}
|
||||
if (classes.includes('resize-ne')) {
|
||||
newWidth = startWidth + deltaX;
|
||||
newHeight = startHeight - deltaY;
|
||||
newTop = startTop + deltaY;
|
||||
}
|
||||
if (classes.includes('resize-nw')) {
|
||||
newWidth = startWidth - deltaX;
|
||||
newHeight = startHeight - deltaY;
|
||||
newLeft = startLeft + deltaX;
|
||||
newTop = startTop + deltaY;
|
||||
}
|
||||
|
||||
// Aplicar limites
|
||||
const maxWidth = limites.maxWidth || window.innerWidth - newLeft;
|
||||
const maxHeight = limites.maxHeight || window.innerHeight - newTop;
|
||||
|
||||
newWidth = Math.max(limites.minWidth, Math.min(newWidth, maxWidth));
|
||||
newHeight = Math.max(limites.minHeight, Math.min(newHeight, maxHeight));
|
||||
|
||||
// Ajustar posição se necessário
|
||||
if (newLeft + newWidth > window.innerWidth) {
|
||||
newLeft = window.innerWidth - newWidth;
|
||||
}
|
||||
if (newTop + newHeight > window.innerHeight) {
|
||||
newTop = window.innerHeight - newHeight;
|
||||
}
|
||||
|
||||
if (newLeft < 0) {
|
||||
newLeft = 0;
|
||||
newWidth = Math.min(newWidth, window.innerWidth);
|
||||
}
|
||||
if (newTop < 0) {
|
||||
newTop = 0;
|
||||
newHeight = Math.min(newHeight, window.innerHeight);
|
||||
}
|
||||
|
||||
element.style.width = `${newWidth}px`;
|
||||
element.style.height = `${newHeight}px`;
|
||||
element.style.left = `${newLeft}px`;
|
||||
element.style.top = `${newTop}px`;
|
||||
|
||||
if (onSizeChange) {
|
||||
onSizeChange(newWidth, newHeight);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp(): void {
|
||||
isResizing = false;
|
||||
currentHandle = null;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
const cleanupFunctions: (() => void)[] = [];
|
||||
|
||||
// Adicionar listeners para cada handle
|
||||
for (const handle of handles) {
|
||||
const handler = (e: MouseEvent) => handleMouseDown(e, handle);
|
||||
handle.addEventListener('mousedown', handler);
|
||||
cleanupFunctions.push(() => handle.removeEventListener('mousedown', handler));
|
||||
}
|
||||
|
||||
// Retornar função de cleanup
|
||||
return () => {
|
||||
cleanupFunctions.forEach((cleanup) => cleanup());
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}
|
||||
|
||||
265
apps/web/src/lib/utils/jitsi.ts
Normal file
265
apps/web/src/lib/utils/jitsi.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Utilitários para integração com Jitsi Meet
|
||||
*/
|
||||
|
||||
export interface ConfiguracaoJitsi {
|
||||
domain: string;
|
||||
appId: string;
|
||||
roomPrefix: string;
|
||||
useHttps: boolean;
|
||||
}
|
||||
|
||||
export interface DispositivoMedia {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
kind: 'audioinput' | 'audiooutput' | 'videoinput';
|
||||
}
|
||||
|
||||
export interface DispositivosDisponiveis {
|
||||
microphones: DispositivoMedia[];
|
||||
speakers: DispositivoMedia[];
|
||||
cameras: DispositivoMedia[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter configuração do Jitsi baseada em variáveis de ambiente
|
||||
*/
|
||||
export function obterConfiguracaoJitsi(): ConfiguracaoJitsi {
|
||||
const domain = import.meta.env.VITE_JITSI_DOMAIN || 'localhost:8443';
|
||||
const appId = import.meta.env.VITE_JITSI_APP_ID || 'sgse-app';
|
||||
const roomPrefix = import.meta.env.VITE_JITSI_ROOM_PREFIX || 'sgse';
|
||||
const useHttps = import.meta.env.VITE_JITSI_USE_HTTPS === 'true';
|
||||
|
||||
return {
|
||||
domain,
|
||||
appId,
|
||||
roomPrefix,
|
||||
useHttps
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gerar nome único para a sala Jitsi
|
||||
*/
|
||||
export function gerarRoomName(conversaId: string, tipo: 'audio' | 'video'): string {
|
||||
const config = obterConfiguracaoJitsi();
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 9);
|
||||
const conversaHash = conversaId.replace(/[^a-zA-Z0-9]/g, '').substring(0, 10);
|
||||
|
||||
return `${config.roomPrefix}-${tipo}-${conversaHash}-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter URL completa da sala Jitsi
|
||||
*/
|
||||
export function obterUrlSala(roomName: string): string {
|
||||
const config = obterConfiguracaoJitsi();
|
||||
const protocol = config.useHttps ? 'https' : 'http';
|
||||
return `${protocol}://${config.domain}/${roomName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar se dispositivos de mídia estão disponíveis
|
||||
*/
|
||||
export async function validarDispositivos(): Promise<{
|
||||
microfoneDisponivel: boolean;
|
||||
cameraDisponivel: boolean;
|
||||
}> {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
microfoneDisponivel: false,
|
||||
cameraDisponivel: false
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
const microfoneDisponivel = devices.some(
|
||||
(device) => device.kind === 'audioinput'
|
||||
);
|
||||
const cameraDisponivel = devices.some(
|
||||
(device) => device.kind === 'videoinput'
|
||||
);
|
||||
|
||||
return {
|
||||
microfoneDisponivel,
|
||||
cameraDisponivel
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erro ao validar dispositivos:', error);
|
||||
return {
|
||||
microfoneDisponivel: false,
|
||||
cameraDisponivel: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Solicitar permissão de acesso aos dispositivos de mídia
|
||||
*/
|
||||
export async function solicitarPermissaoMidia(
|
||||
audio: boolean = true,
|
||||
video: boolean = false
|
||||
): Promise<MediaStream | null> {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio,
|
||||
video: video ? { facingMode: 'user' } : false
|
||||
});
|
||||
return stream;
|
||||
} catch (error) {
|
||||
console.error('Erro ao solicitar permissão de mídia:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter lista de dispositivos de mídia disponíveis
|
||||
*/
|
||||
export async function obterDispositivosDisponiveis(): Promise<DispositivosDisponiveis> {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
microphones: [],
|
||||
speakers: [],
|
||||
cameras: []
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Solicitar permissão primeiro para obter labels dos dispositivos
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
const microphones: DispositivoMedia[] = devices
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({
|
||||
deviceId: device.deviceId,
|
||||
label: device.label || `Microfone ${device.deviceId.substring(0, 8)}`,
|
||||
kind: 'audioinput' as const
|
||||
}));
|
||||
|
||||
const speakers: DispositivoMedia[] = devices
|
||||
.filter((device) => device.kind === 'audiooutput')
|
||||
.map((device) => ({
|
||||
deviceId: device.deviceId,
|
||||
label: device.label || `Alto-falante ${device.deviceId.substring(0, 8)}`,
|
||||
kind: 'audiooutput' as const
|
||||
}));
|
||||
|
||||
const cameras: DispositivoMedia[] = devices
|
||||
.filter((device) => device.kind === 'videoinput')
|
||||
.map((device) => ({
|
||||
deviceId: device.deviceId,
|
||||
label: device.label || `Câmera ${device.deviceId.substring(0, 8)}`,
|
||||
kind: 'videoinput' as const
|
||||
}));
|
||||
|
||||
return {
|
||||
microphones,
|
||||
speakers,
|
||||
cameras
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erro ao obter dispositivos disponíveis:', error);
|
||||
return {
|
||||
microphones: [],
|
||||
speakers: [],
|
||||
cameras: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurar dispositivo de áudio de saída (alto-falante)
|
||||
*/
|
||||
export async function configurarAltoFalante(
|
||||
deviceId: string,
|
||||
audioElement: HTMLAudioElement
|
||||
): Promise<boolean> {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error - setSinkId pode não estar disponível em todos os navegadores
|
||||
if (audioElement.setSinkId && typeof audioElement.setSinkId === 'function') {
|
||||
await audioElement.setSinkId(deviceId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Erro ao configurar alto-falante:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar se WebRTC está disponível no navegador
|
||||
*/
|
||||
export function verificarSuporteWebRTC(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!(
|
||||
navigator.mediaDevices &&
|
||||
navigator.mediaDevices.getUserMedia &&
|
||||
window.RTCPeerConnection
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter informações do navegador para debug
|
||||
*/
|
||||
export function obterInfoNavegador(): {
|
||||
navegador: string;
|
||||
versao: string;
|
||||
webrtcSuportado: boolean;
|
||||
mediaDevicesDisponivel: boolean;
|
||||
} {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
navegador: 'Servidor',
|
||||
versao: 'N/A',
|
||||
webrtcSuportado: false,
|
||||
mediaDevicesDisponivel: false
|
||||
};
|
||||
}
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
let navegador = 'Desconhecido';
|
||||
let versao = 'Desconhecida';
|
||||
|
||||
if (userAgent.indexOf('Chrome') > -1) {
|
||||
navegador = 'Chrome';
|
||||
const match = userAgent.match(/Chrome\/(\d+)/);
|
||||
versao = match ? match[1] : 'Desconhecida';
|
||||
} else if (userAgent.indexOf('Firefox') > -1) {
|
||||
navegador = 'Firefox';
|
||||
const match = userAgent.match(/Firefox\/(\d+)/);
|
||||
versao = match ? match[1] : 'Desconhecida';
|
||||
} else if (userAgent.indexOf('Safari') > -1) {
|
||||
navegador = 'Safari';
|
||||
const match = userAgent.match(/Version\/(\d+)/);
|
||||
versao = match ? match[1] : 'Desconhecida';
|
||||
} else if (userAgent.indexOf('Edge') > -1) {
|
||||
navegador = 'Edge';
|
||||
const match = userAgent.match(/Edge\/(\d+)/);
|
||||
versao = match ? match[1] : 'Desconhecida';
|
||||
}
|
||||
|
||||
return {
|
||||
navegador,
|
||||
versao,
|
||||
webrtcSuportado: verificarSuporteWebRTC(),
|
||||
mediaDevicesDisponivel: !!navigator.mediaDevices
|
||||
};
|
||||
}
|
||||
|
||||
331
apps/web/src/lib/utils/mediaRecorder.ts
Normal file
331
apps/web/src/lib/utils/mediaRecorder.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Utilitários para gravação de mídia usando MediaRecorder API
|
||||
*/
|
||||
|
||||
export interface OpcoesGravacao {
|
||||
audioBitsPerSecond?: number;
|
||||
videoBitsPerSecond?: number;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface ResultadoGravacao {
|
||||
blob: Blob;
|
||||
duracaoSegundos: number;
|
||||
nomeArquivo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar se MediaRecorder está disponível no navegador
|
||||
*/
|
||||
export function verificarSuporteMediaRecorder(): boolean {
|
||||
return typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter tipos MIME suportados para gravação
|
||||
*/
|
||||
export function obterTiposMimeSuportados(): {
|
||||
video: string[];
|
||||
audio: string[];
|
||||
} {
|
||||
if (!verificarSuporteMediaRecorder()) {
|
||||
return { video: [], audio: [] };
|
||||
}
|
||||
|
||||
const tiposVideo: string[] = [];
|
||||
const tiposAudio: string[] = [];
|
||||
|
||||
// Tipos comuns de vídeo
|
||||
const tiposVideoComuns = [
|
||||
'video/webm',
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm;codecs=h264',
|
||||
'video/mp4',
|
||||
'video/ogg',
|
||||
'video/x-matroska'
|
||||
];
|
||||
|
||||
// Tipos comuns de áudio
|
||||
const tiposAudioComuns = [
|
||||
'audio/webm',
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/ogg',
|
||||
'audio/mp4',
|
||||
'audio/mpeg'
|
||||
];
|
||||
|
||||
for (const tipo of tiposVideoComuns) {
|
||||
if (MediaRecorder.isTypeSupported(tipo)) {
|
||||
tiposVideo.push(tipo);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tipo of tiposAudioComuns) {
|
||||
if (MediaRecorder.isTypeSupported(tipo)) {
|
||||
tiposAudio.push(tipo);
|
||||
}
|
||||
}
|
||||
|
||||
return { video: tiposVideo, audio: tiposAudio };
|
||||
}
|
||||
|
||||
/**
|
||||
* Iniciar gravação de áudio apenas
|
||||
*/
|
||||
export function iniciarGravacaoAudio(
|
||||
stream: MediaStream,
|
||||
opcoes?: OpcoesGravacao
|
||||
): MediaRecorder | null {
|
||||
if (!verificarSuporteMediaRecorder()) {
|
||||
console.error('MediaRecorder não está disponível neste navegador');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tiposAudio = obterTiposMimeSuportados().audio;
|
||||
const mimeType = opcoes?.mimeType || tiposAudio[0] || 'audio/webm';
|
||||
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
audioBitsPerSecond: opcoes?.audioBitsPerSecond || 128000
|
||||
});
|
||||
|
||||
return recorder;
|
||||
} catch (error) {
|
||||
console.error('Erro ao iniciar gravação de áudio:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Iniciar gravação de vídeo (áudio + vídeo)
|
||||
*/
|
||||
export function iniciarGravacaoVideo(
|
||||
stream: MediaStream,
|
||||
opcoes?: OpcoesGravacao
|
||||
): MediaRecorder | null {
|
||||
if (!verificarSuporteMediaRecorder()) {
|
||||
console.error('MediaRecorder não está disponível neste navegador');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tiposVideo = obterTiposMimeSuportados().video;
|
||||
const mimeType = opcoes?.mimeType || tiposVideo[0] || 'video/webm';
|
||||
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
audioBitsPerSecond: opcoes?.audioBitsPerSecond || 128000,
|
||||
videoBitsPerSecond: opcoes?.videoBitsPerSecond || 2500000
|
||||
});
|
||||
|
||||
return recorder;
|
||||
} catch (error) {
|
||||
console.error('Erro ao iniciar gravação de vídeo:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parar gravação e retornar blob
|
||||
*/
|
||||
export function pararGravacao(recorder: MediaRecorder): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: BlobPart[] = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: recorder.mimeType });
|
||||
resolve(blob);
|
||||
};
|
||||
|
||||
recorder.onerror = (event) => {
|
||||
console.error('Erro na gravação:', event);
|
||||
reject(new Error('Erro ao parar gravação'));
|
||||
};
|
||||
|
||||
if (recorder.state === 'recording') {
|
||||
recorder.stop();
|
||||
} else {
|
||||
reject(new Error('Recorder não está gravando'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Salvar gravação localmente
|
||||
*/
|
||||
export function salvarGravacao(
|
||||
blob: Blob,
|
||||
nomeArquivo: string
|
||||
): void {
|
||||
try {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = nomeArquivo;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar gravação:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gerar nome de arquivo para gravação
|
||||
*/
|
||||
export function gerarNomeArquivo(
|
||||
tipo: 'audio' | 'video',
|
||||
roomName: string,
|
||||
timestamp?: number
|
||||
): string {
|
||||
const agora = timestamp || Date.now();
|
||||
const data = new Date(agora);
|
||||
const dataFormatada = data.toISOString().replace(/[:.]/g, '-').split('T')[0];
|
||||
const horaFormatada = data.toLocaleTimeString('pt-BR', { hour12: false }).replace(/:/g, '-');
|
||||
const extensao = tipo === 'audio' ? 'webm' : 'webm';
|
||||
|
||||
return `gravacao-${tipo}-${roomName}-${dataFormatada}-${horaFormatada}.${extensao}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter tamanho do blob em formato legível
|
||||
*/
|
||||
export function formatarTamanhoBlob(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular duração de gravação (em segundos)
|
||||
*/
|
||||
export function calcularDuracaoGravacao(
|
||||
inicioTimestamp: number,
|
||||
fimTimestamp?: number
|
||||
): number {
|
||||
const fim = fimTimestamp || Date.now();
|
||||
return Math.floor((fim - inicioTimestamp) / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gravar com controle completo
|
||||
*/
|
||||
export class GravadorMedia {
|
||||
private recorder: MediaRecorder | null = null;
|
||||
private stream: MediaStream | null = null;
|
||||
private inicioTimestamp: number = 0;
|
||||
private chunks: BlobPart[] = [];
|
||||
|
||||
constructor(
|
||||
private streamOriginal: MediaStream,
|
||||
private tipo: 'audio' | 'video',
|
||||
private opcoes?: OpcoesGravacao
|
||||
) {
|
||||
this.stream = streamOriginal;
|
||||
}
|
||||
|
||||
iniciar(): boolean {
|
||||
if (this.recorder && this.recorder.state === 'recording') {
|
||||
console.warn('Gravação já está em andamento');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.recorder =
|
||||
this.tipo === 'audio'
|
||||
? iniciarGravacaoAudio(this.stream!, this.opcoes)
|
||||
: iniciarGravacaoVideo(this.stream!, this.opcoes);
|
||||
|
||||
if (!this.recorder) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.chunks = [];
|
||||
this.inicioTimestamp = Date.now();
|
||||
|
||||
this.recorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
this.chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.recorder.start(1000); // Coletar dados a cada segundo
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao iniciar gravação:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
parar(): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.recorder) {
|
||||
reject(new Error('Recorder não foi inicializado'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.recorder.state === 'inactive') {
|
||||
// Se já parou, retornar blob dos chunks
|
||||
if (this.chunks.length > 0) {
|
||||
const blob = new Blob(this.chunks, { type: this.recorder.mimeType });
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Nenhum dado gravado'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.recorder.onstop = () => {
|
||||
const blob = new Blob(this.chunks, { type: this.recorder!.mimeType });
|
||||
resolve(blob);
|
||||
};
|
||||
|
||||
this.recorder.onerror = (event) => {
|
||||
console.error('Erro na gravação:', event);
|
||||
reject(new Error('Erro ao parar gravação'));
|
||||
};
|
||||
|
||||
this.recorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
obterDuracaoSegundos(): number {
|
||||
if (this.inicioTimestamp === 0) return 0;
|
||||
return calcularDuracaoGravacao(this.inicioTimestamp);
|
||||
}
|
||||
|
||||
estaGravando(): boolean {
|
||||
return this.recorder?.state === 'recording';
|
||||
}
|
||||
|
||||
liberar(): void {
|
||||
if (this.recorder && this.recorder.state === 'recording') {
|
||||
this.recorder.stop();
|
||||
}
|
||||
|
||||
// Parar todas as tracks do stream
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
this.recorder = null;
|
||||
this.stream = null;
|
||||
this.chunks = [];
|
||||
this.inicioTimestamp = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user