added public watch parties with cloudflared

This commit is contained in:
2026-01-04 19:59:37 +01:00
parent d9c1ba3d27
commit 5fe0e319b9
20 changed files with 1426 additions and 458 deletions

View File

@@ -24,6 +24,13 @@ class CreateRoomModal {
<label>Password (Optional)</label>
<input type="password" class="cr-input" name="password" placeholder="Leave empty for public" maxlength="50" />
</div>
<div class="cr-form-group cr-checkbox-group">
<label class="cr-checkbox">
<input type="checkbox" name="expose" />
<span>Generate public link (via tunnel)</span>
</label>
</div>
<div class="cr-actions">
<button type="button" class="cr-btn-cancel" id="cr-cancel">Cancel</button>
@@ -87,6 +94,7 @@ class CreateRoomModal {
btn.textContent = 'Creating...';
const formData = new FormData(e.target);
const expose = formData.get('expose') === 'on';
const name = formData.get('name').trim();
const password = formData.get('password').trim();
const token = localStorage.getItem('token');
@@ -100,7 +108,8 @@ class CreateRoomModal {
},
body: JSON.stringify({
name,
password: password || undefined
password: password || undefined,
expose
})
});

View File

@@ -13,13 +13,13 @@ const RoomsApp = (function() {
let configState = {
extension: null,
server: null,
category: 'sub', // 'sub' o 'dub'
category: 'sub',
episode: 1
};
let extensionsStore = {
list: [],
settings: {} // { anilist: {...}, gogo: {...} }
settings: {}
};
const elements = {
@@ -27,7 +27,6 @@ const RoomsApp = (function() {
roomView: document.getElementById('room-view'),
roomName: document.getElementById('room-name'),
roomViewers: document.getElementById('room-viewers'),
leaveRoomBtn: document.getElementById('leave-room-btn'),
selectAnimeBtn: document.getElementById('select-anime-btn'),
toggleChatBtn: document.getElementById('toggle-chat-btn'),
@@ -59,7 +58,7 @@ const RoomsApp = (function() {
chatMessages: document.getElementById('chat-messages'),
chatForm: document.getElementById('chat-form'),
chatInput: document.getElementById('chat-input'),
roomLayout: document.getElementById('room-layout'), // Corregido: referencia al layout
roomLayout: document.getElementById('room-layout'),
// Modals
joinRoomModal: document.getElementById('join-room-modal'),
@@ -113,7 +112,6 @@ const RoomsApp = (function() {
setupEventListeners();
await preloadExtensions();
// --- NUEVO: Obtener info de la sala primero ---
try {
const res = await fetch(`/api/rooms/${currentRoomId}`);
if (!res.ok) throw new Error('Room not found');
@@ -122,41 +120,39 @@ const RoomsApp = (function() {
} catch (e) {
console.error(e);
alert("Room not found or deleted");
window.location.href = '/anime';
}
}
// --- NUEVO: Función para manejar la entrada lógica ---
function handleInitialEntry(roomInfo) {
const token = localStorage.getItem('token');
const passwordGroup = document.getElementById('password-group');
// Configurar UI del Modal con datos del Host
const hostInfoDiv = document.getElementById('join-host-info');
const hostAvatar = document.getElementById('join-host-avatar');
const hostText = document.getElementById('join-host-text');
if (hostInfoDiv && roomInfo.host) {
hostInfoDiv.style.display = 'flex';
// Usar avatar del host o un placeholder
hostAvatar.src = roomInfo.host.avatar || '/public/assets/placeholder.png';
hostText.innerHTML = `<span>${escapeHtml(roomInfo.host.username)}</span> invited you to watch`;
}
// Configurar si pide contraseña
if (passwordGroup) {
// Si la sala tiene pass, mostramos el campo
passwordGroup.style.display = roomInfo.hasPassword ? 'block' : 'none';
// Marcar en un atributo dataset si es requerida para validación
passwordGroup.dataset.required = roomInfo.hasPassword ? 'true' : 'false';
}
window.__roomPublicUrl = roomInfo.publicUrl || null;
window.__roomExposed = roomInfo.exposed || false;
console.log('Room info loaded:', {
exposed: window.__roomExposed,
publicUrl: window.__roomPublicUrl
});
if (token) {
// Si tiene token, intentamos conectar directamente.
// Si hay pass y no somos el host/dueño, el socket fallará y pedirá pass luego.
connectToRoom(currentRoomId);
} else {
// Es Guest: Mostrar modal directamente
console.log('Guest user, showing modal...');
if (elements.joinRoomModal) {
elements.joinRoomModal.classList.add('show');
@@ -186,39 +182,32 @@ const RoomsApp = (function() {
}
function setupEventListeners() {
// Join Room Form
const cancelJoinBtn = document.getElementById('cancel-join-btn');
if (cancelJoinBtn) cancelJoinBtn.onclick = leaveRoom;
if (elements.joinRoomForm) elements.joinRoomForm.onsubmit = submitJoinForm;
// Header Controls
if (elements.selectAnimeBtn) elements.selectAnimeBtn.onclick = openAnimeSearchModal;
if (elements.toggleChatBtn) elements.toggleChatBtn.onclick = toggleChat;
if (elements.leaveRoomBtn) elements.leaveRoomBtn.onclick = leaveRoom;
// Host Quick Controls Listeners
if (elements.roomExtSelect) elements.roomExtSelect.onchange = (e) => onQuickExtensionChange(e, false);
if (elements.roomServerSelect) elements.roomServerSelect.onchange = onQuickServerChange;
// Sub/Dub Toggle Logic (Header)
if (elements.roomSdToggle) {
elements.roomSdToggle.onclick = () => {
if (!isHost) return;
const currentState = elements.roomSdToggle.getAttribute('data-state');
const newState = currentState === 'sub' ? 'dub' : 'sub';
// Update UI visually immediately
elements.roomSdToggle.setAttribute('data-state', newState);
elements.roomSdToggle.querySelectorAll('.sd-option').forEach(opt => {
opt.classList.toggle('active', opt.dataset.val === newState);
});
// Trigger Stream Reload
onQuickServerChange();
};
}
// Anime Search Modal
const closeSearchBtn = document.getElementById('close-search-modal');
const animeSearchBtn = document.getElementById('anime-search-btn');
@@ -234,7 +223,6 @@ const RoomsApp = (function() {
};
}
// Config Step (Modal)
if (elements.backToSearchBtn) {
elements.backToSearchBtn.onclick = () => {
elements.stepConfig.style.display = 'none';
@@ -244,13 +232,10 @@ const RoomsApp = (function() {
if (elements.selExtension) elements.selExtension.onchange = handleModalExtensionChange;
if (elements.btnLaunch) elements.btnLaunch.onclick = () => launchStream(true); // true = from modal
// Room & User List
if (elements.toggleUsersBtn) elements.toggleUsersBtn.onclick = toggleUsersList;
// Chat
if (elements.chatForm) elements.chatForm.onsubmit = sendMessage;
// Anime results delegation
if (elements.animeResults) {
elements.animeResults.addEventListener('click', handleAnimeResultClick);
}
@@ -271,11 +256,8 @@ const RoomsApp = (function() {
elements.roomExtSelect.appendChild(opt);
});
// 🔥 FORZAR valor real
const extToUse = selectedAnimeData.source || extensionsStore.list[0];
elements.roomExtSelect.value = extToUse;
elements.roomExtSelect.value = selectedAnimeData.source || extensionsStore.list[0];
// 🔥 FORZAR carga de servers
await onQuickExtensionChange(null, true);
}
@@ -299,7 +281,6 @@ const RoomsApp = (function() {
elements.roomServerSelect.appendChild(opt);
});
// 🔥 FORZAR server seleccionado
elements.roomServerSelect.value = servers[0];
handleDubUI(settings, 'header');
@@ -348,8 +329,6 @@ const RoomsApp = (function() {
launchStream(false);
}
// --- MODAL LOGIC ---
function handleAnimeResultClick(e) {
const itemLink = e.target.closest('.search-item, .anime-result-item, a[href*="/anime/"]');
@@ -363,7 +342,7 @@ const RoomsApp = (function() {
const imgEl = itemLink.querySelector('.search-poster, img');
title = titleEl ? titleEl.textContent : (itemLink.textContent.trim() || 'Unknown');
img = imgEl ? (imgEl.src || imgEl.dataset.src || '/public/assets/placeholder.png') : '/public/assets/placeholder.png';
img = imgEl ? (imgEl.src || imgEl.dataset.src || '/public/assets/placeholder.svg') : '/public/assets/placeholder.svg';
const href = itemLink.getAttribute('href') || '';
const hrefParts = href.split('/').filter(p => p);
@@ -386,36 +365,28 @@ const RoomsApp = (function() {
if (!selectedAnimeData) return;
if (!extensionsReady) return;
// 1. Resetear UI básica
elements.configTitle.textContent = selectedAnimeData.title;
elements.configCover.src = selectedAnimeData.image;
if(ui.configError) ui.configError.style.display = 'none';
// 2. Resetear Estado interno
configState.episode = 1;
configState.server = null;
configState.category = 'sub'; // Reset a sub por defecto
configState.extension = null; // Reset extensión
configState.category = 'sub';
configState.extension = null;
// 3. Resetear controles visuales
if(ui.epInput) ui.epInput.value = 1;
if(ui.launchBtn) ui.launchBtn.disabled = true;
updateSDUI(); // Función visual para el toggle sub/dub
updateSDUI();
// 4. Configurar listeners de botones +/- y toggle
setupConfigListeners();
// 5. Renderizar los botones de extensiones
renderExtensionChips();
// Mostrar pantalla
elements.stepSearch.style.display = 'none';
elements.stepConfig.style.display = 'block';
}
// Configura los botones + / - y el toggle Sub/Dub
function setupConfigListeners() {
// Episode Stepper
if(ui.epInc) ui.epInc.onclick = () => {
ui.epInput.value = parseInt(ui.epInput.value || 0) + 1;
configState.episode = ui.epInput.value;
@@ -426,14 +397,12 @@ const RoomsApp = (function() {
};
if(ui.epInput) ui.epInput.onchange = (e) => configState.episode = e.target.value;
// Sub/Dub Toggle
if(ui.sdToggle) {
ui.sdToggle.querySelectorAll('.cat-opt').forEach(opt => {
opt.onclick = () => {
if(opt.classList.contains('disabled')) return;
configState.category = opt.dataset.val;
updateSDUI();
// Al cambiar categoría, recargar servidores (quizás cambien los disponibles)
if(configState.extension) loadServersForExtension(configState.extension);
};
});
@@ -447,7 +416,6 @@ const RoomsApp = (function() {
});
}
// Dibuja los botones de Extensiones
function renderExtensionChips() {
ui.extContainer.innerHTML = '';
@@ -461,30 +429,25 @@ const RoomsApp = (function() {
chip.className = 'chip';
chip.textContent = ext.charAt(0).toUpperCase() + ext.slice(1);
// Auto-seleccionar si ya estaba en el estado (o default a anilist)
if (!configState.extension && ext === 'anilist') configState.extension = 'anilist';
if (ext === configState.extension) chip.classList.add('active');
chip.onclick = () => {
// Actualizar visual
document.querySelectorAll('#ext-chips-container .chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
// Actualizar lógica
configState.extension = ext;
configState.server = null; // Reset servidor al cambiar extensión
ui.launchBtn.disabled = true; // Deshabilitar Play hasta elegir server
configState.server = null;
ui.launchBtn.disabled = true;
loadServersForExtension(ext);
};
ui.extContainer.appendChild(chip);
});
// Cargar servidores de la extensión inicial
if(configState.extension) loadServersForExtension(configState.extension);
}
// Carga los servidores de la API (Asíncrono)
async function loadServersForExtension(extName) {
if (!extensionsReady) return;
ui.serverContainer.innerHTML = '<div class="grid-loader"><div class="spinner" style="width:20px;height:20px;"></div> Loading servers...</div>';
@@ -500,7 +463,6 @@ const RoomsApp = (function() {
renderServerChips(servers);
// Manejar si la extensión soporta Dub
const dubBtn = ui.sdToggle.querySelector('[data-val="dub"]');
if (dubBtn) {
if (!settings.supportsDub) {
@@ -520,7 +482,6 @@ const RoomsApp = (function() {
}
}
// Dibuja los botones de Servidores
function renderServerChips(servers) {
ui.serverContainer.innerHTML = '';
@@ -534,7 +495,6 @@ const RoomsApp = (function() {
chip.classList.add('active');
configState.server = srv;
// AHORA sí habilitamos el botón de Play
ui.launchBtn.disabled = false;
};
@@ -597,19 +557,16 @@ const RoomsApp = (function() {
episode = configState.episode;
category = configState.category;
} else {
// LÓGICA DEL HEADER (Corregida)
ext = elements.roomExtSelect.value;
server = elements.roomServerSelect.value;
// Intentar leer episodio del texto
let epText = elements.npEpisode.textContent.replace('Ep ', '').trim();
// Fallback robusto: Si dice "--" o está vacío, usar los datos guardados o 1
if (!epText || epText === '--' || isNaN(epText)) {
if (selectedAnimeData.episode) {
epText = selectedAnimeData.episode;
} else {
epText = "1"; // Default absoluto
epText = "1";
}
}
episode = epText;
@@ -617,7 +574,6 @@ const RoomsApp = (function() {
category = elements.roomSdToggle.getAttribute('data-state');
}
// Validación
if (!ext || !server || !episode) {
console.error("Missing params:", { ext, server, episode });
if(fromModal) {
@@ -629,7 +585,6 @@ const RoomsApp = (function() {
return;
}
// Feedback UI
if(fromModal) {
elements.btnLaunch.disabled = true;
elements.btnLaunch.innerHTML = '<div class="spinner" style="width:20px;height:20px;"></div> Fetching...';
@@ -645,7 +600,6 @@ const RoomsApp = (function() {
const data = await res.json();
// Lógica de fuentes (igual que antes)
const source = data.videoSources?.find(s => s.type === 'm3u8') || data.videoSources?.[0];
if (!source) throw new Error('No video source found');
@@ -673,7 +627,7 @@ const RoomsApp = (function() {
},
metadata: {
title: selectedAnimeData.title,
episode: episode, // Usar el episodio corregido
episode: episode,
image: selectedAnimeData.image,
id: selectedAnimeData.id
}
@@ -682,32 +636,21 @@ const RoomsApp = (function() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(videoPayload));
// Carga local
loadVideo(videoPayload.video);
updateHeaderInfo(videoPayload.metadata);
// Si venimos del Modal, sincronizamos los controles rápidos del header
if(fromModal) {
closeAnimeSearchModal();
// --- CORRECCIÓN INICIO ---
// 1. Actualizamos el source en la data global para que coincida con lo que acabas de elegir
selectedAnimeData.source = ext;
// 2. Forzamos el repoblado del dropdown del header (ahora que tenemos anime y extensión)
await populateQuickControls();
// --- CORRECCIÓN FIN ---
// Sincronizar UI del header
if (extensionsStore.list.includes(ext)) {
elements.roomExtSelect.value = ext;
// Forzamos carga silenciosa para llenar los servidores en el select del header
await onQuickExtensionChange(null, true);
elements.roomServerSelect.value = server;
// Sincronizar toggle Dub/Sub
elements.roomSdToggle.setAttribute('data-state', category);
elements.roomSdToggle.querySelectorAll('.sd-option').forEach(o =>
o.classList.toggle('active', o.dataset.val === category)
@@ -737,48 +680,165 @@ const RoomsApp = (function() {
function connectToRoom(roomId, guestName, password) {
const token = localStorage.getItem('token');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/room/${roomId}`;
const isTunnel = window.location.hostname.includes('trycloudflare.com');
let wsUrl;
if (isTunnel) {
wsUrl = `wss://${window.location.host}/ws/room/${roomId}`;
console.log('[Tunnel Mode] Using secure WebSocket:', wsUrl);
} else {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/ws/room/${roomId}`;
console.log('[Local Mode] Using WebSocket:', wsUrl);
}
const params = new URLSearchParams();
if (token) params.append('token', token);
if (guestName) params.append('guestName', guestName);
if (password) params.append('password', password);
if (ws) ws.close();
if (ws) {
console.log('Closing existing WebSocket...');
ws.close();
}
console.log('Connecting to:', `${wsUrl}?${params.toString()}`);
ws = new WebSocket(`${wsUrl}?${params.toString()}`);
ws.onopen = () => {
console.log('WebSocket Connected');
console.log('WebSocket Connected');
if (window.AnimePlayer && typeof window.AnimePlayer.setWebSocket === 'function') {
window.AnimePlayer.setWebSocket(ws);
}
setTimeout(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
console.log('Requesting sync from host...');
ws.send(JSON.stringify({ type: 'request_sync' }));
}
}, 500);
};
ws.onmessage = (e) => handleWebSocketMessage(JSON.parse(e.data));
ws.onerror = (e) => console.error('WebSocket error:', e);
ws.onclose = () => {
console.log('Disconnected');
ws.onerror = (e) => {
console.error('✗ WebSocket error:', e);
showConnectionError('WebSocket connection failed. Check your connection.');
};
ws.onclose = (event) => {
console.log('WebSocket Disconnected:', event.code, event.reason);
if (window.AnimePlayer && typeof window.AnimePlayer.setWebSocket === 'function') {
window.AnimePlayer.setWebSocket(null);
}
if (event.code !== 1000 && event.code !== 1001) {
console.log('Attempting reconnection in 3 seconds...');
showReconnectingToast();
setTimeout(() => {
if (currentRoomId) {
console.log('Reconnecting to room...');
connectToRoom(currentRoomId, guestName, password);
}
}, 3000);
}
};
}
function showReconnectingToast() {
const toast = document.createElement('div');
toast.id = 'reconnecting-toast';
toast.className = 'connection-error-toast';
toast.innerHTML = `
<div style="display:flex; align-items:center; gap:10px;">
<div class="spinner" style="width:16px; height:16px; border-width:2px;"></div>
<span>Reconnecting...</span>
</div>
`;
toast.style.cssText = `
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(251, 191, 36, 0.95);
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
animation: slideDown 0.3s ease-out;
`;
const existing = document.getElementById('reconnecting-toast');
if (existing) existing.remove();
document.body.appendChild(toast);
}
function showConnectionError(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'connection-error-toast';
errorDiv.textContent = message;
errorDiv.style.cssText = `
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(239, 68, 68, 0.95);
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
animation: slideDown 0.3s ease-out;
`;
document.body.appendChild(errorDiv);
setTimeout(() => {
errorDiv.style.animation = 'fadeOut 0.3s ease-out';
setTimeout(() => errorDiv.remove(), 300);
}, 5000);
}
function handleWebSocketMessage(data) {
switch (data.type) {
case 'error':
handleConnectionError(data.message);
break;
case 'init':
const reconnectToast = document.getElementById('reconnecting-toast');
if (reconnectToast) reconnectToast.remove();
elements.joinRoomModal.classList.remove('show');
currentUserId = data.userId;
currentUsername = data.username;
isGuest = data.isGuest;
updateRoomUI(data.room);
if (data.room.currentVideo && data.room.metadata) {
updateHeaderInfo(data.room.metadata);
if (data.room.currentVideo) {
loadVideo(data.room.currentVideo);
if (data.room.metadata) {
updateHeaderInfo(data.room.metadata);
}
if (!isHost) {
console.log('Video detected on join, requesting sync...');
setTimeout(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'request_sync' }));
}
}, 1000);
}
}
break;
@@ -791,29 +851,27 @@ const RoomsApp = (function() {
updateUsersList();
if (isHost) {
sendSync();
console.log('New user joined, sending sync...');
setTimeout(() => sendSync(), 500);
}
break;
case 'user_left':
addSystemMessage(`${data.user.username} left`);
updateUsersList();
break;
case 'chat':
addChatMessage(data); // Siempre añadir al historial del chat lateral
// Comprobar si el chat está oculto
addChatMessage(data);
const isChatHidden = elements.roomLayout.classList.contains('chat-hidden');
if (isChatHidden) {
// 1. Mostrar Toast sobre el video
showChatToast(data);
// 2. Poner punto rojo en el botón
if (elements.toggleChatBtn) {
elements.toggleChatBtn.classList.add('has-unread');
}
}
break;
case 'video_update':
loadVideo(data.video);
if (data.metadata) {
@@ -823,25 +881,53 @@ const RoomsApp = (function() {
};
updateHeaderInfo(data.metadata);
}
if (!isHost) {
setTimeout(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
console.log('New video loaded, requesting sync...');
ws.send(JSON.stringify({ type: 'request_sync' }));
}
}, 1500);
}
break;
case 'sync':
console.log('Received sync:', data.currentTime, data.isPlaying ? 'playing' : 'paused');
syncVideo(data.currentTime, data.isPlaying);
updatePlayPauseButton(data.isPlaying);
break;
case 'play':
const vP = getVideoElement();
if(vP) { vP.currentTime = data.currentTime; vP.play().catch(console.error); updatePlayPauseButton(true); }
if(vP) {
vP.currentTime = data.currentTime;
vP.play().catch(console.error);
updatePlayPauseButton(true);
}
break;
case 'pause':
const vPa = getVideoElement();
if(vPa) { vPa.currentTime = data.currentTime; vPa.pause(); updatePlayPauseButton(false); }
if(vPa) {
vPa.currentTime = data.currentTime;
vPa.pause();
updatePlayPauseButton(false);
}
break;
case 'seek':
const vS = getVideoElement();
if(vS) { vS.currentTime = data.currentTime; }
if(vS) {
vS.currentTime = data.currentTime;
}
break;
case 'sync_requested':
if (isHost) sendSync();
if (isHost) {
console.log('Sync requested, sending current state...');
sendSync();
}
break;
}
}
@@ -853,7 +939,6 @@ const RoomsApp = (function() {
const currentUser = room.users.find(u => u.id === currentUserId);
isHost = currentUser?.isHost || false;
// Mostrar controles solo si es Host
if (elements.selectAnimeBtn) elements.selectAnimeBtn.style.display = isHost ? 'flex' : 'none';
if (elements.hostControls) elements.hostControls.style.display = isHost ? 'flex' : 'none';
@@ -861,7 +946,49 @@ const RoomsApp = (function() {
window.AnimePlayer.setRoomHost(isHost);
}
// Si somos host y tenemos metadatos, poblar los controles del header
const copyInviteBtn = document.getElementById('copy-invite-btn');
if (copyInviteBtn) {
let inviteUrl = null;
if (window.__roomExposed && window.__roomPublicUrl) {
inviteUrl = window.__roomPublicUrl;
} else {
inviteUrl = `${window.location.origin}/room?id=${room.id}`;
}
console.log('Copy button configured with URL:', inviteUrl);
copyInviteBtn.style.display = 'inline-flex';
copyInviteBtn.title = window.__roomExposed
? 'Copy public invite link (works outside your network)'
: 'Copy local invite link (only works on your network)';
copyInviteBtn.onclick = async () => {
try {
console.log('Copying to clipboard:', inviteUrl);
await navigator.clipboard.writeText(inviteUrl);
const originalHTML = copyInviteBtn.innerHTML;
copyInviteBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
`;
copyInviteBtn.style.color = '#4ade80';
setTimeout(() => {
copyInviteBtn.innerHTML = originalHTML;
copyInviteBtn.style.color = '';
}, 2000);
showCopyToast(window.__roomExposed ? 'Public link copied!' : 'Local link copied!');
} catch (err) {
console.error('Failed to copy:', err);
}
};
}
if (isHost && room.metadata) {
if(!selectedAnimeData) selectedAnimeData = { ...room.metadata, source: 'anilist' };
populateQuickControls();
@@ -871,18 +998,44 @@ const RoomsApp = (function() {
if (room.currentVideo) loadVideo(room.currentVideo);
}
function showCopyToast(message) {
const toast = document.createElement('div');
toast.className = 'copy-toast';
toast.textContent = message;
toast.style.cssText = `
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(74, 222, 128, 0.95);
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
animation: slideUp 0.3s ease-out;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'fadeOut 0.3s ease-out';
setTimeout(() => toast.remove(), 300);
}, 2000);
}
function updateHeaderInfo(meta) {
if (!meta) return;
if (elements.npTitle) elements.npTitle.textContent = meta.title;
if (elements.npEpisode) elements.npEpisode.textContent = `Episode ${meta.episode}`;
if (elements.npInfo) elements.npInfo.style.opacity = '1';
// Save data locally so we can use quick controls
if(!selectedAnimeData) selectedAnimeData = { ...meta, source: 'anilist' };
else {
selectedAnimeData.id = meta.id;
selectedAnimeData.title = meta.title;
// Episode is tracked visually in header
}
}
@@ -901,7 +1054,6 @@ const RoomsApp = (function() {
if(!token) document.getElementById('guest-name-input').focus();
} else {
alert(message);
window.location.href = '/anime';
}
}
@@ -911,13 +1063,11 @@ const RoomsApp = (function() {
const password = document.getElementById('join-password-input').value.trim();
const passwordGroup = document.getElementById('password-group');
// Validar Nombre para Guest
if (!guestName && !localStorage.getItem('token')) {
alert("Please enter a name");
return;
}
// Validar Password si es requerida y está visible
if (passwordGroup.style.display !== 'none' && !password) {
alert("This room requires a password");
return;
@@ -926,17 +1076,13 @@ const RoomsApp = (function() {
connectToRoom(currentRoomId, guestName, password);
}
// room.js - Reemplazar toggleChat
function toggleChat() {
if (elements.roomLayout) {
elements.roomLayout.classList.toggle('chat-hidden');
// Si acabamos de ABRIR el chat (ya no tiene la clase chat-hidden)
if (!elements.roomLayout.classList.contains('chat-hidden')) {
// Quitar notificación roja
elements.toggleChatBtn.classList.remove('has-unread');
// Opcional: Limpiar los toasts flotantes para que no estorben
if(elements.toastContainer) elements.toastContainer.innerHTML = '';
}
@@ -947,12 +1093,10 @@ const RoomsApp = (function() {
function showChatToast(data) {
if (!elements.toastContainer) return;
// Crear elemento
const toast = document.createElement('div');
toast.className = 'video-toast';
// Avatar (usar el mismo fallback que el chat)
const avatarSrc = data.avatar || '/public/assets/placeholder.png'; // Asegúrate de tener un placeholder o lógica de iniciales
const avatarSrc = data.avatar || '/public/assets/placeholder.png';
toast.innerHTML = `
<img src="${avatarSrc}" class="toast-avatar" onerror="this.style.display='none'">
@@ -962,17 +1106,14 @@ const RoomsApp = (function() {
</div>
`;
// Añadir al contenedor
elements.toastContainer.appendChild(toast);
// Eliminar del DOM después de que termine la animación (5s total: 0.3s in + 4.2s wait + 0.5s out)
setTimeout(() => {
if (toast.parentElement) {
toast.remove();
}
}, 5000);
// Limitar número de toasts (máximo 3 al mismo tiempo para no tapar todo el video)
while (elements.toastContainer.children.length > 3) {
elements.toastContainer.removeChild(elements.toastContainer.firstChild);
}
@@ -998,14 +1139,12 @@ const RoomsApp = (function() {
}
function addSystemMessage(text) {
// 1. Agregar al chat (siempre)
const div = document.createElement('div');
div.className = 'chat-message system';
div.innerHTML = `<div class="message-content">${escapeHtml(text)}</div>`;
elements.chatMessages.appendChild(div);
elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight;
// 2. Si el chat está oculto, mostrar notificación flotante
if (elements.roomLayout && elements.roomLayout.classList.contains('chat-hidden')) {
showSystemToast(text);
}
@@ -1015,12 +1154,11 @@ const RoomsApp = (function() {
if (!elements.toastContainer) return;
const toast = document.createElement('div');
toast.className = 'video-toast system-toast'; // Clase especial para diferenciarlo
toast.className = 'video-toast system-toast';
toast.innerHTML = `<span class="toast-msg">${escapeHtml(text)}</span>`;
elements.toastContainer.appendChild(toast);
// Eliminar a los 4 segundos
setTimeout(() => toast.remove(), 4000);
}
@@ -1029,13 +1167,10 @@ const RoomsApp = (function() {
const div = document.createElement('div');
div.className = 'chat-message';
// LÓGICA DE AVATAR ACTUALIZADA
let avatarHtml;
if (data.avatar) {
// Si tiene imagen, usamos img tag
avatarHtml = `<img src="${data.avatar}" alt="${data.username}" style="width:100%; height:100%; object-fit:cover; border-radius:50%;">`;
} else {
// Fallback a inicial
avatarHtml = data.username[0].toUpperCase();
}
@@ -1093,7 +1228,6 @@ const RoomsApp = (function() {
return document.getElementById('player');
}
// Fallback simple video loader
function loadVideoBasic(url, type, videoData) {
elements.loading.style.display = 'none';
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
@@ -1113,13 +1247,39 @@ const RoomsApp = (function() {
function syncVideo(currentTime, isPlaying) {
const video = getVideoElement();
if (!video) return;
if (!video) {
console.warn('Cannot sync: video element not found');
return;
}
const timeDiff = Math.abs(video.currentTime - currentTime);
if (timeDiff > 1) video.currentTime = currentTime;
console.log('Syncing video:', {
targetTime: currentTime,
currentTime: video.currentTime,
diff: timeDiff.toFixed(2) + 's',
targetState: isPlaying ? 'playing' : 'paused',
currentState: video.paused ? 'paused' : 'playing'
});
if (timeDiff > 0.5) {
console.log('Time diff exceeds threshold, seeking to:', currentTime);
video.currentTime = currentTime;
}
if (isPlaying && video.paused) {
video.play().then(() => updatePlayPauseButton(true)).catch(console.error);
console.log('Starting playback...');
video.play()
.then(() => {
console.log('✓ Playback started');
updatePlayPauseButton(true);
})
.catch(err => {
console.error('✗ Playback failed:', err);
showPlaybackBlockedToast();
});
} else if (!isPlaying && !video.paused) {
console.log('Pausing playback...');
video.pause();
updatePlayPauseButton(false);
}
@@ -1127,8 +1287,62 @@ const RoomsApp = (function() {
function sendSync() {
const video = getVideoElement();
if (!video || !ws) return;
ws.send(JSON.stringify({ type: 'sync', currentTime: video.currentTime, isPlaying: !video.paused }));
if (!video) {
console.warn('Cannot send sync: video element not found');
return;
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.warn('Cannot send sync: WebSocket not connected');
return;
}
const syncData = {
type: 'sync',
currentTime: video.currentTime,
isPlaying: !video.paused
};
console.log('Sending sync:', syncData);
ws.send(JSON.stringify(syncData));
}
function showPlaybackBlockedToast() {
const toast = document.createElement('div');
toast.className = 'playback-blocked-toast';
toast.innerHTML = `
<div style="display:flex; flex-direction:column; gap:8px; align-items:center;">
<span>⚠️ Autoplay blocked by browser</span>
<button onclick="this.parentElement.parentElement.remove(); getVideoElement()?.play();"
style="background:white; color:#1a1a2e; border:none; padding:6px 12px; border-radius:6px; cursor:pointer; font-weight:600;">
Click to Play
</button>
</div>
`;
toast.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(251, 191, 36, 0.95);
color: white;
padding: 20px 30px;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
z-index: 10000;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
animation: scaleIn 0.3s ease-out;
`;
document.body.appendChild(toast);
setTimeout(() => {
if (toast.parentElement) {
toast.style.animation = 'fadeOut 0.3s ease-out';
setTimeout(() => toast.remove(), 300);
}
}, 10000);
}
function updatePlayPauseButton(isPlaying) {
@@ -1158,7 +1372,6 @@ const RoomsApp = (function() {
function leaveRoom() {
if (ws) ws.close();
if (hlsInstance) hlsInstance.destroy();
window.location.href = '/anime';
}
function openAnimeSearchModal() {