Lee en voz alta los mensajes del chat de Amateur.tv con volumen independiente para la voz del modelo y del usuario, selección de voces y panel plegable.
// ==UserScript==
// @name Amateur.tv Chat Reader
// @namespace http://tampermonkey.net/
// @version 20.0
// @description Lee en voz alta los mensajes del chat de Amateur.tv con volumen independiente para la voz del modelo y del usuario, selección de voces y panel plegable.
// @author ChatGPT (Corregido - Versión 20.0 FINAL)
// @match https://es.amateur.tv/*
// @match https://www.amateur.tv/*
// @match https://amateur.tv/*
// @grant GM_xmlhttpRequest
// @connect api.elevenlabs.io
// @icon https://www.amateur.tv/favicon.ico
// @license MIT
// ==/UserScript==
(function() {
'use strict';
console.log('ChatReader: script started. Version 20.0 FINAL');
// ===== CONFIGURACIÓN DE API KEY =====
// Obtén tu API key GRATIS en: https://elevenlabs.io (20,000 caracteres/mes)
// 1. Regístrate gratis en https://elevenlabs.io
// 2. Ve a https://elevenlabs.io/app/developers/api-keys
// 3. Copia tu API key y pégala aquí:
const ELEVENLABS_API_KEY = 'YOUR_API_KEY_HERE';
const API_KEY_PLACEHOLDERS = ['TU_API_KEY_AQUI', 'YOUR_API_KEY_HERE'];
// ===== DETECCIÓN DEL CHAT =====
// Estructura 2026: el chat usa la clase estable "cy_chatContainer".
// Mantenemos los selectores antiguos como respaldo por si la web los recupera:
const CHAT_SELECTORS = [
'.cy_chatContainer',
'[class*="cy_chatContainer"]',
'[class*="ChatBlockLegacy__ChatBlockContainer"]',
'[class*="ChatBlock__ChatBlockContainer"]',
'[class*="ChatBlockContainer"]',
'[class*="ChatBlockLegacy"]',
'[class*="ChatBlock"]'
];
// Voces disponibles en español (con género).
// NOTA: Charlotte, Grace, Rachel, Domi y Elli son voces de PAGO en ElevenLabs
// (error 402), se han sustituido por voces del plan gratuito.
const AVAILABLE_VOICES = {
// VOCES FEMENINAS (plan gratuito)
'Sarah': { id: 'EXAVITQu4vr4xnSDxMaL', gender: 'female', name: 'Sarah (Femenina - Suave)' },
'Laura': { id: 'FGY2WhTYpPnrIDTdsKH5', gender: 'female', name: 'Laura (Femenina - Joven)' },
'Alice': { id: 'Xb7hH8MSUJpSbSDYk0k2', gender: 'female', name: 'Alice (Femenina - Natural)' },
'Lily': { id: 'pFZP5JQG7iQjIQuC4Bku', gender: 'female', name: 'Lily (Femenina - Cálida)' },
'Matilda': { id: 'XrExE9yKIg1WjnnlVkGX', gender: 'female', name: 'Matilda (Femenina - Joven)' },
'Jessica': { id: 'cgSgspJ2msm6clMCkdW9', gender: 'female', name: 'Jessica (Femenina - Cercana)' },
// VOCES MASCULINAS (todas verificadas funcionando en tu cuenta)
'Bill': { id: 'pqHfZKP75CvOlQylNhV4', gender: 'male', name: 'Bill (Masculina - Firme)' },
'Daniel': { id: 'onwK4e9ZLuTAKqWW03F9', gender: 'male', name: 'Daniel (Masculina - Profunda)' },
'Callum': { id: 'N2lVS1w4EtoT3dr4eOWO', gender: 'male', name: 'Callum (Masculina - Juvenil)' },
'George': { id: 'JBFqnCBsd6RMkjVDRZzb', gender: 'male', name: 'George (Masculina - Natural)' },
'Eric': { id: 'cjVigY5qzO86Huf0OWal', gender: 'male', name: 'Eric (Masculina - Cálida)' }
};
// ========================================
let lastReadMessageCount = 0;
let chatObserver = null;
let isSpeechEnabled = false;
let controlsContainer = null;
let enableButton = null;
let statusDiv = null;
let modelVolumeSlider = null;
let userVolumeSlider = null;
let modelVoiceSelect = null;
let userVoiceSelect = null;
let modelVoice = localStorage.getItem('chatReaderModelVoice') || 'Sarah';
let userVoice = localStorage.getItem('chatReaderUserVoice') || 'Bill';
// Si la voz guardada ya no existe en la lista (p.ej. Charlotte/Grace premium),
// volvemos a una voz gratuita por defecto
if (!AVAILABLE_VOICES[modelVoice]) {
modelVoice = 'Sarah';
localStorage.setItem('chatReaderModelVoice', modelVoice);
}
if (!AVAILABLE_VOICES[userVoice]) {
userVoice = 'Bill';
localStorage.setItem('chatReaderUserVoice', userVoice);
}
let urlCheckInterval = null;
let detectionInterval = null;
let bodyWatcher = null;
let toggleButton = null;
let currentChatContainer = null;
let audioQueue = [];
let isPlaying = false;
let currentAudio = null;
let pendingProcessTimer = null;
const IS_TOP_WINDOW = (window.self === window.top);
// Cola de reproducción
function playNextInQueue() {
if (audioQueue.length === 0) {
isPlaying = false;
return;
}
isPlaying = true;
const audioData = audioQueue.shift();
const audio = new Audio();
currentAudio = audio;
audio.volume = audioData.volume;
audio.onloadeddata = () => {
console.log('ChatReader: Audio loaded, playing...');
};
audio.onended = () => {
console.log('ChatReader: Finished playing audio');
playNextInQueue();
};
audio.onerror = (e) => {
console.error('ChatReader: Audio playback error:', e);
playNextInQueue();
};
audio.src = URL.createObjectURL(audioData.blob);
audio.play().catch(err => {
console.error('ChatReader: Error playing audio:', err);
playNextInQueue();
});
}
// TTS con ElevenLabs
function speakWithElevenLabs(text, isModelVoice = true, forcePlay = false) {
if (!isSpeechEnabled && !forcePlay) {
console.log('ChatReader: Speech is disabled. Skipping:', text);
return;
}
if (!ELEVENLABS_API_KEY || API_KEY_PLACEHOLDERS.indexOf(ELEVENLABS_API_KEY) !== -1) {
console.error('ChatReader: API key not configured!');
alert('❌ Este script requiere configuración inicial.\n\nEdita el script y pega tu clave en la constante ELEVENLABS_API_KEY (al principio del código).\n\nMás info en: https://elevenlabs.io/app/developers/api-keys');
return;
}
const voiceName = isModelVoice ? modelVoice : userVoice;
const voiceData = AVAILABLE_VOICES[voiceName];
if (!voiceData) {
console.error('ChatReader: Voice not found:', voiceName);
return;
}
const volume = isModelVoice ?
(modelVolumeSlider ? parseFloat(modelVolumeSlider.value) : 1.0) :
(userVolumeSlider ? parseFloat(userVolumeSlider.value) : 1.0);
const url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceData.id}`;
const requestData = {
text: text,
model_id: 'eleven_multilingual_v2',
voice_settings: {
stability: 0.5,
similarity_boost: 0.75,
style: 0.0,
use_speaker_boost: true
}
};
console.log(`ChatReader: Requesting TTS for: "${text.substring(0, 30)}..." with voice: ${voiceData.name}`);
GM_xmlhttpRequest({
method: 'POST',
url: url,
headers: {
'Accept': 'audio/mpeg',
'Content-Type': 'application/json',
'xi-api-key': ELEVENLABS_API_KEY
},
data: JSON.stringify(requestData),
responseType: 'blob',
onload: function(response) {
if (response.status === 200) {
console.log('ChatReader: Audio generated successfully');
audioQueue.push({
blob: response.response,
volume: volume
});
if (!isPlaying) {
playNextInQueue();
}
} else if (response.status === 401) {
console.error('ChatReader: Invalid API key');
alert('❌ Clave inválida. Verifica la configuración del script.');
} else if (response.status === 402) {
console.error('ChatReader: 402 con la voz:', voiceData.name);
if (response.response && response.response.text) {
response.response.text().then(t => console.error('ChatReader: detalle API:', t)).catch(() => {});
}
alert('⚠️ ElevenLabs rechazó la voz "' + voiceData.name + '" (402).\n\nEsta voz requiere plan de pago, o se acabó tu cuota del mes.\n\n• Prueba otra voz con "🔊 Probar Voz".\n• Saldo y consumo: https://elevenlabs.io/app/usage');
} else if (response.status === 429) {
console.error('ChatReader: Rate limit exceeded');
alert('⚠️ Has excedido el límite de uso gratuito.\n\nEspera hasta el próximo mes o actualiza tu plan.');
} else {
console.error('ChatReader: API error:', response.status, response.statusText);
alert(`❌ Error: ${response.status}\n\nRevisa la consola para más detalles.`);
}
},
onerror: function(error) {
console.error('ChatReader: Request error:', error);
alert('❌ Error de conexión. Verifica tu internet y la configuración de Tampermonkey.');
}
});
}
// Inject CSS
function injectStyles() {
if (document.getElementById('chatReaderStyles')) return; // Ya existe
const style = document.createElement('style');
style.id = 'chatReaderStyles';
style.type = 'text/css';
style.innerHTML = `
#chatReaderPanel {
position: fixed;
top: 50%;
right: -290px;
transform: translateY(-50%);
width: 270px;
box-sizing: border-box;
background-color: rgba(0, 0, 0, 0.9);
border-radius: 8px 0 0 8px;
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
padding: 15px;
display: flex;
flex-direction: column;
gap: 10px;
font-family: Arial, sans-serif;
color: white;
transition: right 0.3s ease-in-out;
z-index: 99999;
}
#chatReaderPanel.open {
right: 0;
}
#chatReaderToggleButton {
position: fixed;
top: 50%;
right: 0;
transform: translateY(-50%) rotate(270deg);
transform-origin: bottom right;
background-color: rgba(0, 0, 0, 0.9);
color: white;
padding: 8px 12px;
border: none;
border-radius: 8px 8px 0 0;
cursor: pointer;
font-size: 13px;
z-index: 100000;
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
transition: right 0.3s ease-in-out;
}
#chatReaderPanel.open + #chatReaderToggleButton {
right: 270px;
}
#chatReaderPanel button {
padding: 8px 12px;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
#chatReaderPanel select {
width: 100%;
padding: 6px;
border-radius: 3px;
border: 1px solid #555;
background-color: #222;
color: white;
font-size: 12px;
cursor: pointer;
}
#chatReaderPanel select option {
background-color: #222;
color: white;
}
#chatReaderPanel .volume-control-wrapper {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12px;
}
#chatReaderPanel #enableButton.enabled {
background-color: #28a745;
}
#chatReaderPanel #enableButton.disabled {
background-color: #dc3545;
}
#chatReaderPanel input[type="range"] {
width: 100%;
cursor: pointer;
}
#chatReaderPanel .test-button {
margin-top: 5px;
padding: 6px 10px;
background-color: #007bff;
font-size: 11px;
}
#chatReaderPanel .test-button:hover {
background-color: #0056b3;
}
#chatReaderPanel .status {
font-size: 11px;
padding: 6px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
color: #cccccc;
text-align: center;
}
#chatReaderPanel .status.ok {
color: #4CAF50;
background: rgba(76, 175, 80, 0.15);
}
#chatReaderPanel .voice-section {
display: flex;
flex-direction: column;
gap: 5px;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
#chatReaderPanel .voice-section label {
font-size: 11px;
color: #4CAF50;
font-weight: bold;
margin-bottom: 3px;
}
`;
document.head.appendChild(style);
}
function updateToggleButtonState() {
if (enableButton) {
if (isSpeechEnabled) {
enableButton.textContent = 'Deshabilitar Lectura';
enableButton.classList.remove('disabled');
enableButton.classList.add('enabled');
} else {
enableButton.textContent = 'Habilitar Lectura';
enableButton.classList.remove('enabled');
enableButton.classList.add('disabled');
}
}
updateStatus();
}
function updateStatus() {
if (!statusDiv) return;
const chatState = currentChatContainer ? '✅ Chat detectado' : '⏳ Buscando chat… (entra en un directo)';
const speechState = isSpeechEnabled ? '🔊 Lectura ON' : '🔇 Lectura OFF';
statusDiv.textContent = chatState + ' · ' + speechState;
statusDiv.className = 'status' + (currentChatContainer ? ' ok' : '');
}
function createControls() {
// EVITAR CREAR MÚLTIPLES VECES
if (controlsContainer && document.body.contains(controlsContainer)) {
updateToggleButtonState();
return;
}
injectStyles();
controlsContainer = document.createElement('div');
controlsContainer.id = 'chatReaderPanel';
statusDiv = document.createElement('div');
statusDiv.className = 'status';
enableButton = document.createElement('button');
enableButton.id = 'enableButton';
enableButton.onclick = () => {
isSpeechEnabled = !isSpeechEnabled;
updateToggleButtonState();
if (isSpeechEnabled) {
console.log('ChatReader: Speech enabled.');
if (currentChatContainer) {
startChatMessagesObserver(currentChatContainer);
setTimeout(() => processNewMessages(currentChatContainer, true), 100);
}
handleChatContainer(); // Por si el chat acaba de aparecer
} else {
audioQueue = [];
if (currentAudio) {
currentAudio.pause();
currentAudio = null;
}
disconnectChatObserver();
console.log('ChatReader: Speech disabled.');
}
};
updateToggleButtonState();
// Sección Modelo (Femenina)
const modelSection = document.createElement('div');
modelSection.className = 'voice-section';
const modelLabel = document.createElement('label');
modelLabel.textContent = '👩 VOZ MODELO (FEMENINA)';
modelVoiceSelect = document.createElement('select');
Object.entries(AVAILABLE_VOICES).forEach(([key, voice]) => {
if (voice.gender === 'female') {
const option = document.createElement('option');
option.value = key;
option.textContent = voice.name;
if (key === modelVoice) option.selected = true;
modelVoiceSelect.appendChild(option);
}
});
modelVoiceSelect.onchange = (e) => {
modelVoice = e.target.value;
localStorage.setItem('chatReaderModelVoice', modelVoice);
console.log('Model voice changed to:', modelVoice);
};
const modelVolWrapper = document.createElement('div');
modelVolWrapper.className = 'volume-control-wrapper';
const modelVolLabel = document.createElement('span');
const initialModelVol = parseFloat(localStorage.getItem('chatReaderModelVolume') || '1');
modelVolLabel.textContent = `Volumen: ${Math.round(initialModelVol * 100)}%`;
modelVolumeSlider = document.createElement('input');
modelVolumeSlider.type = 'range';
modelVolumeSlider.min = '0';
modelVolumeSlider.max = '1';
modelVolumeSlider.step = '0.05';
modelVolumeSlider.value = initialModelVol;
modelVolumeSlider.oninput = (e) => {
const vol = parseFloat(e.target.value);
modelVolLabel.textContent = `Volumen: ${Math.round(vol * 100)}%`;
localStorage.setItem('chatReaderModelVolume', vol);
};
modelVolWrapper.appendChild(modelVolLabel);
modelVolWrapper.appendChild(modelVolumeSlider);
const modelTestBtn = document.createElement('button');
modelTestBtn.className = 'test-button';
modelTestBtn.textContent = '🔊 Probar Voz';
modelTestBtn.onclick = () => {
speakWithElevenLabs('Hola, soy la voz del modelo', true, true);
};
modelSection.appendChild(modelLabel);
modelSection.appendChild(modelVoiceSelect);
modelSection.appendChild(modelVolWrapper);
modelSection.appendChild(modelTestBtn);
// Sección Usuario (Masculina)
const userSection = document.createElement('div');
userSection.className = 'voice-section';
const userLabel = document.createElement('label');
userLabel.textContent = '👨 VOZ USUARIO (MASCULINA)';
userVoiceSelect = document.createElement('select');
Object.entries(AVAILABLE_VOICES).forEach(([key, voice]) => {
if (voice.gender === 'male') {
const option = document.createElement('option');
option.value = key;
option.textContent = voice.name;
if (key === userVoice) option.selected = true;
userVoiceSelect.appendChild(option);
}
});
userVoiceSelect.onchange = (e) => {
userVoice = e.target.value;
localStorage.setItem('chatReaderUserVoice', userVoice);
console.log('User voice changed to:', userVoice);
};
const userVolWrapper = document.createElement('div');
userVolWrapper.className = 'volume-control-wrapper';
const userVolLabel = document.createElement('span');
const initialUserVol = parseFloat(localStorage.getItem('chatReaderUserVolume') || '1');
userVolLabel.textContent = `Volumen: ${Math.round(initialUserVol * 100)}%`;
userVolumeSlider = document.createElement('input');
userVolumeSlider.type = 'range';
userVolumeSlider.min = '0';
userVolumeSlider.max = '1';
userVolumeSlider.step = '0.05';
userVolumeSlider.value = initialUserVol;
userVolumeSlider.oninput = (e) => {
const vol = parseFloat(e.target.value);
userVolLabel.textContent = `Volumen: ${Math.round(vol * 100)}%`;
localStorage.setItem('chatReaderUserVolume', vol);
};
userVolWrapper.appendChild(userVolLabel);
userVolWrapper.appendChild(userVolumeSlider);
const userTestBtn = document.createElement('button');
userTestBtn.className = 'test-button';
userTestBtn.textContent = '🔊 Probar Voz';
userTestBtn.onclick = () => {
speakWithElevenLabs('Hola, soy la voz del usuario', false, true);
};
userSection.appendChild(userLabel);
userSection.appendChild(userVoiceSelect);
userSection.appendChild(userVolWrapper);
userSection.appendChild(userTestBtn);
controlsContainer.appendChild(enableButton);
controlsContainer.appendChild(statusDiv);
controlsContainer.appendChild(modelSection);
controlsContainer.appendChild(userSection);
document.body.appendChild(controlsContainer);
toggleButton = document.createElement('button');
toggleButton.id = 'chatReaderToggleButton';
toggleButton.textContent = 'Chat Reader';
toggleButton.onclick = () => {
controlsContainer.classList.toggle('open');
};
document.body.appendChild(toggleButton);
updateStatus();
console.log('ChatReader: Panel de control creado correctamente');
}
function getMessageWrappers(chatContainer) {
// Estructura 2026: filas "py-px" con autor + spans de texto.
// En los mensajes de usuario el <button> es hijo directo de la fila; en los
// de la MODELO va dentro de la insignia (span > button). Por eso buscamos
// el botón en cualquier nivel de la fila.
// SIN fallbacks genéricos a propósito: las filas de sistema y avisos
// ("X ha comenzado a seguir esta cam", menús de juegos, "Chat conectado"...)
// usan otras clases (my-[2px] etc.) y así NUNCA se leen por error.
return Array.from(chatContainer.querySelectorAll('div')).filter(el => {
const cls = (typeof el.className === 'string') ? el.className : '';
return cls.indexOf('py-px') !== -1 &&
el.querySelector('button') &&
el.querySelector(':scope > span');
});
}
function extractMessage(wrapper) {
// 1) Estructura antigua (por si alguna sección la conserva)
const oldText = wrapper.querySelector('span.sc-eqUAAy');
const oldAuthor = wrapper.querySelector('span.sc-gEvEer');
if (oldText && oldAuthor) {
const oldMsgText = oldText.textContent.trim();
if (oldMsgText) {
return {
text: oldMsgText,
isModel: oldAuthor.querySelector('svg') !== null
};
}
}
// 2) Estructura nueva (2026): BUTTON = autor, SPANs directos = texto
const authorButton = wrapper.querySelector(':scope > button');
let spans = Array.from(wrapper.querySelectorAll(':scope > span'))
.filter(s => s.textContent.trim().length > 0 &&
!s.querySelector('svg') && !s.querySelector('img'));
// 3) Fallback genérico: el span con más texto sin iconos
if (spans.length === 0) {
spans = Array.from(wrapper.querySelectorAll('span'))
.filter(s => s.textContent.trim().length > 0 &&
!s.querySelector('svg') && !s.querySelector('img'));
}
if (spans.length === 0) return null;
spans.sort((a, b) => b.textContent.trim().length - a.textContent.trim().length);
const text = spans[0].textContent.trim();
if (!text) return null;
// ¿Mensaje de la MODELO?
// Modelo: el botón del autor va dentro de una insignia con
// background-color: var(--color-transmitter-badge) (icono de cámara),
// el svg es viewBox 24x24 (cámara) y el texto usa color-chat-transmitter.
// Un usuario destacado también va en negrita pero SIN insignia de cámara
// (su svg es distinto, p.ej. viewBox 16x19).
let isModel = false;
const badgeSpan = wrapper.querySelector('span[style*="transmitter-badge"]');
if (badgeSpan) {
isModel = true; // insignia de cámara de la modelo
}
if (wrapper.querySelector('svg[viewBox="0 0 24 24"]')) {
isModel = true;
}
if (wrapper.querySelector('span[style*="color-chat-transmitter"]')) {
isModel = true;
}
// En la estructura antigua decidíamos por el svg del autor
if (!badgeSpan && oldAuthor && oldAuthor.querySelector('svg')) {
isModel = true;
}
return { text: text, isModel: isModel };
}
function processNewMessages(chatContainer, isInitialLoad = false) {
if (!isSpeechEnabled && !isInitialLoad) return;
if (!chatContainer || chatContainer !== currentChatContainer) return;
const messages = getMessageWrappers(chatContainer);
if (messages.length === 0) {
lastReadMessageCount = 0;
return;
}
// Si la web recorta mensajes antiguos (la lista tiene tope), reajustamos
if (messages.length < lastReadMessageCount) {
lastReadMessageCount = messages.length;
}
let startIndex = lastReadMessageCount;
if (isInitialLoad) {
// Al entrar, solo leer el último mensaje existente
startIndex = Math.max(0, messages.length - 1);
}
if (messages.length > lastReadMessageCount) {
for (let i = startIndex; i < messages.length; i++) {
const info = extractMessage(messages[i]);
if (info && info.text) {
console.log(`ChatReader: Processing ${info.isModel ? 'model' : 'user'} message:`, info.text);
speakWithElevenLabs(info.text, info.isModel, false);
}
}
}
lastReadMessageCount = messages.length;
}
function stopCurrentAudio() {
if (currentAudio) {
currentAudio.pause();
currentAudio = null;
}
}
function hideControls() {
if (controlsContainer && document.body.contains(controlsContainer)) {
document.body.removeChild(controlsContainer);
}
if (toggleButton && document.body.contains(toggleButton)) {
document.body.removeChild(toggleButton);
}
// Al ocultarse, el panel debe reaparecer CERRADO la próxima vez
if (controlsContainer) {
controlsContainer.classList.remove('open');
}
if (isSpeechEnabled) {
isSpeechEnabled = false;
updateToggleButtonState();
console.log('ChatReader: Lectura desactivada (salimos del chat de la modelo)');
}
audioQueue = [];
stopCurrentAudio();
}
function ensureControls() {
if (!IS_TOP_WINDOW) return;
// El panel SOLO se muestra en páginas de modelo (donde hay chat).
// Si salimos a otra parte de la web, se cierra y desactiva la lectura:
if (!currentChatContainer) {
hideControls();
return;
}
// La app (React) limpia el <body> al montar y borra nuestro panel:
// lo volvemos a pegar / crear automáticamente
if (controlsContainer && !document.body.contains(controlsContainer)) {
document.body.appendChild(controlsContainer); // reutilizar el existente
}
if (toggleButton && !document.body.contains(toggleButton)) {
document.body.appendChild(toggleButton);
}
if (!controlsContainer) {
if (toggleButton && document.body.contains(toggleButton)) {
toggleButton.remove();
}
createControls();
}
}
function findChatContainer() {
for (let i = 0; i < CHAT_SELECTORS.length; i++) {
const sel = CHAT_SELECTORS[i];
try {
const el = document.querySelector(sel);
if (el) {
return el;
}
} catch (e) {
// Selector inválido, continuar con el siguiente
}
}
return null;
}
function disconnectChatObserver() {
if (chatObserver) {
chatObserver.disconnect();
chatObserver = null;
}
}
function resetChatState() {
currentChatContainer = null;
disconnectChatObserver();
lastReadMessageCount = 0;
updateStatus();
}
function handleChatContainer() {
const foundChatContainer = findChatContainer();
if (foundChatContainer) {
// Mostrar/recrear el panel SOLO cuando hay chat (página de modelo)
ensureControls();
if (!controlsContainer) {
createControls(); // caso iframe: el chat vive dentro del iframe
}
if (foundChatContainer !== currentChatContainer) {
console.log('ChatReader: Chat container encontrado');
// Al cambiar de modelo, el panel reaparece CERRADO
if (controlsContainer) {
controlsContainer.classList.remove('open');
}
currentChatContainer = foundChatContainer;
lastReadMessageCount = 0;
disconnectChatObserver();
if (isSpeechEnabled) {
startChatMessagesObserver(foundChatContainer);
setTimeout(() => processNewMessages(foundChatContainer, true), 100);
}
updateStatus();
}
} else {
if (currentChatContainer) {
console.log('ChatReader: Salimos de la modelo, desactivando y cerrando');
resetChatState();
}
// Sin chat → ocultar panel y desactivar lectura
ensureControls();
}
}
function startChatMessagesObserver(chatContainer) {
if (!chatContainer) return;
disconnectChatObserver();
chatObserver = new MutationObserver(mutations => {
if (!isSpeechEnabled || !currentChatContainer) return;
const hasAddedNodes = mutations.some(mutation => mutation.addedNodes.length > 0);
if (!hasAddedNodes) return;
clearTimeout(pendingProcessTimer);
pendingProcessTimer = setTimeout(() => processNewMessages(currentChatContainer, false), 300);
});
// subtree: true para no depender de dónde cuelguen exactamente los mensajes
chatObserver.observe(chatContainer, { childList: true, subtree: true });
console.log('ChatReader: Observer de mensajes iniciado');
}
// Main execution
console.log('ChatReader: Iniciando script...');
function init() {
if (IS_TOP_WINDOW) {
// El panel solo se mostrará en páginas de modelo (con chat).
// Vigilamos el body para repagarlo si la app lo borra (React):
bodyWatcher = new MutationObserver(() => {
if (currentChatContainer &&
(!controlsContainer || !document.body.contains(controlsContainer))) {
ensureControls();
}
});
bodyWatcher.observe(document.body, { childList: true, subtree: false });
}
handleChatContainer();
detectionInterval = setInterval(handleChatContainer, 1500);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
let lastUrl = location.href;
urlCheckInterval = setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
resetChatState();
setTimeout(handleChatContainer, 500);
}
}, 500);
window.addEventListener('beforeunload', () => {
disconnectChatObserver();
if (bodyWatcher) bodyWatcher.disconnect();
if (urlCheckInterval) clearInterval(urlCheckInterval);
if (detectionInterval) clearInterval(detectionInterval);
clearTimeout(pendingProcessTimer);
});
console.log('ChatReader: Script inicializado correctamente');
})();