From 5fe0e319b9f609608a16d33d5fe472f7fdcb5a64 Mon Sep 17 00:00:00 2001 From: lenafx Date: Sun, 4 Jan 2026 19:59:37 +0100 Subject: [PATCH] added public watch parties with cloudflared --- desktop/server.js | 70 ++++ desktop/src/api/rooms/rooms.controller.ts | 35 +- desktop/src/api/rooms/rooms.service.ts | 19 +- desktop/src/api/rooms/rooms.websocket.ts | 48 ++- desktop/src/api/rooms/tunnel.manager.ts | 111 +++++ desktop/src/scripts/room-modal.js | 11 +- desktop/src/scripts/room.js | 471 ++++++++++++++++------ desktop/src/shared/config.js | 6 +- desktop/views/css/room.css | 158 +++++--- desktop/views/room.html | 13 +- docker/server.js | 70 ++++ docker/src/api/rooms/rooms.controller.ts | 35 +- docker/src/api/rooms/rooms.service.ts | 19 +- docker/src/api/rooms/rooms.websocket.ts | 48 ++- docker/src/api/rooms/tunnel.manager.ts | 111 +++++ docker/src/scripts/room-modal.js | 11 +- docker/src/scripts/room.js | 471 ++++++++++++++++------ docker/src/shared/config.js | 6 +- docker/views/css/room.css | 158 +++++--- docker/views/room.html | 13 +- 20 files changed, 1426 insertions(+), 458 deletions(-) create mode 100644 desktop/src/api/rooms/tunnel.manager.ts create mode 100644 docker/src/api/rooms/tunnel.manager.ts diff --git a/desktop/server.js b/desktop/server.js index cf3a7b6..2630104 100644 --- a/desktop/server.js +++ b/desktop/server.js @@ -47,6 +47,76 @@ fastify.addHook("preHandler", async (request) => { } }); +const roomService = require('./electron/api/rooms/rooms.service'); + +fastify.addHook('onRequest', async (req, reply) => { + const isTunnel = + !!req.headers['cf-connecting-ip'] || + !!req.headers['cf-ray']; + + if (!isTunnel) return; + + if (req.url.startsWith('/public/') || + req.url.startsWith('/views/') || + req.url.startsWith('/src/')) { + return; + } + + if (req.url.startsWith('/room')) { + const urlParams = new URLSearchParams(req.url.split('?')[1]); + const roomId = urlParams.get('id'); + + if (!roomId) { + return reply.code(404).send({ error: 'Room ID required' }); + } + + const room = roomService.getRoom(roomId); + if (!room || room.exposed !== true) { + return reply.code(404).send({ error: 'Room not found' }); + } + + return; + } + + const wsMatch = req.url.match(/^\/ws\/room\/([a-f0-9]+)/); + if (wsMatch) { + const roomId = wsMatch[1]; + const room = roomService.getRoom(roomId); + + if (!room || room.exposed !== true) { + return reply.code(404).send({ error: 'Room not found' }); + } + return; + } + + const apiMatch = req.url.match(/^\/api\/rooms\/([a-f0-9]+)/); + if (apiMatch) { + const roomId = apiMatch[1]; + const room = roomService.getRoom(roomId); + + if (!room || room.exposed !== true) { + return reply.code(404).send({ error: 'Room not found' }); + } + return; + } + + const allowedEndpoints = [ + '/api/watch/stream', + '/api/proxy', + '/api/extensions', + '/api/search' + ]; + + for (const endpoint of allowedEndpoints) { + if (req.url.startsWith(endpoint)) { + console.log('[Tunnel] ✓ Allowing utility endpoint:', endpoint); + return; + } + } + + return reply.code(404).send({ error: 'Not found' }); +}); + fastify.register(require("@fastify/static"), { root: path.join(__dirname, "public"), prefix: "/public/", diff --git a/desktop/src/api/rooms/rooms.controller.ts b/desktop/src/api/rooms/rooms.controller.ts index 6f599c3..03c4295 100644 --- a/desktop/src/api/rooms/rooms.controller.ts +++ b/desktop/src/api/rooms/rooms.controller.ts @@ -1,20 +1,17 @@ import { FastifyReply, FastifyRequest } from 'fastify'; import * as roomService from './rooms.service'; import { getUserById } from '../user/user.service'; +import { openTunnel } from "./tunnel.manager"; interface CreateRoomBody { name: string; password?: string; -} - -interface JoinRoomBody { - password?: string; - guestName?: string; + expose?: boolean; } export async function createRoom(req: any, reply: FastifyReply) { try { - const { name, password } = req.body as CreateRoomBody; + const { name, password, expose } = req.body as CreateRoomBody; const userId = req.user?.id; if (!userId) { @@ -39,7 +36,23 @@ export async function createRoom(req: any, reply: FastifyReply) { userId }; - const room = roomService.createRoom(name, host, password); + let publicUrl: string | undefined; + + if (expose) { + publicUrl = await openTunnel(); + } + + const room = roomService.createRoom( + name, + host, + password, + !!expose, + publicUrl + ); + + if (expose && publicUrl) { + room.publicUrl = `${publicUrl}/room?id=${room.id}`; + } return reply.send({ success: true, @@ -47,7 +60,9 @@ export async function createRoom(req: any, reply: FastifyReply) { id: room.id, name: room.name, hasPassword: !!room.password, - userCount: room.users.size + userCount: room.users.size, + exposed: room.exposed, + publicUrl: room.publicUrl } }); } catch (err) { @@ -104,7 +119,9 @@ export async function getRoom(req: FastifyRequest, reply: FastifyReply) { isGuest: u.isGuest })), hasPassword: !!room.password, - currentVideo: room.currentVideo + currentVideo: room.currentVideo, + exposed: room.exposed, + publicUrl: room.publicUrl } }); } catch (err) { diff --git a/desktop/src/api/rooms/rooms.service.ts b/desktop/src/api/rooms/rooms.service.ts index 89b374c..2d06c1f 100644 --- a/desktop/src/api/rooms/rooms.service.ts +++ b/desktop/src/api/rooms/rooms.service.ts @@ -1,4 +1,5 @@ import crypto from 'crypto'; +import { closeTunnelIfUnused } from "./tunnel.manager"; interface RoomUser { id: string; @@ -33,6 +34,8 @@ interface RoomData { } | null; password?: string; metadata?: RoomMetadata | null; + exposed: boolean; + publicUrl?: string; } const rooms = new Map(); @@ -41,7 +44,7 @@ export function generateRoomId(): string { return crypto.randomBytes(8).toString('hex'); } -export function createRoom(name: string, host: RoomUser, password?: string): RoomData { +export function createRoom(name: string, host: RoomUser, password?: string, exposed = false, publicUrl?: string): RoomData { const roomId = generateRoomId(); const room: RoomData = { @@ -53,6 +56,8 @@ export function createRoom(name: string, host: RoomUser, password?: string): Roo currentVideo: null, password: password || undefined, metadata: null, + exposed, + publicUrl }; rooms.set(roomId, room); @@ -84,13 +89,14 @@ export function removeUserFromRoom(roomId: string, userId: string): boolean { room.users.delete(userId); - // Si no quedan usuarios, eliminar la sala if (room.users.size === 0) { + if (room.exposed) { + closeTunnelIfUnused(); + } rooms.delete(roomId); return true; } - // Si era el host, asignar nuevo host if (room.host.id === userId && room.users.size > 0) { const newHost = Array.from(room.users.values())[0]; newHost.isHost = true; @@ -109,6 +115,13 @@ export function updateRoomVideo(roomId: string, videoData: any): boolean { } export function deleteRoom(roomId: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + if (room.exposed) { + closeTunnelIfUnused(); + } + return rooms.delete(roomId); } diff --git a/desktop/src/api/rooms/rooms.websocket.ts b/desktop/src/api/rooms/rooms.websocket.ts index bcd863e..182fee6 100644 --- a/desktop/src/api/rooms/rooms.websocket.ts +++ b/desktop/src/api/rooms/rooms.websocket.ts @@ -226,6 +226,34 @@ function handleMessage(roomId: string, userId: string, data: any) { }); break; + case 'request_sync': + // Cualquier usuario puede pedir sync + const host = clients.get(room.host.id); + if (host && host.socket && host.socket.readyState === 1) { + console.log(`[Sync Request] User ${userId} requested sync from host`); + + host.socket.send(JSON.stringify({ + type: 'sync_requested', + requestedBy: userId, + username: room.users.get(userId)?.username + })); + } else { + console.warn(`[Sync Request] Host not available for user ${userId}`); + + if (room.currentVideo) { + const client = clients.get(userId); + if (client && client.socket && client.socket.readyState === 1) { + console.log(`[Sync Request] Sending cached video state to ${userId}`); + client.socket.send(JSON.stringify({ + type: 'sync', + currentTime: room.currentVideo.currentTime || 0, + isPlaying: room.currentVideo.isPlaying || false + })); + } + } + } + break; + case 'video_update': if (room.host.id !== userId) return; @@ -288,11 +316,10 @@ function handleMessage(roomId: string, userId: string, data: any) { type: 'play', currentTime: data.currentTime, timestamp: Date.now() - }, userId); // IMPORTANTE: excludeUserId para no enviar al host + }, userId); break; case 'pause': - // Solo el host puede controlar la reproducción if (room.host.id !== userId) { console.warn('Non-host attempted pause:', userId); return; @@ -303,11 +330,10 @@ function handleMessage(roomId: string, userId: string, data: any) { type: 'pause', currentTime: data.currentTime, timestamp: Date.now() - }, userId); // IMPORTANTE: excludeUserId para no enviar al host + }, userId); break; case 'seek': - // Solo el host puede controlar la reproducción if (room.host.id !== userId) { console.warn('Non-host attempted seek:', userId); return; @@ -318,22 +344,10 @@ function handleMessage(roomId: string, userId: string, data: any) { type: 'seek', currentTime: data.currentTime, timestamp: Date.now() - }, userId); // IMPORTANTE: excludeUserId para no enviar al host - break; - - case 'request_sync': - // Cualquier usuario puede pedir sync - const host = clients.get(room.host.id); - if (host && host.socket && host.socket.readyState === 1) { - host.socket.send(JSON.stringify({ - type: 'sync_requested', - requestedBy: userId - })); - } + }, userId); break; case 'request_state': - // Enviar estado actual de la sala al usuario que lo solicita const client = clients.get(userId); if (client && client.socket && client.socket.readyState === 1) { const updatedRoom = roomService.getRoom(roomId); diff --git a/desktop/src/api/rooms/tunnel.manager.ts b/desktop/src/api/rooms/tunnel.manager.ts new file mode 100644 index 0000000..15aab27 --- /dev/null +++ b/desktop/src/api/rooms/tunnel.manager.ts @@ -0,0 +1,111 @@ +import { spawn, ChildProcess } from "child_process"; +import { getConfig as loadConfig } from '../../shared/config'; +const { values } = loadConfig(); +const CLOUDFLARED_PATH = values.paths?.cloudflared || 'cloudflared'; + +let tunnelProcess: ChildProcess | null = null; +let exposedRooms = 0; +let publicUrl: string | null = null; +let tunnelPromise: Promise | null = null; + +export function openTunnel(): Promise { + if (tunnelProcess && publicUrl) { + exposedRooms++; + return Promise.resolve(publicUrl); + } + + if (tunnelPromise) { + return tunnelPromise; + } + + tunnelPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timeout esperando URL del túnel (30s)")); + }, 30000); + + tunnelProcess = spawn(CLOUDFLARED_PATH, [ + "tunnel", + "--url", + "http://localhost:54322", + "--no-autoupdate" + ]); + + const cleanup = () => { + clearTimeout(timeout); + tunnelPromise = null; + }; + + let outputBuffer = ""; + + const processOutput = (data: Buffer) => { + const text = data.toString(); + outputBuffer += text; + + const match = outputBuffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/); + if (match && !publicUrl) { + publicUrl = match[0]; + exposedRooms = 1; + cleanup(); + resolve(publicUrl); + } + }; + + tunnelProcess.stdout?.on("data", (data) => { + processOutput(data); + }); + + tunnelProcess.stderr?.on("data", (data) => { + processOutput(data); + }); + + tunnelProcess.on("error", (error) => { + console.error("[Cloudflared Process Error]", error); + cleanup(); + tunnelProcess = null; + reject(error); + }); + + tunnelProcess.on("exit", (code, signal) => { + tunnelProcess = null; + publicUrl = null; + exposedRooms = 0; + + if (!publicUrl) { + cleanup(); + reject(new Error(`Proceso cloudflared terminó antes de obtener URL (código: ${code})`)); + } + }); + }); + + return tunnelPromise; +} + +export function closeTunnelIfUnused() { + exposedRooms--; + console.log(`[Tunnel Manager] Rooms expuestas: ${exposedRooms}`); + + if (exposedRooms <= 0 && tunnelProcess) { + console.log("[Tunnel Manager] Cerrando túnel..."); + tunnelProcess.kill(); + tunnelProcess = null; + publicUrl = null; + exposedRooms = 0; + tunnelPromise = null; + } +} + +export function getTunnelUrl(): string | null { + return publicUrl; +} + +export function forceTunnelClose() { + if (tunnelProcess) { + console.log("[Tunnel Manager] Forzando cierre del túnel..."); + tunnelProcess.kill(); + tunnelProcess = null; + publicUrl = null; + exposedRooms = 0; + tunnelPromise = null; + } +} \ No newline at end of file diff --git a/desktop/src/scripts/room-modal.js b/desktop/src/scripts/room-modal.js index 2a03513..82362ab 100644 --- a/desktop/src/scripts/room-modal.js +++ b/desktop/src/scripts/room-modal.js @@ -24,6 +24,13 @@ class CreateRoomModal { + +
+ +
@@ -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 }) }); diff --git a/desktop/src/scripts/room.js b/desktop/src/scripts/room.js index 8397884..eb69f58 100644 --- a/desktop/src/scripts/room.js +++ b/desktop/src/scripts/room.js @@ -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 = `${escapeHtml(roomInfo.host.username)} 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 = '
Loading servers...
'; @@ -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 = '
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 = ` +
+
+ Reconnecting... +
+ `; + 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 = ` + + + + `; + 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 = ` @@ -962,17 +1106,14 @@ const RoomsApp = (function() {
`; - // 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 = `
${escapeHtml(text)}
`; 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 = `${escapeHtml(text)}`; 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 = `${data.username}`; } 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 = ` +
+ ⚠️ Autoplay blocked by browser + +
+ `; + 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() { diff --git a/desktop/src/shared/config.js b/desktop/src/shared/config.js index 1741424..c656ee0 100644 --- a/desktop/src/shared/config.js +++ b/desktop/src/shared/config.js @@ -14,7 +14,8 @@ const DEFAULT_CONFIG = { }, paths: { mpv: null, - ffmpeg: null + ffmpeg: null, + cloudflared: null, } }; @@ -26,7 +27,8 @@ export const CONFIG_SCHEMA = { }, paths: { mpv: { description: "Required to open anime episodes in mpv on desktop version." }, - ffmpeg: { description: "Required for downloading anime episodes." } + ffmpeg: { description: "Required for downloading anime episodes." }, + cloudflared: { description: "Required for creating pubic rooms." } } }; diff --git a/desktop/views/css/room.css b/desktop/views/css/room.css index 5a5cbe2..d665ffb 100644 --- a/desktop/views/css/room.css +++ b/desktop/views/css/room.css @@ -1,6 +1,3 @@ -/* ========================================= - 1. VARIABLES & UTILITIES - ========================================= */ :root { --brand-color: #8b5cf6; --brand-gradient: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%); @@ -11,7 +8,6 @@ --text-muted: rgba(255, 255, 255, 0.6); } -/* Scrollbar Styles */ ::-webkit-scrollbar { width: 6px; height: 6px; @@ -38,11 +34,6 @@ @keyframes spin { to { transform: rotate(360deg); } } -/* ========================================= - 2. UI COMPONENTS (Buttons, Inputs, Chips) - ========================================= */ - -/* Glass Buttons & Icons */ .btn-icon-glass, .btn-icon-small, .modal-close { appearance: none; background: rgba(255, 255, 255, 0.05); @@ -91,7 +82,6 @@ transform: translateY(-1px); } -/* Primary/Confirm Buttons */ .btn-confirm, .btn-primary { background: var(--brand-color); border: none; @@ -126,7 +116,6 @@ } .btn-cancel:hover { background: rgba(255, 255, 255, 0.1); color: white; } -/* Inputs & Selects */ input[type="text"], input[type="password"], input[type="number"], .form-input { width: 100%; background: rgba(255, 255, 255, 0.05); @@ -144,16 +133,15 @@ input:focus, .form-input:focus { background: rgba(255, 255, 255, 0.08); } -/* Glass Select (Header Style - FIX: Better alignment) */ .glass-select-sm { appearance: none; -webkit-appearance: none; background-color: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); color: #eee; - padding: 0 32px 0 12px; /* Extra padding right for arrow */ + padding: 0 32px 0 12px; height: 32px; - line-height: 30px; /* Vertically center text */ + line-height: 30px; border-radius: 8px; font-size: 0.85rem; font-weight: 600; @@ -164,7 +152,6 @@ input:focus, .form-input:focus { white-space: nowrap; text-overflow: ellipsis; - /* SVG Arrow */ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.6)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; @@ -178,7 +165,6 @@ input:focus, .form-input:focus { } .glass-select-sm option { background: #1a1a1a; color: #e0e0e0; } -/* Chips (Config Modal) */ .chips-grid { display: flex; flex-wrap: wrap; gap: 10px; } .chip { padding: 8px 16px; @@ -200,9 +186,6 @@ input:focus, .form-input:focus { } .chip.disabled { opacity: 0.5; pointer-events: none; filter: grayscale(1); } -/* ========================================= - 3. ROOM LAYOUT (The Watch Page) - ========================================= */ .room-layout { display: grid; grid-template-columns: 1fr 380px; @@ -225,7 +208,6 @@ input:focus, .form-input:focus { position: relative; } -/* Responsive Layout */ @media (max-width: 1200px) { .room-layout { grid-template-columns: 1fr; @@ -241,9 +223,6 @@ input:focus, .form-input:focus { } } -/* ========================================= - 4. ROOM HEADER - ========================================= */ .room-header { display: flex; justify-content: space-between; @@ -265,7 +244,6 @@ input:focus, .form-input:focus { } .header-right { justify-content: flex-end; } -/* Info Section */ .room-info { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; } #room-name { margin: 0; @@ -286,7 +264,6 @@ input:focus, .form-input:focus { font-weight: 700; } -/* Host Controls (Center) - FIX: Better alignment container */ .header-center { flex: 2; display: flex; justify-content: center; z-index: 50; } .quick-controls-group { @@ -295,16 +272,15 @@ input:focus, .form-input:focus { gap: 8px; background: rgba(20, 20, 20, 0.6); backdrop-filter: blur(12px); - padding: 4px 8px; /* Slightly tighter padding */ + padding: 4px 8px; border-radius: 10px; border: 1px solid var(--glass-border); box-shadow: 0 4px 12px rgba(0,0,0,0.2); transition: all 0.2s ease; - height: 42px; /* Explicit height to align children */ + height: 42px; } .quick-controls-group:hover { border-color: rgba(255,255,255,0.15); background: rgba(30, 30, 30, 0.7); } -/* Toggle Sub/Dub Mini */ .sd-toggle.small { height: 32px; background: rgba(255, 255, 255, 0.05); @@ -340,7 +316,6 @@ input:focus, .form-input:focus { } .sd-toggle.small .sd-option.active { color: #fff; } -/* Viewers Pill */ .viewers-pill { display: flex; align-items: center; @@ -354,9 +329,6 @@ input:focus, .form-input:focus { height: 32px; } -/* ========================================= - 5. VIDEO PLAYER AREA - ========================================= */ .player-wrapper { display: flex !important; flex-direction: column; @@ -394,7 +366,6 @@ input:focus, .form-input:focus { max-height: 100%; } -/* Custom Controls Layout Fixes */ .custom-controls { position: absolute; bottom: 0; @@ -405,7 +376,6 @@ input:focus, .form-input:focus { padding: 20px 10px 10px; } -/* FIX: Ensure left controls stay in one line (time display fix) */ .controls-left { display: flex; align-items: center; @@ -413,14 +383,13 @@ input:focus, .form-input:focus { } .time-display { - white-space: nowrap; /* Prevent line break */ - font-variant-numeric: tabular-nums; /* Monospaced numbers prevent jitter */ + white-space: nowrap; + font-variant-numeric: tabular-nums; font-size: 0.9rem; color: #ddd; min-width: fit-content; } -/* Subtitles Canvas */ #subtitles-canvas { position: absolute; top: 0; left: 0; @@ -429,7 +398,6 @@ input:focus, .form-input:focus { z-index: 10; } -/* Hide unused player buttons in Room Mode */ #download-btn, #manual-match-btn, #server-select, @@ -439,7 +407,6 @@ input:focus, .form-input:focus { display: none !important; } -/* Settings Panel Position Fix */ .settings-panel { position: absolute; bottom: 70px; @@ -452,15 +419,13 @@ input:focus, .form-input:focus { border-radius: 8px; } -/* ========================================= - 6. CHAT SIDEBAR - ========================================= */ .chat-sidebar { display: flex; flex-direction: column; background: rgba(15, 15, 15, 0.95); border-left: 1px solid var(--glass-border); height: 100%; + overflow: hidden; } .room-layout.chat-hidden .chat-sidebar { opacity: 0; @@ -477,7 +442,6 @@ input:focus, .form-input:focus { } .chat-header h3 { margin: 0; font-size: 1.1rem; font-weight: 700; color: white; } -/* User List */ .users-list { padding: 12px; border-bottom: 1px solid var(--glass-border); @@ -516,7 +480,6 @@ input:focus, .form-input:focus { font-weight: 600; } -/* Messages */ .chat-messages { flex: 1; overflow-y: auto; @@ -524,6 +487,7 @@ input:focus, .form-input:focus { display: flex; flex-direction: column; gap: 12px; + min-height: 0; } .chat-message { display: flex; gap: 10px; } .chat-message.system { justify-content: center; margin: 8px 0; } @@ -558,7 +522,6 @@ input:focus, .form-input:focus { word-wrap: break-word; } -/* Input Area */ .chat-input { display: flex; gap: 8px; @@ -585,9 +548,6 @@ input:focus, .form-input:focus { } .chat-input button:hover { background: #7c3aed; } -/* ========================================= - 7. MODALS & CONFIGURATION - ========================================= */ .modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.8); @@ -617,12 +577,10 @@ input:focus, .form-input:focus { .modal-header-row { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; } .modal-header-row .modal-title { margin: 0; } -/* Forms inside modal */ .form-group { margin-bottom: 20px; } .form-group label { display: block; margin-bottom: 8px; font-weight: 600; color: white; } .form-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; } -/* Search Step */ .anime-search-content { max-width: 800px; max-height: 85vh; display: flex; flex-direction: column; } .search-bar { display: flex; gap: 12px; margin-bottom: 20px; } .search-bar button { @@ -653,7 +611,6 @@ input:focus, .form-input:focus { .search-title { font-weight: 700; color: white; margin-bottom: 4px; font-size: 1rem; } .search-meta { font-size: 0.85rem; color: var(--text-muted); } -/* Config Step (Unified UI) */ .config-layout { display: flex; gap: 24px; margin-top: 20px; } .config-sidebar { width: 140px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 12px; } .config-cover { @@ -668,7 +625,6 @@ input:focus, .form-input:focus { margin-bottom: 8px; font-weight: 700; } -/* Episode Stepper */ .ep-control { display: flex; align-items: center; background: rgba(255,255,255,0.05); @@ -689,7 +645,6 @@ input:focus, .form-input:focus { font-weight: 700; outline: none; } -/* Category Toggle */ .cat-toggle { display: flex; background: rgba(0,0,0,0.3); padding: 4px; border-radius: 10px; width: fit-content; @@ -714,9 +669,6 @@ input:focus, .form-input:focus { .ep-control { width: auto; flex: 1; } } -/* ========================================= - 8. ROOM LIST / LOBBY (If used externally) - ========================================= */ .container { max-width: 1400px; margin: 0 auto; padding: 80px 40px 40px; } .header h1 { font-size: 2.5rem; font-weight: 800; @@ -772,7 +724,7 @@ input:focus, .form-input:focus { height: 100%; border-radius: 50%; object-fit: cover; - border: 3px solid #1a1a1a; /* Borde oscuro para separar del gradiente */ + border: 3px solid #1a1a1a; background: #2a2a2a; } @@ -796,13 +748,13 @@ input:focus, .form-input:focus { .video-toast-container { position: absolute; - bottom: 100px; /* Encima de la barra de controles */ + bottom: 100px; left: 20px; - z-index: 80; /* Por encima del video, debajo de los controles */ + z-index: 80; display: flex; flex-direction: column; gap: 10px; - pointer-events: none; /* Permitir clicks a través de ellos */ + pointer-events: none; max-width: 400px; } @@ -819,9 +771,8 @@ input:focus, .form-input:focus { font-size: 0.9rem; box-shadow: 0 4px 12px rgba(0,0,0,0.3); - /* Animación de entrada y salida */ animation: toastSlideIn 0.3s ease forwards, toastFadeOut 0.5s ease 4.5s forwards; - pointer-events: auto; /* Permitir seleccionar texto si se quiere */ + pointer-events: auto; opacity: 0; } @@ -850,7 +801,6 @@ input:focus, .form-input:focus { margin-top: 2px; } -/* --- BADGE DE NOTIFICACIÓN (Punto Rojo) --- */ #toggle-chat-btn { position: relative; } @@ -862,7 +812,7 @@ input:focus, .form-input:focus { right: 2px; width: 10px; height: 10px; - background-color: #ef4444; /* Rojo */ + background-color: #ef4444; border: 2px solid #1a1a1a; border-radius: 50%; animation: pulse 2s infinite; @@ -885,11 +835,11 @@ input:focus, .form-input:focus { } .video-toast.system-toast { - border-left-color: #9ca3af; /* Borde gris */ - background: rgba(20, 20, 20, 0.7); /* Un poco más transparente */ + border-left-color: #9ca3af; + background: rgba(20, 20, 20, 0.7); justify-content: center; padding: 6px 12px; - min-height: auto; /* Más compacto */ + min-height: auto; } .video-toast.system-toast .toast-msg { @@ -897,4 +847,78 @@ input:focus, .form-input:focus { font-style: italic; color: rgba(255, 255, 255, 0.8); margin: 0; +} + +@keyframes slideUp { + from { + transform: translateX(-50%) translateY(20px); + opacity: 0; + } + to { + transform: translateX(-50%) translateY(0); + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes slideUp { + from { + transform: translateX(-50%) translateY(20px); + opacity: 0; + } + to { + transform: translateX(-50%) translateY(0); + opacity: 1; + } +} + +@keyframes slideDown { + from { + transform: translateX(-50%) translateY(-20px); + opacity: 0; + } + to { + transform: translateX(-50%) translateY(0); + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +#copy-invite-btn { + transition: all 0.3s ease; +} + +#copy-invite-btn:hover { + transform: scale(1.05); +} + +#copy-invite-btn:active { + transform: scale(0.95); +} + +@keyframes scaleIn { + from { + transform: translate(-50%, -50%) scale(0.8); + opacity: 0; + } + to { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } } \ No newline at end of file diff --git a/desktop/views/room.html b/desktop/views/room.html index 2347e6d..1ba9a2f 100644 --- a/desktop/views/room.html +++ b/desktop/views/room.html @@ -33,7 +33,7 @@
-
@@ -61,6 +61,17 @@ +
diff --git a/docker/server.js b/docker/server.js index e1be721..7033e1a 100644 --- a/docker/server.js +++ b/docker/server.js @@ -41,6 +41,76 @@ fastify.addHook("preHandler", async (request) => { } }); +const roomService = require('./dist/api/rooms/rooms.service'); + +fastify.addHook('onRequest', async (req, reply) => { + const isTunnel = + !!req.headers['cf-connecting-ip'] || + !!req.headers['cf-ray']; + + if (!isTunnel) return; + + if (req.url.startsWith('/public/') || + req.url.startsWith('/views/') || + req.url.startsWith('/src/')) { + return; + } + + if (req.url.startsWith('/room')) { + const urlParams = new URLSearchParams(req.url.split('?')[1]); + const roomId = urlParams.get('id'); + + if (!roomId) { + return reply.code(404).send({ error: 'Room ID required' }); + } + + const room = roomService.getRoom(roomId); + if (!room || room.exposed !== true) { + return reply.code(404).send({ error: 'Room not found' }); + } + + return; + } + + const wsMatch = req.url.match(/^\/ws\/room\/([a-f0-9]+)/); + if (wsMatch) { + const roomId = wsMatch[1]; + const room = roomService.getRoom(roomId); + + if (!room || room.exposed !== true) { + return reply.code(404).send({ error: 'Room not found' }); + } + return; + } + + const apiMatch = req.url.match(/^\/api\/rooms\/([a-f0-9]+)/); + if (apiMatch) { + const roomId = apiMatch[1]; + const room = roomService.getRoom(roomId); + + if (!room || room.exposed !== true) { + return reply.code(404).send({ error: 'Room not found' }); + } + return; + } + + const allowedEndpoints = [ + '/api/watch/stream', + '/api/proxy', + '/api/extensions', + '/api/search' + ]; + + for (const endpoint of allowedEndpoints) { + if (req.url.startsWith(endpoint)) { + console.log('[Tunnel] ✓ Allowing utility endpoint:', endpoint); + return; + } + } + + return reply.code(404).send({ error: 'Not found' }); +}); + fastify.register(require("@fastify/static"), { root: path.join(__dirname, "public"), prefix: "/public/", diff --git a/docker/src/api/rooms/rooms.controller.ts b/docker/src/api/rooms/rooms.controller.ts index 6f599c3..03c4295 100644 --- a/docker/src/api/rooms/rooms.controller.ts +++ b/docker/src/api/rooms/rooms.controller.ts @@ -1,20 +1,17 @@ import { FastifyReply, FastifyRequest } from 'fastify'; import * as roomService from './rooms.service'; import { getUserById } from '../user/user.service'; +import { openTunnel } from "./tunnel.manager"; interface CreateRoomBody { name: string; password?: string; -} - -interface JoinRoomBody { - password?: string; - guestName?: string; + expose?: boolean; } export async function createRoom(req: any, reply: FastifyReply) { try { - const { name, password } = req.body as CreateRoomBody; + const { name, password, expose } = req.body as CreateRoomBody; const userId = req.user?.id; if (!userId) { @@ -39,7 +36,23 @@ export async function createRoom(req: any, reply: FastifyReply) { userId }; - const room = roomService.createRoom(name, host, password); + let publicUrl: string | undefined; + + if (expose) { + publicUrl = await openTunnel(); + } + + const room = roomService.createRoom( + name, + host, + password, + !!expose, + publicUrl + ); + + if (expose && publicUrl) { + room.publicUrl = `${publicUrl}/room?id=${room.id}`; + } return reply.send({ success: true, @@ -47,7 +60,9 @@ export async function createRoom(req: any, reply: FastifyReply) { id: room.id, name: room.name, hasPassword: !!room.password, - userCount: room.users.size + userCount: room.users.size, + exposed: room.exposed, + publicUrl: room.publicUrl } }); } catch (err) { @@ -104,7 +119,9 @@ export async function getRoom(req: FastifyRequest, reply: FastifyReply) { isGuest: u.isGuest })), hasPassword: !!room.password, - currentVideo: room.currentVideo + currentVideo: room.currentVideo, + exposed: room.exposed, + publicUrl: room.publicUrl } }); } catch (err) { diff --git a/docker/src/api/rooms/rooms.service.ts b/docker/src/api/rooms/rooms.service.ts index 89b374c..2d06c1f 100644 --- a/docker/src/api/rooms/rooms.service.ts +++ b/docker/src/api/rooms/rooms.service.ts @@ -1,4 +1,5 @@ import crypto from 'crypto'; +import { closeTunnelIfUnused } from "./tunnel.manager"; interface RoomUser { id: string; @@ -33,6 +34,8 @@ interface RoomData { } | null; password?: string; metadata?: RoomMetadata | null; + exposed: boolean; + publicUrl?: string; } const rooms = new Map(); @@ -41,7 +44,7 @@ export function generateRoomId(): string { return crypto.randomBytes(8).toString('hex'); } -export function createRoom(name: string, host: RoomUser, password?: string): RoomData { +export function createRoom(name: string, host: RoomUser, password?: string, exposed = false, publicUrl?: string): RoomData { const roomId = generateRoomId(); const room: RoomData = { @@ -53,6 +56,8 @@ export function createRoom(name: string, host: RoomUser, password?: string): Roo currentVideo: null, password: password || undefined, metadata: null, + exposed, + publicUrl }; rooms.set(roomId, room); @@ -84,13 +89,14 @@ export function removeUserFromRoom(roomId: string, userId: string): boolean { room.users.delete(userId); - // Si no quedan usuarios, eliminar la sala if (room.users.size === 0) { + if (room.exposed) { + closeTunnelIfUnused(); + } rooms.delete(roomId); return true; } - // Si era el host, asignar nuevo host if (room.host.id === userId && room.users.size > 0) { const newHost = Array.from(room.users.values())[0]; newHost.isHost = true; @@ -109,6 +115,13 @@ export function updateRoomVideo(roomId: string, videoData: any): boolean { } export function deleteRoom(roomId: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + if (room.exposed) { + closeTunnelIfUnused(); + } + return rooms.delete(roomId); } diff --git a/docker/src/api/rooms/rooms.websocket.ts b/docker/src/api/rooms/rooms.websocket.ts index bcd863e..182fee6 100644 --- a/docker/src/api/rooms/rooms.websocket.ts +++ b/docker/src/api/rooms/rooms.websocket.ts @@ -226,6 +226,34 @@ function handleMessage(roomId: string, userId: string, data: any) { }); break; + case 'request_sync': + // Cualquier usuario puede pedir sync + const host = clients.get(room.host.id); + if (host && host.socket && host.socket.readyState === 1) { + console.log(`[Sync Request] User ${userId} requested sync from host`); + + host.socket.send(JSON.stringify({ + type: 'sync_requested', + requestedBy: userId, + username: room.users.get(userId)?.username + })); + } else { + console.warn(`[Sync Request] Host not available for user ${userId}`); + + if (room.currentVideo) { + const client = clients.get(userId); + if (client && client.socket && client.socket.readyState === 1) { + console.log(`[Sync Request] Sending cached video state to ${userId}`); + client.socket.send(JSON.stringify({ + type: 'sync', + currentTime: room.currentVideo.currentTime || 0, + isPlaying: room.currentVideo.isPlaying || false + })); + } + } + } + break; + case 'video_update': if (room.host.id !== userId) return; @@ -288,11 +316,10 @@ function handleMessage(roomId: string, userId: string, data: any) { type: 'play', currentTime: data.currentTime, timestamp: Date.now() - }, userId); // IMPORTANTE: excludeUserId para no enviar al host + }, userId); break; case 'pause': - // Solo el host puede controlar la reproducción if (room.host.id !== userId) { console.warn('Non-host attempted pause:', userId); return; @@ -303,11 +330,10 @@ function handleMessage(roomId: string, userId: string, data: any) { type: 'pause', currentTime: data.currentTime, timestamp: Date.now() - }, userId); // IMPORTANTE: excludeUserId para no enviar al host + }, userId); break; case 'seek': - // Solo el host puede controlar la reproducción if (room.host.id !== userId) { console.warn('Non-host attempted seek:', userId); return; @@ -318,22 +344,10 @@ function handleMessage(roomId: string, userId: string, data: any) { type: 'seek', currentTime: data.currentTime, timestamp: Date.now() - }, userId); // IMPORTANTE: excludeUserId para no enviar al host - break; - - case 'request_sync': - // Cualquier usuario puede pedir sync - const host = clients.get(room.host.id); - if (host && host.socket && host.socket.readyState === 1) { - host.socket.send(JSON.stringify({ - type: 'sync_requested', - requestedBy: userId - })); - } + }, userId); break; case 'request_state': - // Enviar estado actual de la sala al usuario que lo solicita const client = clients.get(userId); if (client && client.socket && client.socket.readyState === 1) { const updatedRoom = roomService.getRoom(roomId); diff --git a/docker/src/api/rooms/tunnel.manager.ts b/docker/src/api/rooms/tunnel.manager.ts new file mode 100644 index 0000000..15aab27 --- /dev/null +++ b/docker/src/api/rooms/tunnel.manager.ts @@ -0,0 +1,111 @@ +import { spawn, ChildProcess } from "child_process"; +import { getConfig as loadConfig } from '../../shared/config'; +const { values } = loadConfig(); +const CLOUDFLARED_PATH = values.paths?.cloudflared || 'cloudflared'; + +let tunnelProcess: ChildProcess | null = null; +let exposedRooms = 0; +let publicUrl: string | null = null; +let tunnelPromise: Promise | null = null; + +export function openTunnel(): Promise { + if (tunnelProcess && publicUrl) { + exposedRooms++; + return Promise.resolve(publicUrl); + } + + if (tunnelPromise) { + return tunnelPromise; + } + + tunnelPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timeout esperando URL del túnel (30s)")); + }, 30000); + + tunnelProcess = spawn(CLOUDFLARED_PATH, [ + "tunnel", + "--url", + "http://localhost:54322", + "--no-autoupdate" + ]); + + const cleanup = () => { + clearTimeout(timeout); + tunnelPromise = null; + }; + + let outputBuffer = ""; + + const processOutput = (data: Buffer) => { + const text = data.toString(); + outputBuffer += text; + + const match = outputBuffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/); + if (match && !publicUrl) { + publicUrl = match[0]; + exposedRooms = 1; + cleanup(); + resolve(publicUrl); + } + }; + + tunnelProcess.stdout?.on("data", (data) => { + processOutput(data); + }); + + tunnelProcess.stderr?.on("data", (data) => { + processOutput(data); + }); + + tunnelProcess.on("error", (error) => { + console.error("[Cloudflared Process Error]", error); + cleanup(); + tunnelProcess = null; + reject(error); + }); + + tunnelProcess.on("exit", (code, signal) => { + tunnelProcess = null; + publicUrl = null; + exposedRooms = 0; + + if (!publicUrl) { + cleanup(); + reject(new Error(`Proceso cloudflared terminó antes de obtener URL (código: ${code})`)); + } + }); + }); + + return tunnelPromise; +} + +export function closeTunnelIfUnused() { + exposedRooms--; + console.log(`[Tunnel Manager] Rooms expuestas: ${exposedRooms}`); + + if (exposedRooms <= 0 && tunnelProcess) { + console.log("[Tunnel Manager] Cerrando túnel..."); + tunnelProcess.kill(); + tunnelProcess = null; + publicUrl = null; + exposedRooms = 0; + tunnelPromise = null; + } +} + +export function getTunnelUrl(): string | null { + return publicUrl; +} + +export function forceTunnelClose() { + if (tunnelProcess) { + console.log("[Tunnel Manager] Forzando cierre del túnel..."); + tunnelProcess.kill(); + tunnelProcess = null; + publicUrl = null; + exposedRooms = 0; + tunnelPromise = null; + } +} \ No newline at end of file diff --git a/docker/src/scripts/room-modal.js b/docker/src/scripts/room-modal.js index 2a03513..82362ab 100644 --- a/docker/src/scripts/room-modal.js +++ b/docker/src/scripts/room-modal.js @@ -24,6 +24,13 @@ class CreateRoomModal {
+ +
+ +
@@ -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 }) }); diff --git a/docker/src/scripts/room.js b/docker/src/scripts/room.js index 8397884..eb69f58 100644 --- a/docker/src/scripts/room.js +++ b/docker/src/scripts/room.js @@ -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 = `${escapeHtml(roomInfo.host.username)} 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 = '
Loading servers...
'; @@ -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 = '
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 = ` +
+
+ Reconnecting... +
+ `; + 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 = ` + + + + `; + 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 = ` @@ -962,17 +1106,14 @@ const RoomsApp = (function() {
`; - // 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 = `
${escapeHtml(text)}
`; 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 = `${escapeHtml(text)}`; 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 = `${data.username}`; } 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 = ` +
+ ⚠️ Autoplay blocked by browser + +
+ `; + 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() { diff --git a/docker/src/shared/config.js b/docker/src/shared/config.js index 1741424..c656ee0 100644 --- a/docker/src/shared/config.js +++ b/docker/src/shared/config.js @@ -14,7 +14,8 @@ const DEFAULT_CONFIG = { }, paths: { mpv: null, - ffmpeg: null + ffmpeg: null, + cloudflared: null, } }; @@ -26,7 +27,8 @@ export const CONFIG_SCHEMA = { }, paths: { mpv: { description: "Required to open anime episodes in mpv on desktop version." }, - ffmpeg: { description: "Required for downloading anime episodes." } + ffmpeg: { description: "Required for downloading anime episodes." }, + cloudflared: { description: "Required for creating pubic rooms." } } }; diff --git a/docker/views/css/room.css b/docker/views/css/room.css index 5a5cbe2..d665ffb 100644 --- a/docker/views/css/room.css +++ b/docker/views/css/room.css @@ -1,6 +1,3 @@ -/* ========================================= - 1. VARIABLES & UTILITIES - ========================================= */ :root { --brand-color: #8b5cf6; --brand-gradient: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%); @@ -11,7 +8,6 @@ --text-muted: rgba(255, 255, 255, 0.6); } -/* Scrollbar Styles */ ::-webkit-scrollbar { width: 6px; height: 6px; @@ -38,11 +34,6 @@ @keyframes spin { to { transform: rotate(360deg); } } -/* ========================================= - 2. UI COMPONENTS (Buttons, Inputs, Chips) - ========================================= */ - -/* Glass Buttons & Icons */ .btn-icon-glass, .btn-icon-small, .modal-close { appearance: none; background: rgba(255, 255, 255, 0.05); @@ -91,7 +82,6 @@ transform: translateY(-1px); } -/* Primary/Confirm Buttons */ .btn-confirm, .btn-primary { background: var(--brand-color); border: none; @@ -126,7 +116,6 @@ } .btn-cancel:hover { background: rgba(255, 255, 255, 0.1); color: white; } -/* Inputs & Selects */ input[type="text"], input[type="password"], input[type="number"], .form-input { width: 100%; background: rgba(255, 255, 255, 0.05); @@ -144,16 +133,15 @@ input:focus, .form-input:focus { background: rgba(255, 255, 255, 0.08); } -/* Glass Select (Header Style - FIX: Better alignment) */ .glass-select-sm { appearance: none; -webkit-appearance: none; background-color: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); color: #eee; - padding: 0 32px 0 12px; /* Extra padding right for arrow */ + padding: 0 32px 0 12px; height: 32px; - line-height: 30px; /* Vertically center text */ + line-height: 30px; border-radius: 8px; font-size: 0.85rem; font-weight: 600; @@ -164,7 +152,6 @@ input:focus, .form-input:focus { white-space: nowrap; text-overflow: ellipsis; - /* SVG Arrow */ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.6)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; @@ -178,7 +165,6 @@ input:focus, .form-input:focus { } .glass-select-sm option { background: #1a1a1a; color: #e0e0e0; } -/* Chips (Config Modal) */ .chips-grid { display: flex; flex-wrap: wrap; gap: 10px; } .chip { padding: 8px 16px; @@ -200,9 +186,6 @@ input:focus, .form-input:focus { } .chip.disabled { opacity: 0.5; pointer-events: none; filter: grayscale(1); } -/* ========================================= - 3. ROOM LAYOUT (The Watch Page) - ========================================= */ .room-layout { display: grid; grid-template-columns: 1fr 380px; @@ -225,7 +208,6 @@ input:focus, .form-input:focus { position: relative; } -/* Responsive Layout */ @media (max-width: 1200px) { .room-layout { grid-template-columns: 1fr; @@ -241,9 +223,6 @@ input:focus, .form-input:focus { } } -/* ========================================= - 4. ROOM HEADER - ========================================= */ .room-header { display: flex; justify-content: space-between; @@ -265,7 +244,6 @@ input:focus, .form-input:focus { } .header-right { justify-content: flex-end; } -/* Info Section */ .room-info { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; } #room-name { margin: 0; @@ -286,7 +264,6 @@ input:focus, .form-input:focus { font-weight: 700; } -/* Host Controls (Center) - FIX: Better alignment container */ .header-center { flex: 2; display: flex; justify-content: center; z-index: 50; } .quick-controls-group { @@ -295,16 +272,15 @@ input:focus, .form-input:focus { gap: 8px; background: rgba(20, 20, 20, 0.6); backdrop-filter: blur(12px); - padding: 4px 8px; /* Slightly tighter padding */ + padding: 4px 8px; border-radius: 10px; border: 1px solid var(--glass-border); box-shadow: 0 4px 12px rgba(0,0,0,0.2); transition: all 0.2s ease; - height: 42px; /* Explicit height to align children */ + height: 42px; } .quick-controls-group:hover { border-color: rgba(255,255,255,0.15); background: rgba(30, 30, 30, 0.7); } -/* Toggle Sub/Dub Mini */ .sd-toggle.small { height: 32px; background: rgba(255, 255, 255, 0.05); @@ -340,7 +316,6 @@ input:focus, .form-input:focus { } .sd-toggle.small .sd-option.active { color: #fff; } -/* Viewers Pill */ .viewers-pill { display: flex; align-items: center; @@ -354,9 +329,6 @@ input:focus, .form-input:focus { height: 32px; } -/* ========================================= - 5. VIDEO PLAYER AREA - ========================================= */ .player-wrapper { display: flex !important; flex-direction: column; @@ -394,7 +366,6 @@ input:focus, .form-input:focus { max-height: 100%; } -/* Custom Controls Layout Fixes */ .custom-controls { position: absolute; bottom: 0; @@ -405,7 +376,6 @@ input:focus, .form-input:focus { padding: 20px 10px 10px; } -/* FIX: Ensure left controls stay in one line (time display fix) */ .controls-left { display: flex; align-items: center; @@ -413,14 +383,13 @@ input:focus, .form-input:focus { } .time-display { - white-space: nowrap; /* Prevent line break */ - font-variant-numeric: tabular-nums; /* Monospaced numbers prevent jitter */ + white-space: nowrap; + font-variant-numeric: tabular-nums; font-size: 0.9rem; color: #ddd; min-width: fit-content; } -/* Subtitles Canvas */ #subtitles-canvas { position: absolute; top: 0; left: 0; @@ -429,7 +398,6 @@ input:focus, .form-input:focus { z-index: 10; } -/* Hide unused player buttons in Room Mode */ #download-btn, #manual-match-btn, #server-select, @@ -439,7 +407,6 @@ input:focus, .form-input:focus { display: none !important; } -/* Settings Panel Position Fix */ .settings-panel { position: absolute; bottom: 70px; @@ -452,15 +419,13 @@ input:focus, .form-input:focus { border-radius: 8px; } -/* ========================================= - 6. CHAT SIDEBAR - ========================================= */ .chat-sidebar { display: flex; flex-direction: column; background: rgba(15, 15, 15, 0.95); border-left: 1px solid var(--glass-border); height: 100%; + overflow: hidden; } .room-layout.chat-hidden .chat-sidebar { opacity: 0; @@ -477,7 +442,6 @@ input:focus, .form-input:focus { } .chat-header h3 { margin: 0; font-size: 1.1rem; font-weight: 700; color: white; } -/* User List */ .users-list { padding: 12px; border-bottom: 1px solid var(--glass-border); @@ -516,7 +480,6 @@ input:focus, .form-input:focus { font-weight: 600; } -/* Messages */ .chat-messages { flex: 1; overflow-y: auto; @@ -524,6 +487,7 @@ input:focus, .form-input:focus { display: flex; flex-direction: column; gap: 12px; + min-height: 0; } .chat-message { display: flex; gap: 10px; } .chat-message.system { justify-content: center; margin: 8px 0; } @@ -558,7 +522,6 @@ input:focus, .form-input:focus { word-wrap: break-word; } -/* Input Area */ .chat-input { display: flex; gap: 8px; @@ -585,9 +548,6 @@ input:focus, .form-input:focus { } .chat-input button:hover { background: #7c3aed; } -/* ========================================= - 7. MODALS & CONFIGURATION - ========================================= */ .modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.8); @@ -617,12 +577,10 @@ input:focus, .form-input:focus { .modal-header-row { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; } .modal-header-row .modal-title { margin: 0; } -/* Forms inside modal */ .form-group { margin-bottom: 20px; } .form-group label { display: block; margin-bottom: 8px; font-weight: 600; color: white; } .form-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; } -/* Search Step */ .anime-search-content { max-width: 800px; max-height: 85vh; display: flex; flex-direction: column; } .search-bar { display: flex; gap: 12px; margin-bottom: 20px; } .search-bar button { @@ -653,7 +611,6 @@ input:focus, .form-input:focus { .search-title { font-weight: 700; color: white; margin-bottom: 4px; font-size: 1rem; } .search-meta { font-size: 0.85rem; color: var(--text-muted); } -/* Config Step (Unified UI) */ .config-layout { display: flex; gap: 24px; margin-top: 20px; } .config-sidebar { width: 140px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 12px; } .config-cover { @@ -668,7 +625,6 @@ input:focus, .form-input:focus { margin-bottom: 8px; font-weight: 700; } -/* Episode Stepper */ .ep-control { display: flex; align-items: center; background: rgba(255,255,255,0.05); @@ -689,7 +645,6 @@ input:focus, .form-input:focus { font-weight: 700; outline: none; } -/* Category Toggle */ .cat-toggle { display: flex; background: rgba(0,0,0,0.3); padding: 4px; border-radius: 10px; width: fit-content; @@ -714,9 +669,6 @@ input:focus, .form-input:focus { .ep-control { width: auto; flex: 1; } } -/* ========================================= - 8. ROOM LIST / LOBBY (If used externally) - ========================================= */ .container { max-width: 1400px; margin: 0 auto; padding: 80px 40px 40px; } .header h1 { font-size: 2.5rem; font-weight: 800; @@ -772,7 +724,7 @@ input:focus, .form-input:focus { height: 100%; border-radius: 50%; object-fit: cover; - border: 3px solid #1a1a1a; /* Borde oscuro para separar del gradiente */ + border: 3px solid #1a1a1a; background: #2a2a2a; } @@ -796,13 +748,13 @@ input:focus, .form-input:focus { .video-toast-container { position: absolute; - bottom: 100px; /* Encima de la barra de controles */ + bottom: 100px; left: 20px; - z-index: 80; /* Por encima del video, debajo de los controles */ + z-index: 80; display: flex; flex-direction: column; gap: 10px; - pointer-events: none; /* Permitir clicks a través de ellos */ + pointer-events: none; max-width: 400px; } @@ -819,9 +771,8 @@ input:focus, .form-input:focus { font-size: 0.9rem; box-shadow: 0 4px 12px rgba(0,0,0,0.3); - /* Animación de entrada y salida */ animation: toastSlideIn 0.3s ease forwards, toastFadeOut 0.5s ease 4.5s forwards; - pointer-events: auto; /* Permitir seleccionar texto si se quiere */ + pointer-events: auto; opacity: 0; } @@ -850,7 +801,6 @@ input:focus, .form-input:focus { margin-top: 2px; } -/* --- BADGE DE NOTIFICACIÓN (Punto Rojo) --- */ #toggle-chat-btn { position: relative; } @@ -862,7 +812,7 @@ input:focus, .form-input:focus { right: 2px; width: 10px; height: 10px; - background-color: #ef4444; /* Rojo */ + background-color: #ef4444; border: 2px solid #1a1a1a; border-radius: 50%; animation: pulse 2s infinite; @@ -885,11 +835,11 @@ input:focus, .form-input:focus { } .video-toast.system-toast { - border-left-color: #9ca3af; /* Borde gris */ - background: rgba(20, 20, 20, 0.7); /* Un poco más transparente */ + border-left-color: #9ca3af; + background: rgba(20, 20, 20, 0.7); justify-content: center; padding: 6px 12px; - min-height: auto; /* Más compacto */ + min-height: auto; } .video-toast.system-toast .toast-msg { @@ -897,4 +847,78 @@ input:focus, .form-input:focus { font-style: italic; color: rgba(255, 255, 255, 0.8); margin: 0; +} + +@keyframes slideUp { + from { + transform: translateX(-50%) translateY(20px); + opacity: 0; + } + to { + transform: translateX(-50%) translateY(0); + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes slideUp { + from { + transform: translateX(-50%) translateY(20px); + opacity: 0; + } + to { + transform: translateX(-50%) translateY(0); + opacity: 1; + } +} + +@keyframes slideDown { + from { + transform: translateX(-50%) translateY(-20px); + opacity: 0; + } + to { + transform: translateX(-50%) translateY(0); + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +#copy-invite-btn { + transition: all 0.3s ease; +} + +#copy-invite-btn:hover { + transform: scale(1.05); +} + +#copy-invite-btn:active { + transform: scale(0.95); +} + +@keyframes scaleIn { + from { + transform: translate(-50%, -50%) scale(0.8); + opacity: 0; + } + to { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } } \ No newline at end of file diff --git a/docker/views/room.html b/docker/views/room.html index 73762c7..a4d683c 100644 --- a/docker/views/room.html +++ b/docker/views/room.html @@ -21,7 +21,7 @@
-
@@ -49,6 +49,17 @@ +