added public watch parties with cloudflared

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

View File

@@ -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"), { fastify.register(require("@fastify/static"), {
root: path.join(__dirname, "public"), root: path.join(__dirname, "public"),
prefix: "/public/", prefix: "/public/",

View File

@@ -1,20 +1,17 @@
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import * as roomService from './rooms.service'; import * as roomService from './rooms.service';
import { getUserById } from '../user/user.service'; import { getUserById } from '../user/user.service';
import { openTunnel } from "./tunnel.manager";
interface CreateRoomBody { interface CreateRoomBody {
name: string; name: string;
password?: string; password?: string;
} expose?: boolean;
interface JoinRoomBody {
password?: string;
guestName?: string;
} }
export async function createRoom(req: any, reply: FastifyReply) { export async function createRoom(req: any, reply: FastifyReply) {
try { try {
const { name, password } = req.body as CreateRoomBody; const { name, password, expose } = req.body as CreateRoomBody;
const userId = req.user?.id; const userId = req.user?.id;
if (!userId) { if (!userId) {
@@ -39,7 +36,23 @@ export async function createRoom(req: any, reply: FastifyReply) {
userId 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({ return reply.send({
success: true, success: true,
@@ -47,7 +60,9 @@ export async function createRoom(req: any, reply: FastifyReply) {
id: room.id, id: room.id,
name: room.name, name: room.name,
hasPassword: !!room.password, hasPassword: !!room.password,
userCount: room.users.size userCount: room.users.size,
exposed: room.exposed,
publicUrl: room.publicUrl
} }
}); });
} catch (err) { } catch (err) {
@@ -104,7 +119,9 @@ export async function getRoom(req: FastifyRequest, reply: FastifyReply) {
isGuest: u.isGuest isGuest: u.isGuest
})), })),
hasPassword: !!room.password, hasPassword: !!room.password,
currentVideo: room.currentVideo currentVideo: room.currentVideo,
exposed: room.exposed,
publicUrl: room.publicUrl
} }
}); });
} catch (err) { } catch (err) {

View File

@@ -1,4 +1,5 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { closeTunnelIfUnused } from "./tunnel.manager";
interface RoomUser { interface RoomUser {
id: string; id: string;
@@ -33,6 +34,8 @@ interface RoomData {
} | null; } | null;
password?: string; password?: string;
metadata?: RoomMetadata | null; metadata?: RoomMetadata | null;
exposed: boolean;
publicUrl?: string;
} }
const rooms = new Map<string, RoomData>(); const rooms = new Map<string, RoomData>();
@@ -41,7 +44,7 @@ export function generateRoomId(): string {
return crypto.randomBytes(8).toString('hex'); 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 roomId = generateRoomId();
const room: RoomData = { const room: RoomData = {
@@ -53,6 +56,8 @@ export function createRoom(name: string, host: RoomUser, password?: string): Roo
currentVideo: null, currentVideo: null,
password: password || undefined, password: password || undefined,
metadata: null, metadata: null,
exposed,
publicUrl
}; };
rooms.set(roomId, room); rooms.set(roomId, room);
@@ -84,13 +89,14 @@ export function removeUserFromRoom(roomId: string, userId: string): boolean {
room.users.delete(userId); room.users.delete(userId);
// Si no quedan usuarios, eliminar la sala
if (room.users.size === 0) { if (room.users.size === 0) {
if (room.exposed) {
closeTunnelIfUnused();
}
rooms.delete(roomId); rooms.delete(roomId);
return true; return true;
} }
// Si era el host, asignar nuevo host
if (room.host.id === userId && room.users.size > 0) { if (room.host.id === userId && room.users.size > 0) {
const newHost = Array.from(room.users.values())[0]; const newHost = Array.from(room.users.values())[0];
newHost.isHost = true; newHost.isHost = true;
@@ -109,6 +115,13 @@ export function updateRoomVideo(roomId: string, videoData: any): boolean {
} }
export function deleteRoom(roomId: string): boolean { export function deleteRoom(roomId: string): boolean {
const room = rooms.get(roomId);
if (!room) return false;
if (room.exposed) {
closeTunnelIfUnused();
}
return rooms.delete(roomId); return rooms.delete(roomId);
} }

View File

@@ -226,6 +226,34 @@ function handleMessage(roomId: string, userId: string, data: any) {
}); });
break; 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': case 'video_update':
if (room.host.id !== userId) return; if (room.host.id !== userId) return;
@@ -288,11 +316,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
type: 'play', type: 'play',
currentTime: data.currentTime, currentTime: data.currentTime,
timestamp: Date.now() timestamp: Date.now()
}, userId); // IMPORTANTE: excludeUserId para no enviar al host }, userId);
break; break;
case 'pause': case 'pause':
// Solo el host puede controlar la reproducción
if (room.host.id !== userId) { if (room.host.id !== userId) {
console.warn('Non-host attempted pause:', userId); console.warn('Non-host attempted pause:', userId);
return; return;
@@ -303,11 +330,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
type: 'pause', type: 'pause',
currentTime: data.currentTime, currentTime: data.currentTime,
timestamp: Date.now() timestamp: Date.now()
}, userId); // IMPORTANTE: excludeUserId para no enviar al host }, userId);
break; break;
case 'seek': case 'seek':
// Solo el host puede controlar la reproducción
if (room.host.id !== userId) { if (room.host.id !== userId) {
console.warn('Non-host attempted seek:', userId); console.warn('Non-host attempted seek:', userId);
return; return;
@@ -318,22 +344,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
type: 'seek', type: 'seek',
currentTime: data.currentTime, currentTime: data.currentTime,
timestamp: Date.now() timestamp: Date.now()
}, userId); // IMPORTANTE: excludeUserId para no enviar al host }, userId);
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
}));
}
break; break;
case 'request_state': case 'request_state':
// Enviar estado actual de la sala al usuario que lo solicita
const client = clients.get(userId); const client = clients.get(userId);
if (client && client.socket && client.socket.readyState === 1) { if (client && client.socket && client.socket.readyState === 1) {
const updatedRoom = roomService.getRoom(roomId); const updatedRoom = roomService.getRoom(roomId);

View File

@@ -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<string> | null = null;
export function openTunnel(): Promise<string> {
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;
}
}

View File

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

View File

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

View File

@@ -14,7 +14,8 @@ const DEFAULT_CONFIG = {
}, },
paths: { paths: {
mpv: null, mpv: null,
ffmpeg: null ffmpeg: null,
cloudflared: null,
} }
}; };
@@ -26,7 +27,8 @@ export const CONFIG_SCHEMA = {
}, },
paths: { paths: {
mpv: { description: "Required to open anime episodes in mpv on desktop version." }, 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." }
} }
}; };

View File

@@ -1,6 +1,3 @@
/* =========================================
1. VARIABLES & UTILITIES
========================================= */
:root { :root {
--brand-color: #8b5cf6; --brand-color: #8b5cf6;
--brand-gradient: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%); --brand-gradient: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);
@@ -11,7 +8,6 @@
--text-muted: rgba(255, 255, 255, 0.6); --text-muted: rgba(255, 255, 255, 0.6);
} }
/* Scrollbar Styles */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -38,11 +34,6 @@
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
/* =========================================
2. UI COMPONENTS (Buttons, Inputs, Chips)
========================================= */
/* Glass Buttons & Icons */
.btn-icon-glass, .btn-icon-small, .modal-close { .btn-icon-glass, .btn-icon-small, .modal-close {
appearance: none; appearance: none;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
@@ -91,7 +82,6 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Primary/Confirm Buttons */
.btn-confirm, .btn-primary { .btn-confirm, .btn-primary {
background: var(--brand-color); background: var(--brand-color);
border: none; border: none;
@@ -126,7 +116,6 @@
} }
.btn-cancel:hover { background: rgba(255, 255, 255, 0.1); color: white; } .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 { input[type="text"], input[type="password"], input[type="number"], .form-input {
width: 100%; width: 100%;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
@@ -144,16 +133,15 @@ input:focus, .form-input:focus {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
/* Glass Select (Header Style - FIX: Better alignment) */
.glass-select-sm { .glass-select-sm {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
color: #eee; color: #eee;
padding: 0 32px 0 12px; /* Extra padding right for arrow */ padding: 0 32px 0 12px;
height: 32px; height: 32px;
line-height: 30px; /* Vertically center text */ line-height: 30px;
border-radius: 8px; border-radius: 8px;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
@@ -164,7 +152,6 @@ input:focus, .form-input:focus {
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; 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-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-repeat: no-repeat;
background-position: right 8px center; background-position: right 8px center;
@@ -178,7 +165,6 @@ input:focus, .form-input:focus {
} }
.glass-select-sm option { background: #1a1a1a; color: #e0e0e0; } .glass-select-sm option { background: #1a1a1a; color: #e0e0e0; }
/* Chips (Config Modal) */
.chips-grid { display: flex; flex-wrap: wrap; gap: 10px; } .chips-grid { display: flex; flex-wrap: wrap; gap: 10px; }
.chip { .chip {
padding: 8px 16px; padding: 8px 16px;
@@ -200,9 +186,6 @@ input:focus, .form-input:focus {
} }
.chip.disabled { opacity: 0.5; pointer-events: none; filter: grayscale(1); } .chip.disabled { opacity: 0.5; pointer-events: none; filter: grayscale(1); }
/* =========================================
3. ROOM LAYOUT (The Watch Page)
========================================= */
.room-layout { .room-layout {
display: grid; display: grid;
grid-template-columns: 1fr 380px; grid-template-columns: 1fr 380px;
@@ -225,7 +208,6 @@ input:focus, .form-input:focus {
position: relative; position: relative;
} }
/* Responsive Layout */
@media (max-width: 1200px) { @media (max-width: 1200px) {
.room-layout { .room-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -241,9 +223,6 @@ input:focus, .form-input:focus {
} }
} }
/* =========================================
4. ROOM HEADER
========================================= */
.room-header { .room-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -265,7 +244,6 @@ input:focus, .form-input:focus {
} }
.header-right { justify-content: flex-end; } .header-right { justify-content: flex-end; }
/* Info Section */
.room-info { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; } .room-info { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; }
#room-name { #room-name {
margin: 0; margin: 0;
@@ -286,7 +264,6 @@ input:focus, .form-input:focus {
font-weight: 700; font-weight: 700;
} }
/* Host Controls (Center) - FIX: Better alignment container */
.header-center { flex: 2; display: flex; justify-content: center; z-index: 50; } .header-center { flex: 2; display: flex; justify-content: center; z-index: 50; }
.quick-controls-group { .quick-controls-group {
@@ -295,16 +272,15 @@ input:focus, .form-input:focus {
gap: 8px; gap: 8px;
background: rgba(20, 20, 20, 0.6); background: rgba(20, 20, 20, 0.6);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
padding: 4px 8px; /* Slightly tighter padding */ padding: 4px 8px;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
box-shadow: 0 4px 12px rgba(0,0,0,0.2); box-shadow: 0 4px 12px rgba(0,0,0,0.2);
transition: all 0.2s ease; 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); } .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 { .sd-toggle.small {
height: 32px; height: 32px;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
@@ -340,7 +316,6 @@ input:focus, .form-input:focus {
} }
.sd-toggle.small .sd-option.active { color: #fff; } .sd-toggle.small .sd-option.active { color: #fff; }
/* Viewers Pill */
.viewers-pill { .viewers-pill {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -354,9 +329,6 @@ input:focus, .form-input:focus {
height: 32px; height: 32px;
} }
/* =========================================
5. VIDEO PLAYER AREA
========================================= */
.player-wrapper { .player-wrapper {
display: flex !important; display: flex !important;
flex-direction: column; flex-direction: column;
@@ -394,7 +366,6 @@ input:focus, .form-input:focus {
max-height: 100%; max-height: 100%;
} }
/* Custom Controls Layout Fixes */
.custom-controls { .custom-controls {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@@ -405,7 +376,6 @@ input:focus, .form-input:focus {
padding: 20px 10px 10px; padding: 20px 10px 10px;
} }
/* FIX: Ensure left controls stay in one line (time display fix) */
.controls-left { .controls-left {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -413,14 +383,13 @@ input:focus, .form-input:focus {
} }
.time-display { .time-display {
white-space: nowrap; /* Prevent line break */ white-space: nowrap;
font-variant-numeric: tabular-nums; /* Monospaced numbers prevent jitter */ font-variant-numeric: tabular-nums;
font-size: 0.9rem; font-size: 0.9rem;
color: #ddd; color: #ddd;
min-width: fit-content; min-width: fit-content;
} }
/* Subtitles Canvas */
#subtitles-canvas { #subtitles-canvas {
position: absolute; position: absolute;
top: 0; left: 0; top: 0; left: 0;
@@ -429,7 +398,6 @@ input:focus, .form-input:focus {
z-index: 10; z-index: 10;
} }
/* Hide unused player buttons in Room Mode */
#download-btn, #download-btn,
#manual-match-btn, #manual-match-btn,
#server-select, #server-select,
@@ -439,7 +407,6 @@ input:focus, .form-input:focus {
display: none !important; display: none !important;
} }
/* Settings Panel Position Fix */
.settings-panel { .settings-panel {
position: absolute; position: absolute;
bottom: 70px; bottom: 70px;
@@ -452,15 +419,13 @@ input:focus, .form-input:focus {
border-radius: 8px; border-radius: 8px;
} }
/* =========================================
6. CHAT SIDEBAR
========================================= */
.chat-sidebar { .chat-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: rgba(15, 15, 15, 0.95); background: rgba(15, 15, 15, 0.95);
border-left: 1px solid var(--glass-border); border-left: 1px solid var(--glass-border);
height: 100%; height: 100%;
overflow: hidden;
} }
.room-layout.chat-hidden .chat-sidebar { .room-layout.chat-hidden .chat-sidebar {
opacity: 0; 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; } .chat-header h3 { margin: 0; font-size: 1.1rem; font-weight: 700; color: white; }
/* User List */
.users-list { .users-list {
padding: 12px; padding: 12px;
border-bottom: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border);
@@ -516,7 +480,6 @@ input:focus, .form-input:focus {
font-weight: 600; font-weight: 600;
} }
/* Messages */
.chat-messages { .chat-messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -524,6 +487,7 @@ input:focus, .form-input:focus {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
min-height: 0;
} }
.chat-message { display: flex; gap: 10px; } .chat-message { display: flex; gap: 10px; }
.chat-message.system { justify-content: center; margin: 8px 0; } .chat-message.system { justify-content: center; margin: 8px 0; }
@@ -558,7 +522,6 @@ input:focus, .form-input:focus {
word-wrap: break-word; word-wrap: break-word;
} }
/* Input Area */
.chat-input { .chat-input {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -585,9 +548,6 @@ input:focus, .form-input:focus {
} }
.chat-input button:hover { background: #7c3aed; } .chat-input button:hover { background: #7c3aed; }
/* =========================================
7. MODALS & CONFIGURATION
========================================= */
.modal-overlay { .modal-overlay {
position: fixed; inset: 0; position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.8); 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 { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
.modal-header-row .modal-title { margin: 0; } .modal-header-row .modal-title { margin: 0; }
/* Forms inside modal */
.form-group { margin-bottom: 20px; } .form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 8px; font-weight: 600; color: white; } .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; } .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; } .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 { display: flex; gap: 12px; margin-bottom: 20px; }
.search-bar button { .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-title { font-weight: 700; color: white; margin-bottom: 4px; font-size: 1rem; }
.search-meta { font-size: 0.85rem; color: var(--text-muted); } .search-meta { font-size: 0.85rem; color: var(--text-muted); }
/* Config Step (Unified UI) */
.config-layout { display: flex; gap: 24px; margin-top: 20px; } .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-sidebar { width: 140px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.config-cover { .config-cover {
@@ -668,7 +625,6 @@ input:focus, .form-input:focus {
margin-bottom: 8px; font-weight: 700; margin-bottom: 8px; font-weight: 700;
} }
/* Episode Stepper */
.ep-control { .ep-control {
display: flex; align-items: center; display: flex; align-items: center;
background: rgba(255,255,255,0.05); background: rgba(255,255,255,0.05);
@@ -689,7 +645,6 @@ input:focus, .form-input:focus {
font-weight: 700; outline: none; font-weight: 700; outline: none;
} }
/* Category Toggle */
.cat-toggle { .cat-toggle {
display: flex; background: rgba(0,0,0,0.3); display: flex; background: rgba(0,0,0,0.3);
padding: 4px; border-radius: 10px; width: fit-content; padding: 4px; border-radius: 10px; width: fit-content;
@@ -714,9 +669,6 @@ input:focus, .form-input:focus {
.ep-control { width: auto; flex: 1; } .ep-control { width: auto; flex: 1; }
} }
/* =========================================
8. ROOM LIST / LOBBY (If used externally)
========================================= */
.container { max-width: 1400px; margin: 0 auto; padding: 80px 40px 40px; } .container { max-width: 1400px; margin: 0 auto; padding: 80px 40px 40px; }
.header h1 { .header h1 {
font-size: 2.5rem; font-weight: 800; font-size: 2.5rem; font-weight: 800;
@@ -772,7 +724,7 @@ input:focus, .form-input:focus {
height: 100%; height: 100%;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
border: 3px solid #1a1a1a; /* Borde oscuro para separar del gradiente */ border: 3px solid #1a1a1a;
background: #2a2a2a; background: #2a2a2a;
} }
@@ -796,13 +748,13 @@ input:focus, .form-input:focus {
.video-toast-container { .video-toast-container {
position: absolute; position: absolute;
bottom: 100px; /* Encima de la barra de controles */ bottom: 100px;
left: 20px; left: 20px;
z-index: 80; /* Por encima del video, debajo de los controles */ z-index: 80;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
pointer-events: none; /* Permitir clicks a través de ellos */ pointer-events: none;
max-width: 400px; max-width: 400px;
} }
@@ -819,9 +771,8 @@ input:focus, .form-input:focus {
font-size: 0.9rem; font-size: 0.9rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.3); 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; 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; opacity: 0;
} }
@@ -850,7 +801,6 @@ input:focus, .form-input:focus {
margin-top: 2px; margin-top: 2px;
} }
/* --- BADGE DE NOTIFICACIÓN (Punto Rojo) --- */
#toggle-chat-btn { #toggle-chat-btn {
position: relative; position: relative;
} }
@@ -862,7 +812,7 @@ input:focus, .form-input:focus {
right: 2px; right: 2px;
width: 10px; width: 10px;
height: 10px; height: 10px;
background-color: #ef4444; /* Rojo */ background-color: #ef4444;
border: 2px solid #1a1a1a; border: 2px solid #1a1a1a;
border-radius: 50%; border-radius: 50%;
animation: pulse 2s infinite; animation: pulse 2s infinite;
@@ -885,11 +835,11 @@ input:focus, .form-input:focus {
} }
.video-toast.system-toast { .video-toast.system-toast {
border-left-color: #9ca3af; /* Borde gris */ border-left-color: #9ca3af;
background: rgba(20, 20, 20, 0.7); /* Un poco más transparente */ background: rgba(20, 20, 20, 0.7);
justify-content: center; justify-content: center;
padding: 6px 12px; padding: 6px 12px;
min-height: auto; /* Más compacto */ min-height: auto;
} }
.video-toast.system-toast .toast-msg { .video-toast.system-toast .toast-msg {
@@ -898,3 +848,77 @@ input:focus, .form-input:focus {
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
margin: 0; 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;
}
}

View File

@@ -33,7 +33,7 @@
<div class="video-area"> <div class="video-area">
<div class="room-header"> <div class="room-header">
<div class="header-left"> <div class="header-left">
<button id="leave-room-btn" class="btn-icon-glass" title="Leave"> <button id="leave-room-btn" class="btn-icon-glass" title="Leave" style="visibility: hidden;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
</button> </button>
<div class="room-info"> <div class="room-info">
@@ -61,6 +61,17 @@
<select id="room-server-select" class="glass-select-sm" title="Server"> <select id="room-server-select" class="glass-select-sm" title="Server">
<option value="" disabled selected>Server</option> <option value="" disabled selected>Server</option>
</select> </select>
<button
id="copy-invite-btn"
class="btn-icon-glass"
title="Copy invite link"
style="display:none;"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.07 0l1.41-1.41a5 5 0 0 0-7.07-7.07L10 5"/>
<path d="M14 11a5 5 0 0 0-7.07 0L5.5 12.41a5 5 0 0 0 7.07 7.07L14 19"/>
</svg>
</button>
</div> </div>
</div> </div>

View File

@@ -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"), { fastify.register(require("@fastify/static"), {
root: path.join(__dirname, "public"), root: path.join(__dirname, "public"),
prefix: "/public/", prefix: "/public/",

View File

@@ -1,20 +1,17 @@
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import * as roomService from './rooms.service'; import * as roomService from './rooms.service';
import { getUserById } from '../user/user.service'; import { getUserById } from '../user/user.service';
import { openTunnel } from "./tunnel.manager";
interface CreateRoomBody { interface CreateRoomBody {
name: string; name: string;
password?: string; password?: string;
} expose?: boolean;
interface JoinRoomBody {
password?: string;
guestName?: string;
} }
export async function createRoom(req: any, reply: FastifyReply) { export async function createRoom(req: any, reply: FastifyReply) {
try { try {
const { name, password } = req.body as CreateRoomBody; const { name, password, expose } = req.body as CreateRoomBody;
const userId = req.user?.id; const userId = req.user?.id;
if (!userId) { if (!userId) {
@@ -39,7 +36,23 @@ export async function createRoom(req: any, reply: FastifyReply) {
userId 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({ return reply.send({
success: true, success: true,
@@ -47,7 +60,9 @@ export async function createRoom(req: any, reply: FastifyReply) {
id: room.id, id: room.id,
name: room.name, name: room.name,
hasPassword: !!room.password, hasPassword: !!room.password,
userCount: room.users.size userCount: room.users.size,
exposed: room.exposed,
publicUrl: room.publicUrl
} }
}); });
} catch (err) { } catch (err) {
@@ -104,7 +119,9 @@ export async function getRoom(req: FastifyRequest, reply: FastifyReply) {
isGuest: u.isGuest isGuest: u.isGuest
})), })),
hasPassword: !!room.password, hasPassword: !!room.password,
currentVideo: room.currentVideo currentVideo: room.currentVideo,
exposed: room.exposed,
publicUrl: room.publicUrl
} }
}); });
} catch (err) { } catch (err) {

View File

@@ -1,4 +1,5 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { closeTunnelIfUnused } from "./tunnel.manager";
interface RoomUser { interface RoomUser {
id: string; id: string;
@@ -33,6 +34,8 @@ interface RoomData {
} | null; } | null;
password?: string; password?: string;
metadata?: RoomMetadata | null; metadata?: RoomMetadata | null;
exposed: boolean;
publicUrl?: string;
} }
const rooms = new Map<string, RoomData>(); const rooms = new Map<string, RoomData>();
@@ -41,7 +44,7 @@ export function generateRoomId(): string {
return crypto.randomBytes(8).toString('hex'); 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 roomId = generateRoomId();
const room: RoomData = { const room: RoomData = {
@@ -53,6 +56,8 @@ export function createRoom(name: string, host: RoomUser, password?: string): Roo
currentVideo: null, currentVideo: null,
password: password || undefined, password: password || undefined,
metadata: null, metadata: null,
exposed,
publicUrl
}; };
rooms.set(roomId, room); rooms.set(roomId, room);
@@ -84,13 +89,14 @@ export function removeUserFromRoom(roomId: string, userId: string): boolean {
room.users.delete(userId); room.users.delete(userId);
// Si no quedan usuarios, eliminar la sala
if (room.users.size === 0) { if (room.users.size === 0) {
if (room.exposed) {
closeTunnelIfUnused();
}
rooms.delete(roomId); rooms.delete(roomId);
return true; return true;
} }
// Si era el host, asignar nuevo host
if (room.host.id === userId && room.users.size > 0) { if (room.host.id === userId && room.users.size > 0) {
const newHost = Array.from(room.users.values())[0]; const newHost = Array.from(room.users.values())[0];
newHost.isHost = true; newHost.isHost = true;
@@ -109,6 +115,13 @@ export function updateRoomVideo(roomId: string, videoData: any): boolean {
} }
export function deleteRoom(roomId: string): boolean { export function deleteRoom(roomId: string): boolean {
const room = rooms.get(roomId);
if (!room) return false;
if (room.exposed) {
closeTunnelIfUnused();
}
return rooms.delete(roomId); return rooms.delete(roomId);
} }

View File

@@ -226,6 +226,34 @@ function handleMessage(roomId: string, userId: string, data: any) {
}); });
break; 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': case 'video_update':
if (room.host.id !== userId) return; if (room.host.id !== userId) return;
@@ -288,11 +316,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
type: 'play', type: 'play',
currentTime: data.currentTime, currentTime: data.currentTime,
timestamp: Date.now() timestamp: Date.now()
}, userId); // IMPORTANTE: excludeUserId para no enviar al host }, userId);
break; break;
case 'pause': case 'pause':
// Solo el host puede controlar la reproducción
if (room.host.id !== userId) { if (room.host.id !== userId) {
console.warn('Non-host attempted pause:', userId); console.warn('Non-host attempted pause:', userId);
return; return;
@@ -303,11 +330,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
type: 'pause', type: 'pause',
currentTime: data.currentTime, currentTime: data.currentTime,
timestamp: Date.now() timestamp: Date.now()
}, userId); // IMPORTANTE: excludeUserId para no enviar al host }, userId);
break; break;
case 'seek': case 'seek':
// Solo el host puede controlar la reproducción
if (room.host.id !== userId) { if (room.host.id !== userId) {
console.warn('Non-host attempted seek:', userId); console.warn('Non-host attempted seek:', userId);
return; return;
@@ -318,22 +344,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
type: 'seek', type: 'seek',
currentTime: data.currentTime, currentTime: data.currentTime,
timestamp: Date.now() timestamp: Date.now()
}, userId); // IMPORTANTE: excludeUserId para no enviar al host }, userId);
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
}));
}
break; break;
case 'request_state': case 'request_state':
// Enviar estado actual de la sala al usuario que lo solicita
const client = clients.get(userId); const client = clients.get(userId);
if (client && client.socket && client.socket.readyState === 1) { if (client && client.socket && client.socket.readyState === 1) {
const updatedRoom = roomService.getRoom(roomId); const updatedRoom = roomService.getRoom(roomId);

View File

@@ -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<string> | null = null;
export function openTunnel(): Promise<string> {
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;
}
}

View File

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

View File

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

View File

@@ -14,7 +14,8 @@ const DEFAULT_CONFIG = {
}, },
paths: { paths: {
mpv: null, mpv: null,
ffmpeg: null ffmpeg: null,
cloudflared: null,
} }
}; };
@@ -26,7 +27,8 @@ export const CONFIG_SCHEMA = {
}, },
paths: { paths: {
mpv: { description: "Required to open anime episodes in mpv on desktop version." }, 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." }
} }
}; };

View File

@@ -1,6 +1,3 @@
/* =========================================
1. VARIABLES & UTILITIES
========================================= */
:root { :root {
--brand-color: #8b5cf6; --brand-color: #8b5cf6;
--brand-gradient: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%); --brand-gradient: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);
@@ -11,7 +8,6 @@
--text-muted: rgba(255, 255, 255, 0.6); --text-muted: rgba(255, 255, 255, 0.6);
} }
/* Scrollbar Styles */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -38,11 +34,6 @@
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
/* =========================================
2. UI COMPONENTS (Buttons, Inputs, Chips)
========================================= */
/* Glass Buttons & Icons */
.btn-icon-glass, .btn-icon-small, .modal-close { .btn-icon-glass, .btn-icon-small, .modal-close {
appearance: none; appearance: none;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
@@ -91,7 +82,6 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Primary/Confirm Buttons */
.btn-confirm, .btn-primary { .btn-confirm, .btn-primary {
background: var(--brand-color); background: var(--brand-color);
border: none; border: none;
@@ -126,7 +116,6 @@
} }
.btn-cancel:hover { background: rgba(255, 255, 255, 0.1); color: white; } .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 { input[type="text"], input[type="password"], input[type="number"], .form-input {
width: 100%; width: 100%;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
@@ -144,16 +133,15 @@ input:focus, .form-input:focus {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
/* Glass Select (Header Style - FIX: Better alignment) */
.glass-select-sm { .glass-select-sm {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
color: #eee; color: #eee;
padding: 0 32px 0 12px; /* Extra padding right for arrow */ padding: 0 32px 0 12px;
height: 32px; height: 32px;
line-height: 30px; /* Vertically center text */ line-height: 30px;
border-radius: 8px; border-radius: 8px;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
@@ -164,7 +152,6 @@ input:focus, .form-input:focus {
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; 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-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-repeat: no-repeat;
background-position: right 8px center; background-position: right 8px center;
@@ -178,7 +165,6 @@ input:focus, .form-input:focus {
} }
.glass-select-sm option { background: #1a1a1a; color: #e0e0e0; } .glass-select-sm option { background: #1a1a1a; color: #e0e0e0; }
/* Chips (Config Modal) */
.chips-grid { display: flex; flex-wrap: wrap; gap: 10px; } .chips-grid { display: flex; flex-wrap: wrap; gap: 10px; }
.chip { .chip {
padding: 8px 16px; padding: 8px 16px;
@@ -200,9 +186,6 @@ input:focus, .form-input:focus {
} }
.chip.disabled { opacity: 0.5; pointer-events: none; filter: grayscale(1); } .chip.disabled { opacity: 0.5; pointer-events: none; filter: grayscale(1); }
/* =========================================
3. ROOM LAYOUT (The Watch Page)
========================================= */
.room-layout { .room-layout {
display: grid; display: grid;
grid-template-columns: 1fr 380px; grid-template-columns: 1fr 380px;
@@ -225,7 +208,6 @@ input:focus, .form-input:focus {
position: relative; position: relative;
} }
/* Responsive Layout */
@media (max-width: 1200px) { @media (max-width: 1200px) {
.room-layout { .room-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -241,9 +223,6 @@ input:focus, .form-input:focus {
} }
} }
/* =========================================
4. ROOM HEADER
========================================= */
.room-header { .room-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -265,7 +244,6 @@ input:focus, .form-input:focus {
} }
.header-right { justify-content: flex-end; } .header-right { justify-content: flex-end; }
/* Info Section */
.room-info { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; } .room-info { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; }
#room-name { #room-name {
margin: 0; margin: 0;
@@ -286,7 +264,6 @@ input:focus, .form-input:focus {
font-weight: 700; font-weight: 700;
} }
/* Host Controls (Center) - FIX: Better alignment container */
.header-center { flex: 2; display: flex; justify-content: center; z-index: 50; } .header-center { flex: 2; display: flex; justify-content: center; z-index: 50; }
.quick-controls-group { .quick-controls-group {
@@ -295,16 +272,15 @@ input:focus, .form-input:focus {
gap: 8px; gap: 8px;
background: rgba(20, 20, 20, 0.6); background: rgba(20, 20, 20, 0.6);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
padding: 4px 8px; /* Slightly tighter padding */ padding: 4px 8px;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
box-shadow: 0 4px 12px rgba(0,0,0,0.2); box-shadow: 0 4px 12px rgba(0,0,0,0.2);
transition: all 0.2s ease; 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); } .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 { .sd-toggle.small {
height: 32px; height: 32px;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
@@ -340,7 +316,6 @@ input:focus, .form-input:focus {
} }
.sd-toggle.small .sd-option.active { color: #fff; } .sd-toggle.small .sd-option.active { color: #fff; }
/* Viewers Pill */
.viewers-pill { .viewers-pill {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -354,9 +329,6 @@ input:focus, .form-input:focus {
height: 32px; height: 32px;
} }
/* =========================================
5. VIDEO PLAYER AREA
========================================= */
.player-wrapper { .player-wrapper {
display: flex !important; display: flex !important;
flex-direction: column; flex-direction: column;
@@ -394,7 +366,6 @@ input:focus, .form-input:focus {
max-height: 100%; max-height: 100%;
} }
/* Custom Controls Layout Fixes */
.custom-controls { .custom-controls {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@@ -405,7 +376,6 @@ input:focus, .form-input:focus {
padding: 20px 10px 10px; padding: 20px 10px 10px;
} }
/* FIX: Ensure left controls stay in one line (time display fix) */
.controls-left { .controls-left {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -413,14 +383,13 @@ input:focus, .form-input:focus {
} }
.time-display { .time-display {
white-space: nowrap; /* Prevent line break */ white-space: nowrap;
font-variant-numeric: tabular-nums; /* Monospaced numbers prevent jitter */ font-variant-numeric: tabular-nums;
font-size: 0.9rem; font-size: 0.9rem;
color: #ddd; color: #ddd;
min-width: fit-content; min-width: fit-content;
} }
/* Subtitles Canvas */
#subtitles-canvas { #subtitles-canvas {
position: absolute; position: absolute;
top: 0; left: 0; top: 0; left: 0;
@@ -429,7 +398,6 @@ input:focus, .form-input:focus {
z-index: 10; z-index: 10;
} }
/* Hide unused player buttons in Room Mode */
#download-btn, #download-btn,
#manual-match-btn, #manual-match-btn,
#server-select, #server-select,
@@ -439,7 +407,6 @@ input:focus, .form-input:focus {
display: none !important; display: none !important;
} }
/* Settings Panel Position Fix */
.settings-panel { .settings-panel {
position: absolute; position: absolute;
bottom: 70px; bottom: 70px;
@@ -452,15 +419,13 @@ input:focus, .form-input:focus {
border-radius: 8px; border-radius: 8px;
} }
/* =========================================
6. CHAT SIDEBAR
========================================= */
.chat-sidebar { .chat-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: rgba(15, 15, 15, 0.95); background: rgba(15, 15, 15, 0.95);
border-left: 1px solid var(--glass-border); border-left: 1px solid var(--glass-border);
height: 100%; height: 100%;
overflow: hidden;
} }
.room-layout.chat-hidden .chat-sidebar { .room-layout.chat-hidden .chat-sidebar {
opacity: 0; 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; } .chat-header h3 { margin: 0; font-size: 1.1rem; font-weight: 700; color: white; }
/* User List */
.users-list { .users-list {
padding: 12px; padding: 12px;
border-bottom: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border);
@@ -516,7 +480,6 @@ input:focus, .form-input:focus {
font-weight: 600; font-weight: 600;
} }
/* Messages */
.chat-messages { .chat-messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -524,6 +487,7 @@ input:focus, .form-input:focus {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
min-height: 0;
} }
.chat-message { display: flex; gap: 10px; } .chat-message { display: flex; gap: 10px; }
.chat-message.system { justify-content: center; margin: 8px 0; } .chat-message.system { justify-content: center; margin: 8px 0; }
@@ -558,7 +522,6 @@ input:focus, .form-input:focus {
word-wrap: break-word; word-wrap: break-word;
} }
/* Input Area */
.chat-input { .chat-input {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -585,9 +548,6 @@ input:focus, .form-input:focus {
} }
.chat-input button:hover { background: #7c3aed; } .chat-input button:hover { background: #7c3aed; }
/* =========================================
7. MODALS & CONFIGURATION
========================================= */
.modal-overlay { .modal-overlay {
position: fixed; inset: 0; position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.8); 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 { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
.modal-header-row .modal-title { margin: 0; } .modal-header-row .modal-title { margin: 0; }
/* Forms inside modal */
.form-group { margin-bottom: 20px; } .form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 8px; font-weight: 600; color: white; } .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; } .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; } .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 { display: flex; gap: 12px; margin-bottom: 20px; }
.search-bar button { .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-title { font-weight: 700; color: white; margin-bottom: 4px; font-size: 1rem; }
.search-meta { font-size: 0.85rem; color: var(--text-muted); } .search-meta { font-size: 0.85rem; color: var(--text-muted); }
/* Config Step (Unified UI) */
.config-layout { display: flex; gap: 24px; margin-top: 20px; } .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-sidebar { width: 140px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.config-cover { .config-cover {
@@ -668,7 +625,6 @@ input:focus, .form-input:focus {
margin-bottom: 8px; font-weight: 700; margin-bottom: 8px; font-weight: 700;
} }
/* Episode Stepper */
.ep-control { .ep-control {
display: flex; align-items: center; display: flex; align-items: center;
background: rgba(255,255,255,0.05); background: rgba(255,255,255,0.05);
@@ -689,7 +645,6 @@ input:focus, .form-input:focus {
font-weight: 700; outline: none; font-weight: 700; outline: none;
} }
/* Category Toggle */
.cat-toggle { .cat-toggle {
display: flex; background: rgba(0,0,0,0.3); display: flex; background: rgba(0,0,0,0.3);
padding: 4px; border-radius: 10px; width: fit-content; padding: 4px; border-radius: 10px; width: fit-content;
@@ -714,9 +669,6 @@ input:focus, .form-input:focus {
.ep-control { width: auto; flex: 1; } .ep-control { width: auto; flex: 1; }
} }
/* =========================================
8. ROOM LIST / LOBBY (If used externally)
========================================= */
.container { max-width: 1400px; margin: 0 auto; padding: 80px 40px 40px; } .container { max-width: 1400px; margin: 0 auto; padding: 80px 40px 40px; }
.header h1 { .header h1 {
font-size: 2.5rem; font-weight: 800; font-size: 2.5rem; font-weight: 800;
@@ -772,7 +724,7 @@ input:focus, .form-input:focus {
height: 100%; height: 100%;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
border: 3px solid #1a1a1a; /* Borde oscuro para separar del gradiente */ border: 3px solid #1a1a1a;
background: #2a2a2a; background: #2a2a2a;
} }
@@ -796,13 +748,13 @@ input:focus, .form-input:focus {
.video-toast-container { .video-toast-container {
position: absolute; position: absolute;
bottom: 100px; /* Encima de la barra de controles */ bottom: 100px;
left: 20px; left: 20px;
z-index: 80; /* Por encima del video, debajo de los controles */ z-index: 80;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
pointer-events: none; /* Permitir clicks a través de ellos */ pointer-events: none;
max-width: 400px; max-width: 400px;
} }
@@ -819,9 +771,8 @@ input:focus, .form-input:focus {
font-size: 0.9rem; font-size: 0.9rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.3); 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; 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; opacity: 0;
} }
@@ -850,7 +801,6 @@ input:focus, .form-input:focus {
margin-top: 2px; margin-top: 2px;
} }
/* --- BADGE DE NOTIFICACIÓN (Punto Rojo) --- */
#toggle-chat-btn { #toggle-chat-btn {
position: relative; position: relative;
} }
@@ -862,7 +812,7 @@ input:focus, .form-input:focus {
right: 2px; right: 2px;
width: 10px; width: 10px;
height: 10px; height: 10px;
background-color: #ef4444; /* Rojo */ background-color: #ef4444;
border: 2px solid #1a1a1a; border: 2px solid #1a1a1a;
border-radius: 50%; border-radius: 50%;
animation: pulse 2s infinite; animation: pulse 2s infinite;
@@ -885,11 +835,11 @@ input:focus, .form-input:focus {
} }
.video-toast.system-toast { .video-toast.system-toast {
border-left-color: #9ca3af; /* Borde gris */ border-left-color: #9ca3af;
background: rgba(20, 20, 20, 0.7); /* Un poco más transparente */ background: rgba(20, 20, 20, 0.7);
justify-content: center; justify-content: center;
padding: 6px 12px; padding: 6px 12px;
min-height: auto; /* Más compacto */ min-height: auto;
} }
.video-toast.system-toast .toast-msg { .video-toast.system-toast .toast-msg {
@@ -898,3 +848,77 @@ input:focus, .form-input:focus {
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
margin: 0; 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;
}
}

View File

@@ -21,7 +21,7 @@
<div class="video-area"> <div class="video-area">
<div class="room-header"> <div class="room-header">
<div class="header-left"> <div class="header-left">
<button id="leave-room-btn" class="btn-icon-glass" title="Leave"> <button id="leave-room-btn" class="btn-icon-glass" title="Leave" style="visibility: hidden;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
</button> </button>
<div class="room-info"> <div class="room-info">
@@ -49,6 +49,17 @@
<select id="room-server-select" class="glass-select-sm" title="Server"> <select id="room-server-select" class="glass-select-sm" title="Server">
<option value="" disabled selected>Server</option> <option value="" disabled selected>Server</option>
</select> </select>
<button
id="copy-invite-btn"
class="btn-icon-glass"
title="Copy invite link"
style="display:none;"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.07 0l1.41-1.41a5 5 0 0 0-7.07-7.07L10 5"/>
<path d="M14 11a5 5 0 0 0-7.07 0L5.5 12.41a5 5 0 0 0 7.07 7.07L14 19"/>
</svg>
</button>
</div> </div>
</div> </div>