vlibras-player-nextjs
Version:
VLibras Player para Next.js - Biblioteca moderna para tradução de texto em Libras com callbacks de estado
1,645 lines • 57.9 kB
JavaScript
import { useReducer, useRef, useCallback, useMemo, useEffect } from 'react';
import axios from 'axios';
/**
* Configuração padrão do VLibras
*/
const defaultConfig = {
translatorUrl: 'https://traducao2-dth.vlibras.gov.br/dl/translate',
dictionaryUrl: 'https://dicionario2-dth.vlibras.gov.br/2018.3.1/WEBGL/',
dictionaryStaticUrl: 'https://dicionario2-dth.vlibras.gov.br/static/BUNDLES/2018.3.1/WEBGL/',
};
/**
* Status do player
*/
const PLAYER_STATUSES = {
idle: 'idle',
preparing: 'preparing',
playing: 'playing',
};
/**
* Configurações padrão do player
*/
const DEFAULT_PLAYER_OPTIONS = {
translatorUrl: defaultConfig.translatorUrl,
targetPath: '/vlibras/target',
region: 'BR',
enableStats: true,
autoInit: true,
};
/**
* Nomes dos objetos Unity
*/
const UNITY_GAME_OBJECTS = {
playerManager: 'PlayerManager',
customizationBridge: 'CustomizationBridge',
};
/**
* Timeouts padrão
*/
const TIMEOUTS = {
translation: 10000, // 10 segundos base
translationPerWord: 400, // 400ms por palavra adicional
maxTranslationTimeout: 60000, // 60 segundos máximo
};
class VLibrasTranslator {
constructor(endpoint) {
this.endpoint = endpoint;
}
/**
* Traduz um texto para glosa (linguagem intermediária para Libras)
*/
async translate(text, domain = '') {
if (!text || text.trim().length === 0) {
throw new Error('Texto não pode estar vazio');
}
const timeout = this.calculateTimeout(text);
try {
const response = await axios.post(this.endpoint, {
text: text.trim(),
domain: domain || window?.location?.hostname || 'localhost',
}, {
timeout,
headers: {
'Content-Type': 'application/json',
},
});
if (!response.data) {
throw new Error('Resposta vazia do servidor de tradução');
}
return response.data;
}
catch (error) {
if (error?.code === 'ECONNABORTED') {
throw new Error('timeout_error');
}
if (error?.response?.status === 400) {
throw new Error('Texto inválido para tradução');
}
if (error?.response?.status >= 500) {
throw new Error('Erro interno do servidor de tradução');
}
throw error instanceof Error ? error : new Error('Erro desconhecido na tradução');
}
}
/**
* Calcula o timeout baseado no tamanho do texto
*/
calculateTimeout(text) {
const wordCount = text.split(/\s+/).length;
let timeout = TIMEOUTS.translation;
// Adiciona tempo extra para textos maiores
if (wordCount > 50) {
timeout += Math.floor(wordCount * TIMEOUTS.translationPerWord / 10);
}
// Garante que não ultrapasse o máximo
return Math.min(timeout, TIMEOUTS.maxTranslationTimeout);
}
/**
* Valida se o endpoint está acessível
*/
async validateEndpoint() {
try {
await axios.get(this.endpoint, { timeout: 5000 });
return true;
}
catch {
return false;
}
}
}
/**
* Adapter para gerenciar a comunicação com o Unity WebGL Player
*/
class UnityPlayerManager {
constructor() {
this.player = null;
this.eventListeners = new Map();
this.subtitle = true;
this.currentBaseUrl = '';
if (UnityPlayerManager.instance) {
return UnityPlayerManager.instance;
}
this.setupGlobalCallbacks();
UnityPlayerManager.instance = this;
}
/**
* Define a referência do player Unity
*/
setPlayerReference(player) {
this.player = player;
}
/**
* Envia uma mensagem para o Unity
*/
sendMessage(method, params) {
if (!this.player) {
// Unity player not initialized - silently return
return;
}
try {
this.player.SendMessage(UNITY_GAME_OBJECTS.playerManager, method, params);
}
catch (error) {
// Silently handle Unity communication errors
}
}
/**
* Reproduz uma glosa ou continua a reprodução
*/
play(gloss) {
if (gloss) {
this.sendMessage('playNow', gloss);
}
else {
this.sendMessage('setPauseState', 0);
}
}
/**
* Pausa a reprodução
*/
pause() {
this.sendMessage('setPauseState', 1);
}
/**
* ✅ NOVO: Retoma a reprodução pausada
*/
resume() {
this.sendMessage('setPauseState', 0);
}
/**
* ✅ NOVO: Reinicia a animação atual (mesmo se estiver rodando)
*/
restart() {
// 🔥 CORREÇÃO: Unity não tem comando restart nativo, então fazemos stop + play da glosa atual
this.sendMessage('stopAll');
// O restart será implementado na lógica do VLibrasPlayer
}
/**
* Para toda a reprodução
*/
stop() {
this.sendMessage('stopAll');
}
/**
* Define a velocidade de reprodução
*/
setSpeed(speed) {
if (speed < 0.5 || speed > 2.0) {
// Speed should be between 0.5 and 2.0 - silently return
return;
}
this.sendMessage('setSlider', speed);
}
/**
* Alterna a exibição de legendas
*/
toggleSubtitle() {
this.subtitle = !this.subtitle;
this.sendMessage('setSubtitlesState', this.subtitle ? 1 : 0);
}
/**
* Reproduz animação de boas-vindas
*/
playWelcome() {
this.sendMessage('playWellcome');
}
/**
* Troca o avatar
*/
changeAvatar(avatarName) {
this.sendMessage('Change', avatarName);
}
/**
* Define a URL base para o dicionário
*/
setBaseUrl(url) {
this.sendMessage('setBaseUrl', url);
this.currentBaseUrl = url;
}
/**
* ✅ CRITICAL FIX: Inicializa animações aleatórias (método do original)
*/
initRandomAnimationsProcess() {
this.sendMessage('initRandomAnimationsProcess');
}
/**
* Define configurações de personalização
*/
setPersonalization(personalization) {
if (!this.player) {
// Unity player not initialized - silently return
return;
}
try {
this.player.SendMessage(UNITY_GAME_OBJECTS.customizationBridge, 'setURL', personalization);
}
catch (error) {
// Silently handle personalization errors
}
}
/**
* Adiciona listener para eventos
*/
addEventListener(event, listener) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, new Set());
}
this.eventListeners.get(event).add(listener);
}
/**
* Remove listener de eventos
*/
removeEventListener(event, listener) {
const listeners = this.eventListeners.get(event);
if (listeners) {
listeners.delete(listener);
}
}
/**
* Emite um evento
*/
emit(event, ...args) {
const listeners = this.eventListeners.get(event);
if (listeners) {
listeners.forEach(listener => {
try {
listener(...args);
}
catch (error) {
// Silently handle event listener errors
}
});
}
}
/**
* Configura callbacks globais para comunicação com Unity
*/
setupGlobalCallbacks() {
if (typeof window === 'undefined')
return;
// Callback para quando o player é carregado
window.onLoadPlayer = () => {
this.sendMessage('initRandomAnimationsProcess');
this.emit('load');
};
// Callback para progresso
window.updateProgress = (progress) => {
this.emit('progress', progress);
};
// Callback para mudança de estado
window.onPlayingStateChange = (isPlaying, isPaused, isPlayingIntervalAnimation, isLoading, _isRepeatable) => {
this.emit('stateChange', this.toBool(isPlaying), this.toBool(isPaused), this.toBool(isLoading));
};
// Callback para contador de glosa
window.CounterGloss = (counter, length) => {
this.emit('counterGloss', counter, length);
};
// Callback para obter avatar
window.GetAvatar = (avatar) => {
this.emit('getAvatar', avatar);
};
// Callback para fim da apresentação
window.FinishWelcome = (finished) => {
this.emit('finishWelcome', this.toBool(finished));
};
}
/**
* Converte string Unity para boolean
*/
toBool(value) {
return value !== 'False';
}
/**
* Obtém URL base atual
*/
getCurrentBaseUrl() {
return this.currentBaseUrl;
}
/**
* Verifica se as legendas estão habilitadas
*/
isSubtitleEnabled() {
return this.subtitle;
}
/**
* Limpa todos os listeners
*/
dispose() {
this.eventListeners.clear();
this.player = null;
if (typeof window !== 'undefined') {
delete window.onLoadPlayer;
delete window.updateProgress;
delete window.onPlayingStateChange;
delete window.CounterGloss;
delete window.GetAvatar;
delete window.FinishWelcome;
}
}
}
UnityPlayerManager.instance = null;
/**
* Gerencia o carregamento do Unity WebGL Player
*/
class UnityLoader {
/**
* Carrega e inicializa o Unity WebGL Player
*/
static async loadPlayer(config) {
const { targetPath, gameContainer, onSuccess, onError, onProgress } = config;
try {
// Carrega o script do Unity Loader se ainda não foi carregado
await this.loadUnityScript(targetPath);
// Aguarda o UnityLoader estar disponível
await this.waitForUnityLoader();
// Configura o container
this.setupGameContainer(gameContainer);
// Carrega a configuração do jogo
const configUrl = this.joinUrl(targetPath, 'playerweb.json');
// Inicializa o Unity
const UnityLoaderGlobal = window.UnityLoader;
const player = UnityLoaderGlobal.instantiate(gameContainer.id, configUrl, {
compatibilityCheck: (_, accept, deny) => {
if (UnityLoaderGlobal.SystemInfo.hasWebGL) {
accept();
}
else {
const errorMsg = 'Seu navegador não suporta WebGL';
onError(errorMsg);
deny();
}
},
onProgress: onProgress ? (unityInstance, progress) => {
onProgress(progress);
} : undefined,
});
onSuccess(player);
}
catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Erro ao carregar Unity Player';
onError(errorMsg);
}
}
/**
* Carrega o script do Unity Loader
*/
static async loadUnityScript(targetPath) {
const scriptUrl = this.joinUrl(targetPath, 'UnityLoader.js');
// Verifica se já foi carregado
if (this.loadedScripts.has(scriptUrl)) {
return;
}
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = scriptUrl;
script.async = true;
script.onload = () => {
this.loadedScripts.add(scriptUrl);
resolve();
};
script.onerror = () => {
reject(new Error(`Falha ao carregar Unity script: ${scriptUrl}`));
};
document.head.appendChild(script);
});
}
/**
* Aguarda o UnityLoader estar disponível no window
*/
static async waitForUnityLoader(timeout = 10000) {
const startTime = Date.now();
return new Promise((resolve, reject) => {
const check = () => {
if (window.UnityLoader) {
resolve();
return;
}
if (Date.now() - startTime > timeout) {
reject(new Error('Timeout aguardando UnityLoader'));
return;
}
setTimeout(check, 100);
};
check();
});
}
/**
* ✅ MELHORADO: Configura o container do jogo com ID estável
*/
static setupGameContainer(container) {
// ✅ CORREÇÃO: ID mais estável baseado em região ou ID padrão
if (!container.id) {
container.id = 'vlibras-game-container-main';
}
// ✅ Verificar se já tem as classes necessárias para evitar duplicação
if (!container.classList.contains('emscripten')) {
container.classList.add('emscripten', 'vlibras-unity-container');
}
// Define estilos básicos se necessário
if (!container.style.position) {
container.style.position = 'relative';
}
}
/**
* Junta URLs de forma segura
*/
static joinUrl(base, path) {
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
return `${normalizedBase}/${normalizedPath}`;
}
/**
* Verifica se o navegador suporta WebGL
*/
static checkWebGLSupport() {
try {
const canvas = document.createElement('canvas');
const context = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
return !!context;
}
catch {
return false;
}
}
/**
* Remove todos os scripts Unity carregados
*/
static cleanup() {
this.loadedScripts.forEach(scriptUrl => {
const scripts = document.querySelectorAll(`script[src="${scriptUrl}"]`);
scripts.forEach(script => script.remove());
});
this.loadedScripts.clear();
// Remove variáveis globais do Unity
if (typeof window !== 'undefined') {
delete window.UnityLoader;
delete window.Module;
}
}
}
UnityLoader.loadedScripts = new Set();
class UnityStateManager {
/**
* Verifica se o Unity WebGL carregou completamente
*/
static isUnityReady(container) {
try {
const canvas = container.querySelector('canvas');
const unityInstance = this.getUnityInstance(container);
// ✅ Verificação mais robusta
const hasValidCanvas = canvas &&
canvas.clientWidth > 0 &&
canvas.clientHeight > 0 &&
typeof canvas.getContext === 'function' &&
!canvas.style.display?.includes('none');
const hasValidUnity = unityInstance &&
unityInstance.Module &&
unityInstance.Module.ready === true &&
unityInstance.SendMessage &&
typeof unityInstance.SendMessage === 'function';
// ✅ Verificação adicional: tentar enviar mensagem de teste
let canSendMessage = false;
if (hasValidUnity && unityInstance?.SendMessage) {
try {
// Teste silencioso - não vai gerar erro se Unity estiver pronto
unityInstance.SendMessage('NonExistentObject', 'NonExistentMethod', '');
canSendMessage = true;
}
catch (error) {
// Se der erro específico de objeto não encontrado, Unity está ok
const errorStr = error instanceof Error ? error.message : String(error);
canSendMessage = errorStr.includes('object') || errorStr.includes('method') || errorStr.includes('not found');
}
}
return !!(hasValidCanvas && hasValidUnity && canSendMessage);
}
catch (error) {
// Silently handle errors - avoid console warnings in production
return false;
}
}
/**
* Verifica se uma animação está sendo reproduzida
*/
static isAnimationPlaying(container) {
try {
const unityInstance = this.getUnityInstance(container);
if (unityInstance?.Module?.isAnimationPlaying) {
return unityInstance.Module.isAnimationPlaying();
}
// Fallback: verificar se há canvas ativo
const canvas = container?.querySelector('canvas') || document.querySelector('canvas');
return canvas ? canvas.style.display !== 'none' : false;
}
catch (error) {
// Silently handle errors
return false;
}
}
/**
* Verifica se uma animação foi completada
*/
static isAnimationComplete(container) {
try {
const unityInstance = this.getUnityInstance(container);
if (unityInstance?.Module?.isAnimationComplete) {
return unityInstance.Module.isAnimationComplete();
}
// Se não tem método específico, assume que não está tocando = completo
return !this.isAnimationPlaying(container);
}
catch (error) {
// In case of error, assume complete to avoid blocking
return true;
}
}
/**
* Aguarda o Unity estar completamente carregado
*/
static waitForUnity(container, timeout = 30000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const check = () => {
if (this.isUnityReady(container)) {
resolve();
return;
}
if (Date.now() - startTime > timeout) {
reject(new Error(`Unity não carregou em ${timeout}ms`));
return;
}
setTimeout(check, 100);
};
// Delay inicial para permitir que Unity comece a carregar
setTimeout(check, 500);
});
}
/**
* Aguarda uma animação terminar completamente
*/
static waitForAnimationEnd(container, timeout = 10000) {
return new Promise((resolve, _reject) => {
const startTime = Date.now();
const check = () => {
if (this.isAnimationComplete(container)) {
resolve();
return;
}
if (Date.now() - startTime > timeout) {
// Resolve even with timeout to avoid blocking the application
resolve();
return;
}
setTimeout(check, 100);
};
// Delay inicial para permitir que animação comece
setTimeout(check, 200);
});
}
/**
* Obtém a instância do Unity para um container específico
*/
static getUnityInstance(container) {
if (container) {
// Tentar encontrar por ID do container
const containerId = container.id;
if (containerId && window.unityInstances?.has(containerId)) {
return window.unityInstances.get(containerId);
}
}
// Fallback para instância global
return window.unityInstance;
}
/**
* Registra uma instância do Unity para um container específico
*/
static registerUnityInstance(containerId, instance) {
if (!window.unityInstances) {
window.unityInstances = new Map();
}
window.unityInstances.set(containerId, instance);
// Também manter referência global para compatibilidade
window.unityInstance = instance;
}
/**
* Remove registro de uma instância do Unity
*/
static unregisterUnityInstance(containerId) {
if (window.unityInstances) {
window.unityInstances.delete(containerId);
}
}
/**
* Limpa todas as instâncias registradas
*/
static clearAllInstances() {
if (window.unityInstances) {
window.unityInstances.clear();
}
window.unityInstance = undefined;
}
/**
* Obtém informações de debug do estado atual
*/
static getDebugInfo(container) {
const unityInstance = this.getUnityInstance(container);
const canvas = container?.querySelector('canvas');
return {
hasUnityInstance: !!unityInstance,
hasModule: !!unityInstance?.Module,
moduleReady: !!unityInstance?.Module?.ready,
hasSendMessage: !!unityInstance?.SendMessage,
hasCanvas: !!canvas,
canvasSize: canvas ? `${canvas.clientWidth}x${canvas.clientHeight}` : 'N/A',
isReady: container ? this.isUnityReady(container) : false,
isAnimationPlaying: this.isAnimationPlaying(container),
isAnimationComplete: this.isAnimationComplete(container),
registeredInstances: window.unityInstances?.size || 0,
};
}
}
/**
* Classe principal do VLibras Player para Next.js
*/
class VLibrasPlayer {
constructor(options = {}) {
this.eventListeners = new Map();
this.container = null;
this.globalGlossLength = 0;
this.isInTranslation = false; // 🔥 Flag para rastrear se estamos em uma tradução
this.isRestarting = false; // 🔥 NOVO: Flag para rastrear se estamos reiniciando
this.options = {
...DEFAULT_PLAYER_OPTIONS,
...options,
};
// Extrair callbacks das opções
this.callbacks = {
onTranslationStart: options.onTranslationStart,
onTranslationEnd: options.onTranslationEnd,
onTranslationError: options.onTranslationError,
onPlay: options.onPlay,
onPause: options.onPause,
onResume: options.onResume,
onRestart: options.onRestart,
onStop: options.onStop,
onPlayerReady: options.onPlayerReady,
onPlayerError: options.onPlayerError,
};
this.translator = new VLibrasTranslator(this.options.translatorUrl);
this.unityManager = new UnityPlayerManager();
this.state = {
status: PLAYER_STATUSES.idle,
loaded: false,
translated: false,
progress: null,
region: this.options.region,
isTranslating: false,
isPlaying: false,
};
this.setupEventListeners();
this.setupCallbackIntegration();
}
/**
* Carrega o player no container especificado
*/
async load(wrapper) {
if (typeof window === 'undefined') {
throw new Error('VLibras Player só pode ser usado no lado cliente');
}
if (!UnityLoader.checkWebGLSupport()) {
throw new Error('Navegador não suporta WebGL');
}
// ✅ CORREÇÃO ORIGINAL: Implementar sequência exata do VLibras original
// Limpar containers anteriores se existirem
const oldContainers = wrapper.querySelectorAll('[id*="vlibras-container"], [id*="gameContainer"]');
oldContainers.forEach(container => {
const containerId = container.id;
UnityStateManager.unregisterUnityInstance(containerId);
container.remove();
});
// ✅ CRITICAL FIX: Criar gameContainer com ID específico que Unity espera
this.container = document.createElement('div');
this.container.setAttribute('id', 'gameContainer'); // ✅ Unity original usa gameContainer
this.container.classList.add('emscripten', 'vlibras-unity-container');
wrapper.appendChild(this.container);
return new Promise((resolve, reject) => {
// ✅ CRITICAL FIX: Implementar window.onLoadPlayer como no original
const originalOnLoadPlayer = window.onLoadPlayer;
window.onLoadPlayer = () => {
try {
// ✅ ORIGINAL SEQUENCE: Exatamente como no Player.js original
this.state.loaded = true;
this.emit('load');
// ✅ CRITICAL FIX: Inicializar animações aleatórias (estava faltando!)
this.unityManager.initRandomAnimationsProcess();
// ✅ ORIGINAL FIX: setBaseUrl SEM região como no original
this.unityManager.setBaseUrl(defaultConfig.dictionaryUrl);
// ✅ ORIGINAL: Chamar onLoad callback ou play automático
if (this.options.onLoad) {
this.options.onLoad();
}
else {
// ✅ ORIGINAL: Play automático de boas-vindas com null
this.play(null, { fromTranslation: true });
}
this.callbacks.onPlayerReady?.();
resolve();
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Erro na inicialização do player';
this.emit('error', errorMessage);
this.callbacks.onPlayerError?.(errorMessage);
reject(new Error(errorMessage));
}
finally {
// Restaurar callback original se existia
if (originalOnLoadPlayer) {
window.onLoadPlayer = originalOnLoadPlayer;
}
}
};
// ✅ Usar UnityLoader original
UnityLoader.loadPlayer({
targetPath: this.options.targetPath,
gameContainer: this.container,
onSuccess: (player) => {
// ✅ CRITICAL FIX: Apenas configurar referência, NÃO marcar como pronto
this.unityManager.setPlayerReference(player);
UnityStateManager.registerUnityInstance('gameContainer', player);
// ✅ O window.onLoadPlayer será chamado pelo Unity quando estiver realmente pronto
},
onError: (error) => {
// Restaurar callback original se existia
if (originalOnLoadPlayer) {
window.onLoadPlayer = originalOnLoadPlayer;
}
this.emit('error', error);
this.callbacks.onPlayerError?.(error);
reject(new Error(error));
},
onProgress: (progress) => {
this.state.progress = progress;
this.emit('animation:progress', progress);
},
});
});
}
/**
* Traduz um texto para Libras - EXATAMENTE como no código original
*/
async translate(text, options = {}) {
if (!text || text.trim().length === 0) {
throw new Error('Texto não pode estar vazio');
}
if (!this.state.loaded) {
throw new Error('Player não está pronto. Aguarde o carregamento completo.');
}
// ✅ ORIGINAL: Emitir translate:start imediatamente
this.emit('translate:start');
this.callbacks.onTranslationStart?.();
this.isInTranslation = true; // 🔥 Marcar que estamos em uma tradução
// ✅ ORIGINAL: Parar reprodução atual se estiver carregado
if (this.state.loaded) {
this.stop();
}
this.state.text = text;
try {
const domain = typeof window !== 'undefined' ? window.location.hostname : '';
const gloss = await this.translator.translate(text, domain);
if (!gloss) {
// ✅ ORIGINAL: Em caso de erro, finalizar tradução
this.isInTranslation = false; // 🔥 Finalizar flag de tradução
this.emit('translate:end');
this.callbacks.onTranslationEnd?.();
return;
}
this.state.gloss = gloss;
// ✅ CRITICAL FIX: Seguir EXATAMENTE o código original
this.play(gloss, { ...options, fromTranslation: true, isEnabledStats: options.isEnabledStats });
// 🔥 REMOVED: Não emitir translate:end aqui!
// Agora será emitido quando animation:end for disparado
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Erro na tradução';
this.callbacks.onTranslationError?.(errorMessage);
if (errorMessage === 'timeout_error') {
this.emit('error', 'timeout_error');
}
else {
this.emit('error', errorMessage);
}
// ✅ ORIGINAL: Em caso de erro, reproduzir texto em maiúsculas
this.play(text.toUpperCase());
this.emit('translate:end');
}
}
/**
* Reproduz uma glosa ou continua a reprodução
*/
play(gloss, options = {}) {
const { fromTranslation = false, isEnabledStats = true } = options;
// Configura URL do dicionário baseado nas estatísticas
this.updateDictionaryUrl(isEnabledStats);
this.state.translated = fromTranslation;
this.state.gloss = gloss || this.state.gloss;
if (this.state.gloss && this.state.loaded) {
this.changeStatus(PLAYER_STATUSES.preparing);
this.unityManager.play(this.state.gloss);
}
}
/**
* Reproduz animação de boas-vindas
*/
playWelcome() {
this.unityManager.playWelcome();
this.emit('welcome:start');
}
/**
* Continua a reprodução pausada
*/
continue() {
this.unityManager.play();
}
/**
* Repete a última reprodução
*/
repeat() {
this.play();
}
/**
* Pausa a reprodução
*/
pause() {
this.unityManager.pause();
// 🔥 CORREÇÃO: Não chamar callback aqui - será chamado pelo evento animation:pause
}
/**
* ✅ NOVO: Retoma a reprodução pausada
*/
resume() {
this.unityManager.resume();
// 🔥 CORREÇÃO: Não chamar callback aqui - será chamado pelo evento animation:play
}
/**
* ✅ NOVO: Reinicia a animação atual (mesmo se estiver rodando)
*/
restart() {
if (!this.state.loaded || !this.state.gloss) {
// Não há glosa para reiniciar
return;
}
// 🔥 CORREÇÃO: Marcar que estamos reiniciando
this.isRestarting = true;
// Emitir apenas o evento - o callback será chamado pelo listener
this.emit('animation:restart');
// Parar e reproduzir novamente a glosa atual
this.unityManager.stop();
// Pequeno delay para garantir que o stop foi processado
setTimeout(() => {
if (this.state.gloss && this.isRestarting) {
this.unityManager.play(this.state.gloss);
this.isRestarting = false; // Reset flag após iniciar
}
}, 100);
}
/**
* Para a reprodução
*/
stop() {
this.unityManager.stop();
// 🔥 CORREÇÃO: Não chamar callback aqui - será chamado pelo evento animation:end
// 🔥 Resetar flag de tradução se necessário
if (this.isInTranslation) {
this.isInTranslation = false;
// Note: Não emitir translate:end aqui pois stop() pode ser chamado manualmente
}
}
/**
* Define a velocidade de reprodução
*/
setSpeed(speed) {
this.unityManager.setSpeed(speed);
}
/**
* Define configurações de personalização
*/
setPersonalization(personalization) {
this.unityManager.setPersonalization(personalization);
}
/**
* Troca o avatar
*/
changeAvatar(avatarName) {
this.unityManager.changeAvatar(avatarName);
this.emit('avatar:change', avatarName);
}
/**
* Alterna exibição de legendas
*/
toggleSubtitle() {
this.unityManager.toggleSubtitle();
}
/**
* Define a região do dicionário
*/
setRegion(region) {
this.state.region = region;
const url = `${defaultConfig.dictionaryUrl}${region}/`;
this.unityManager.setBaseUrl(url);
}
/**
* Obtém o estado atual do player
*/
getState() {
return { ...this.state };
}
/**
* Adiciona listener para eventos
*/
addEventListener(event, listener) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, new Set());
}
this.eventListeners.get(event).add(listener);
}
/**
* Remove listener de eventos
*/
removeEventListener(event, listener) {
const listeners = this.eventListeners.get(event);
if (listeners) {
listeners.delete(listener);
}
}
/**
* ✅ NOVO: Remove todos os listeners de eventos
*/
removeAllListeners() {
this.eventListeners.clear();
}
/**
* Destrói o player e limpa recursos
/**
* ✅ MELHORADO: Destrói o player e limpa recursos completamente
*/
dispose() {
// Limpar listeners
this.eventListeners.clear();
// Limpar Unity Manager
if (this.unityManager) {
this.unityManager.dispose();
}
// ✅ CORREÇÃO: Limpeza completa do container
if (this.container) {
this.container.remove();
this.container = null;
}
// ✅ Limpar estado
this.state.loaded = false;
this.state.status = 'idle';
this.state.translated = false;
this.state.progress = null;
// Limpar referências
this.globalGlossLength = 0;
}
/**
* Emite um evento
*/
emit(event, ...args) {
const listeners = this.eventListeners.get(event);
if (listeners) {
listeners.forEach(listener => {
try {
listener(...args);
}
catch (error) {
// ✅ CORREÇÃO: Emitir erro em vez de console.error
this.emit('error', `Error in VLibras event listener for ${event}: ${error}`);
}
});
}
}
/**
* Configura integração dos callbacks com eventos internos
*/
setupCallbackIntegration() {
// 🔥 CORREÇÃO: Integrar callbacks APENAS com eventos, evitando duplicação
this.addEventListener('animation:play', () => {
this.state.isPlaying = true;
this.callbacks.onPlay?.();
});
this.addEventListener('animation:pause', () => {
this.state.isPlaying = false;
this.callbacks.onPause?.();
});
this.addEventListener('animation:resume', () => {
this.state.isPlaying = true;
this.callbacks.onResume?.();
});
this.addEventListener('animation:restart', () => {
this.state.isPlaying = true;
this.callbacks.onRestart?.();
});
this.addEventListener('animation:end', () => {
this.state.isPlaying = false;
this.callbacks.onStop?.();
});
this.addEventListener('error', (error) => {
this.callbacks.onPlayerError?.(error);
});
}
/**
* Configura listeners para eventos do Unity
*/
setupEventListeners() {
this.unityManager.addEventListener('load', () => {
this.state.loaded = true;
this.emit('load');
this.unityManager.setBaseUrl(`${defaultConfig.dictionaryUrl}${this.state.region}/`);
if (this.options.onLoad) {
this.options.onLoad();
}
});
this.unityManager.addEventListener('progress', (progress) => {
this.state.progress = progress;
this.emit('animation:progress', progress);
});
let wasPaused = false; // 🔥 CORREÇÃO: Flag para detectar se estava pausado
this.unityManager.addEventListener('stateChange', (isPlaying, isPaused, isLoading) => {
if (isPaused) {
wasPaused = true; // 🔥 Marcar que foi pausado
this.emit('animation:pause');
}
else if (isPlaying && !isPaused) {
// 🔥 CORREÇÃO: Distinguir entre play inicial, resume e restart
if (this.isRestarting) {
// Não emitir evento aqui, já foi emitido no método restart()
this.isRestarting = false;
}
else if (wasPaused) {
this.emit('animation:resume'); // ✅ Retomar após pausa
wasPaused = false; // Reset flag
}
else {
this.emit('animation:play'); // ✅ Play inicial
}
this.changeStatus(PLAYER_STATUSES.playing);
}
else if (!isPlaying && !isLoading) {
wasPaused = false; // Reset flag quando termina
// 🔥 CORREÇÃO: Não emitir animation:end se estivermos reiniciando
if (!this.isRestarting) {
this.emit('animation:end');
}
this.changeStatus(PLAYER_STATUSES.idle);
// 🔥 GENIUS LOGIC: Emitir translate:end quando animação terminar durante uma tradução!
if (this.isInTranslation && !this.isRestarting) {
this.isInTranslation = false;
this.emit('translate:end');
this.callbacks.onTranslationEnd?.();
}
}
});
this.unityManager.addEventListener('counterGloss', (counter, length) => {
this.globalGlossLength = length;
this.emit('gloss:info', counter, length);
});
this.unityManager.addEventListener('getAvatar', (avatar) => {
this.emit('avatar:change', avatar);
});
this.unityManager.addEventListener('finishWelcome', (finished) => {
this.emit('welcome:end', finished);
});
}
/**
* Altera o status do player
*/
changeStatus(status) {
const previousStatus = this.state.status;
switch (status) {
case PLAYER_STATUSES.idle:
if (previousStatus === PLAYER_STATUSES.playing) {
this.state.status = status;
this.emit('gloss:end', this.globalGlossLength);
}
break;
case PLAYER_STATUSES.preparing:
this.state.status = status;
break;
case PLAYER_STATUSES.playing:
if (previousStatus === PLAYER_STATUSES.preparing) {
this.state.status = status;
this.emit('gloss:start');
}
break;
}
}
/**
* Atualiza URL do dicionário baseado nas configurações de estatísticas
*/
updateDictionaryUrl(isEnabledStats) {
const currentUrl = this.unityManager.getCurrentBaseUrl();
const defaultUrl = `${defaultConfig.dictionaryUrl}${this.state.region}/`;
const staticUrl = `${defaultConfig.dictionaryStaticUrl}${this.state.region}/`;
if (!isEnabledStats && currentUrl === defaultUrl) {
this.unityManager.setBaseUrl(staticUrl);
}
else if (isEnabledStats && currentUrl !== defaultUrl) {
this.unityManager.setBaseUrl(defaultUrl);
}
}
}
// ========================================
// REDUCER PARA GERENCIAMENTO DE ESTADO
// ========================================
const initialState = {
status: 'idle',
isLoading: false,
isReady: false,
isTranslating: false,
isPlaying: false,
isPaused: false,
progress: null,
region: 'BR',
errors: {
fatal: null,
warnings: [],
},
};
function playerReducer(state, action) {
switch (action.type) {
case 'INITIALIZE_START':
return {
...state,
status: 'initializing',
isLoading: true,
errors: { fatal: null, warnings: [] },
};
case 'INITIALIZE_SUCCESS':
return {
...state,
status: 'ready',
isLoading: false,
isReady: true,
};
case 'INITIALIZE_ERROR':
return {
...state,
status: 'error',
isLoading: false,
isReady: false,
errors: { ...state.errors, fatal: action.payload },
};
case 'TRANSLATION_START':
return {
...state,
status: 'translating',
isTranslating: true,
currentText: action.payload,
lastTranslation: {
text: action.payload,
timestamp: Date.now(),
},
};
case 'TRANSLATION_END':
return {
...state,
status: 'ready',
isTranslating: false,
};
case 'TRANSLATION_ERROR':
return {
...state,
isTranslating: false,
errors: {
...state.errors,
warnings: [...state.errors.warnings, action.payload],
},
};
case 'PLAYBACK_START':
return {
...state,
status: 'playing',
isPlaying: true,
isPaused: false,
};
case 'PLAYBACK_PAUSE':
return {
...state,
status: 'paused',
isPlaying: false,
isPaused: true,
};
case 'PLAYBACK_RESUME':
return {
...state,
status: 'playing',
isPlaying: true,
isPaused: false,
};
case 'PLAYBACK_STOP':
return {
...state,
status: 'ready',
isPlaying: false,
isPaused: false,
progress: null,
};
case 'PLAYBACK_RESTART':
return {
...state,
status: 'playing',
isPlaying: true,
isPaused: false,
progress: 0,
};
case 'PROGRESS_UPDATE':
return {
...state,
progress: action.payload,
};
case 'REGION_CHANGE':
return {
...state,
region: action.payload,
};
case 'WARNING_ADD':
return {
...state,
errors: {
...state.errors,
warnings: [...state.errors.warnings, action.payload],
},
};
case 'WARNINGS_CLEAR':
return {
...state,
errors: {
...state.errors,
warnings: [],
},
};
case 'RESET':
return initialState;
default:
return state;
}
}
// ========================================
// HOOK PRINCIPAL
// ========================================
/**
* Hook React para VLibras Player com melhores práticas
*
* @example
* ```tsx
* const containerRef = useRef<HTMLDivElement>(null);
* const player = useVLibrasPlayer({
* containerRef,
* autoInit: true,
* onPlayerReady: () => console.log('Player pronto!'),
* onStateChange: (state) => console.log('Estado:', state.status)
* });
*
* // Usar o player
* await player.translate('Olá mundo');
* ```
*/
function useVLibrasPlayer(options = {}) {
const { containerRef, autoInit = false, retryOnError = true, maxRetries = 3, debounceMs = 300, onStateChange, onPlayerReady, onLoad, // Compatibilidade com VLibrasPlayer
onPlayerError, onTranslationStart, onTranslationEnd, onTranslationError, onPlay, onPlaybackStart, onPlaybackEnd, onPause, onPlaybackPause, onResume, onPlaybackResume, onRestart, onPlaybackRestart, onStop, ...playerOptions } = options;
// ========================================
// ESTADO GERENCIADO POR REDUCER
// ========================================
const [state, dispatch] = useReducer(playerReducer, initialState);
// Refs para instâncias e controle
const playerRef = useRef(null);
const retryCountRef = useRef(0);
const debounceTimerRef = useRef(null);
const abortControllerRef = useRef(null);
// ========================================
// CALLBACKS MEMOIZADOS
// ========================================
const handlePlayerReady = useCallback(() => {
dispatch({ type: 'INITIALIZE_SUCCESS' });
onPlayerReady?.();
onLoad?.(); // Compatibilidade com VLibrasPlayer
}, [onPlayerReady, onLoad]);
const handlePlayerError = useCallback((errorMessage) => {
const isFatal = errorMessage.includes('failed to load') || errorMessage.includes('network error');
if (isFatal) {
dispatch({ type: 'INITIALIZE_ERROR', payload: errorMessage });
}
else {
dispatch({ type: 'WARNING_ADD', payload: errorMessage });
}
onPlayerError?.(errorMessage, isFatal);
}, [onPlayerError]);
const handleTranslationStart = useCallback((text) => {
dispatch({ type: 'TRANSLATION_START', payload: text });
onTranslationStart?.();
}, [onTranslationStart]);
const handleTranslationEnd = useCallback(() => {
dispatch({ type: 'TRANSLATION_END' });
onTranslationEnd?.();
}, [onTranslationEnd]);
const handleTranslationError = useCallback((errorMessage) => {
dispatch({ type: 'TRANSLATION_ERROR', payload: errorMessage });
onTranslationError?.(errorMessage);
}, [onTranslationError]);
const handlePlaybackStart = useCallback(() => {
dispatch({ type: 'PLAYBACK_START' });
onPlaybackStart?.();
onPlay?.(); // Compatibilidade com VLibrasPlayer
}, [onPlaybackStart, onPlay]);
const handlePlaybackPause = useCallback(() => {
dispatch({ type: 'PLAYBACK_PAUSE' });
onPlaybackPause?.();
onPause?.(); // Compatibilidade com VLibrasPlayer
}, [onPlaybackPause, onPause]);
const handlePlaybackResume = useCallback(() => {
dispatch({ type: 'PLAYBACK_RESUME' });
onPlaybackResume?.();
onResume?.(); // Compatibilidade com VLibrasPlayer
}, [onPlaybackResume, onResume]);
const handlePlaybackStop = useCallback(() => {
dispatch({ type: 'PLAYBACK_STOP' });
onPlaybackEnd?.();
onStop?.(); // Compatibilidade com VLibrasPlayer
}, [onPlaybackEnd, onStop]);
const handlePlaybackRestart = useCallback(() => {
dispatch({ type: 'PLAYBACK_RESTART' });
onPlaybackRestart?.();
onRestart?.(); // Compatibilidade com VLibrasPlayer
}, [onPlaybackRestart, onRestart]);
// ========================================
// FUNÇÕES DE CONTROLE DO PLAYER
// ========================================
const initializePlayer = useCallback(async () => {
if (!containerRef?.current || playerRef.current || state.isLoading) {
return;
}
// Cancelar operação anterior se existir
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
dispatch({ type: 'INITIALIZE_START' });
try {
const player = new VLibrasPlayer({
...playerOptions,
// Mapear callbacks do hook para os callbacks da classe
onLoad: () => {
const playerState = player.getState();
if (playerState.loaded) {
handlePlayerReady();
}
},
onPlayerError: handlePlayerError,
onTranslationStart: () => handleTranslationStart(state.currentText || ''),
onTranslationEnd: handleTranslationEnd,
onTranslationError: handleTranslationError,
onPlay: handlePlaybackStart,
onPause: handlePlaybackPause,
onResume: handlePlaybackResume,
onStop: handlePlaybackStop,
onRestart: handlePlaybackRestart,
});
await player.load(containerRef.current);
// Verificar se a operação foi cancelada
if (abortControllerRef.current?.signal.aborted) {
player.dispose();
return;
}
playerRef.current = player;
retryCountRef.current = 0;
}
catch (error) {
if (abortControllerRef.current?.signal.aborted) {
return;
}
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido ao inicializar player';
// Tentar novamente se configurado
if (retryOnError && retryCountRef.current < maxRetries) {
retryCountRef.current++;
setTimeout(() => initializePlayer(), 1000 * retryCountRef.current);
return;
}
dispatch({ type: 'INITIALIZE_ERROR', payload: errorMessage });
}
}, [
containerRef,
playerOptions,
state.isLoading,
state.currentText,
handlePlayerReady,
handlePlayerError,
handleTranslationStart,
handleTranslationEnd,
handleTranslationError,
handlePlaybackStart,
handlePlaybackPause,
handlePlaybackResume,
handlePlaybackStop,
handlePlaybackRestart,
retryOnError,
maxRetries
]);
const translate = useCallback(async (text, translationOptions) => {
if (!playerRef.current) {
throw new Error('Player não inicializado. Chame initializePlayer() primeiro.');
}
if (!state.isReady) {
throw new Error('Player não está pronto. Aguarde a inicialização.');
}
// Debounce para evitar traduções muito rápidas
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
return new Promise((resolve, reject) => {
debounceTimerRef.current = setTimeout(async () => {
try {
await playerRef.current.translate(text, translationOptions);
resolve();
}
catch (error) {
reject(error);
}
}, debounceMs);
});
}, [state.isReady, debounceMs]);
// Métodos de controle memoizados
const controls = useMemo(() => ({
play: (gloss, options) => {
if (!playerRef.current)
throw new Error('Player não inicializado');
return playerRef.current.play(gloss, options);
},
pause: () => {
if (!playerRef.current)
throw new Error('Player não inicializado');
return playerRef.current.pause();
},
resume: () => {
if (!playerRef.current)
throw new Error('Player não inicializado');
return playerRef.current.resume();
},
stop: () => {
if (!playerRef.current)
throw new Error('Player não inicializado');
return playerRef.current.stop();
},
restart: () => {
if (!playerRef.current)
throw new Error('Player não inicializado');
return playerRef.current.restart();
},
repeat: () => {
if (!playerRef.current)
throw new Error('Player não inicializado');
return playerRef.current.repeat();
},
}), []);
// Métodos de configuração memoizados
const settings = useMemo(() => ({
setSpeed: (speed) => {
if (!playerRef.current)
throw new Error('Player não inicializado');
playerRef.current.setSpeed(speed);
},
setRegion: (region) => {
if (!playerRef.current)
throw new Error('Player não inicializado');
playerRef.current.setRegion(region);
dispatch({ type: 'REGION_CHANGE', payload: region });
},
changeAvatar: (avatarName) => {
if (!playerRef.current)
throw new Error('Player não inicializado');
playerRef.current.changeAvatar(avatarName);
},
toggleSubtitle: () => {
if (!playerRef.current)
throw new Error('Player não inicializado');
playerRef.current.toggleSubtitle();
},
playWelcome: () => {
if (!playerRef.current)
throw new Error('Player não inicializado');
playerRef.current.playWelcome();
},
}), []);
// Estados derivados memoizados
const derivedState = useMemo(() => ({
canTranslate: state.isReady && !state.isTranslating,
canPause: state.isPlaying && !state.isPaused,
canResume: state.isPaused,
canStop: state.isPlaying || state.isPaused,
canRestart: state.isReady && (state.isPlaying || state.isPaused || state.lastTranslation),
hasWarnings: state.errors.warnings.length > 0,
isBusy: state.isLoading || state.isTranslating,
}), [state]);
// Utilitários
const utils = useMemo(() => ({
clearWarnings: () => dispatch({ type: 'WARNINGS_CLEAR' }),
reset: () => {
if (playerRef.current) {
playerRef.current.dispose();
playerRef.current = null;
}
dispatch({ type: 'RESET' });
},
getPlayer: () => playerRef.current,
retry: () => {
retryCountRef.current = 0;
return initializePlayer();
},
}), [initializePlayer]);
// ========================================
// EFEITOS
// ========================================
// Callback para mudanças de estado
useEffect(() => {
onStateChange?.(state);
}, [state, onStateChange]);
// Inicialização automática
useEffect(() => {
if (autoInit && containerRef?.current && !playerRef.current && !state.isLoading) {
initializePlayer();
}
}, [autoInit, containerRef, initializePlayer, state.isLoading]);
// Limpeza ao desmontar
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (playerRef.current) {
playerRef.current.dispose();
}
};
}, []);
// ========================================
// RETORNO DO HOOK
// ========================================
return {
// Estado principal
...state,
...derivedState,
// Métodos principais
translate,
initializePlayer,
// Controles agrupados
controls,
settings,
utils,
// Compatibilidade (métodos individuais)
play: controls.play,
pause: controls.pause,
resume: controls.resume,
stop: controls.stop,
restart: controls.restart,
repeat: controls.repeat,
setSpeed: settings.setSpeed,
setRegion: settings.setRegion,
changeAvatar: settings.changeAvatar,
toggleSubtitle: settings.toggleSubtitle,
playWelcome: settings.playWelcome,
// Dados legados para compatibilidade
player: {
status: state.status,
loaded: state.isReady,
translated: !!state.lastTranslation,
progress: state.progress,
region: state.region,
isTranslating: state.isTranslating,
isPlaying: state.isPlaying,
text: state.currentText,
gloss: state.currentGloss,
},
error: state.errors.fatal,
isLoading: state.isLoading,
isReady: state.isReady,
isTranslating: state.isTranslating,
isPlaying: state.isPlaying,
};
}
// Hooks React para controle avançado
// Versão da biblioteca
const VERSION = '2.4.1';
export { DEFAULT_PLAYER_OPTIONS, PLAYER_STATUSES, UnityLoader, UnityPlayerManager, UnityStateManager, VERSION, VLibrasPlayer, VLibrasTranslator, defaultConfig, useVLibrasPlayer };
//# sourceMappingURL=index.esm.js.map