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

View 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
};
}

View 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;
}
}