diff --git a/desktop/package-lock.json b/desktop/package-lock.json index ba14a94..4038e7a 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@fastify/static": "^8.3.0", + "@fastify/websocket": "^11.2.0", "@xhayper/discord-rpc": "^1.3.0", "adm-zip": "^0.5.16", "bcryptjs": "^3.0.3", @@ -24,13 +25,15 @@ "node-addon-api": "^8.5.0", "node-cron": "^4.2.1", "playwright-chromium": "^1.57.0", - "sqlite3": "^5.1.7" + "sqlite3": "^5.1.7", + "ws": "^8.18.3" }, "devDependencies": { "@types/adm-zip": "^0.5.7", "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", + "@types/ws": "^8.18.1", "electron": "^39.2.5", "electron-builder": "^26.0.12", "node-gyp": "^12.1.0", @@ -1193,6 +1196,27 @@ "glob": "^11.0.0" } }, + "node_modules/@fastify/websocket": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz", + "integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.3", + "fastify-plugin": "^5.0.0", + "ws": "^8.16.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -1640,6 +1664,16 @@ "license": "MIT", "optional": true }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -3398,6 +3432,18 @@ "node": ">= 0.4" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -7645,6 +7691,12 @@ "node": ">= 0.8" } }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/desktop/package.json b/desktop/package.json index 6042b1b..86df586 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -13,6 +13,7 @@ "type": "commonjs", "dependencies": { "@fastify/static": "^8.3.0", + "@fastify/websocket": "^11.2.0", "@xhayper/discord-rpc": "^1.3.0", "adm-zip": "^0.5.16", "bcryptjs": "^3.0.3", @@ -27,13 +28,15 @@ "node-addon-api": "^8.5.0", "node-cron": "^4.2.1", "playwright-chromium": "^1.57.0", - "sqlite3": "^5.1.7" + "sqlite3": "^5.1.7", + "ws": "^8.18.3" }, "devDependencies": { "@types/adm-zip": "^0.5.7", "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", + "@types/ws": "^8.18.1", "electron": "^39.2.5", "electron-builder": "^26.0.12", "node-gyp": "^12.1.0", diff --git a/desktop/server.js b/desktop/server.js index a6f2b94..cf3a7b6 100644 --- a/desktop/server.js +++ b/desktop/server.js @@ -32,6 +32,8 @@ const listRoutes = require("./electron/api/list/list.routes"); const anilistRoute = require("./electron/api/anilist/anilist"); const localRoutes = require("./electron/api/local/local.routes"); const configRoutes = require("./electron/api/config/config.routes"); +const roomRoutes = require("./electron/api/rooms/rooms.routes"); +const { setupRoomWebSocket } = require("./electron/api/rooms/rooms.websocket"); fastify.addHook("preHandler", async (request) => { const auth = request.headers.authorization; @@ -75,11 +77,13 @@ fastify.register(anilistRoute, { prefix: "/api" }); fastify.register(listRoutes, { prefix: "/api" }); fastify.register(localRoutes, { prefix: "/api" }); fastify.register(configRoutes, { prefix: "/api" }); +fastify.register(roomRoutes, { prefix: "/api" }); const sleep = ms => new Promise(r => setTimeout(r, ms)); const start = async () => { try { + await fastify.register(require('@fastify/websocket')); ensureConfigFile() initDatabase("anilist"); initDatabase("favorites"); @@ -87,7 +91,7 @@ const start = async () => { initDatabase("userdata"); initDatabase("local_library"); init(); - + setupRoomWebSocket(fastify); const refreshAll = async () => { await refreshTrendingAnime(); diff --git a/desktop/src/api/rooms/rooms.controller.ts b/desktop/src/api/rooms/rooms.controller.ts new file mode 100644 index 0000000..6f599c3 --- /dev/null +++ b/desktop/src/api/rooms/rooms.controller.ts @@ -0,0 +1,141 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import * as roomService from './rooms.service'; +import { getUserById } from '../user/user.service'; + +interface CreateRoomBody { + name: string; + password?: string; +} + +interface JoinRoomBody { + password?: string; + guestName?: string; +} + +export async function createRoom(req: any, reply: FastifyReply) { + try { + const { name, password } = req.body as CreateRoomBody; + const userId = req.user?.id; + + if (!userId) { + return reply.code(401).send({ error: "Authentication required to create room" }); + } + + if (!name || name.trim().length === 0) { + return reply.code(400).send({ error: "Room name is required" }); + } + + const user = await getUserById(userId); + if (!user) { + return reply.code(404).send({ error: "User not found" }); + } + + const host = { + id: `user_${userId}`, + username: user.username, + avatar: user.profile_picture_url || undefined, + isHost: true, + isGuest: false, + userId + }; + + const room = roomService.createRoom(name, host, password); + + return reply.send({ + success: true, + room: { + id: room.id, + name: room.name, + hasPassword: !!room.password, + userCount: room.users.size + } + }); + } catch (err) { + console.error("Create Room Error:", err); + return reply.code(500).send({ error: "Failed to create room" }); + } +} + +export async function getRooms(req: FastifyRequest, reply: FastifyReply) { + try { + const rooms = roomService.getAllRooms(); + + const roomList = rooms.map((room) => ({ + id: room.id, + name: room.name, + host: room.host.username, + userCount: room.users.size, + hasPassword: !!room.password, + currentlyWatching: room.currentVideo ? { + animeId: room.currentVideo.animeId, + episode: room.currentVideo.episode + } : null + })); + + return reply.send({ rooms: roomList }); + } catch (err) { + console.error("Get Rooms Error:", err); + return reply.code(500).send({ error: "Failed to retrieve rooms" }); + } +} + +export async function getRoom(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as { id: string }; + const room = roomService.getRoom(id); + + if (!room) { + return reply.code(404).send({ error: "Room not found" }); + } + + return reply.send({ + room: { + id: room.id, + name: room.name, + host: { + username: room.host.username, + avatar: room.host.avatar + }, + users: Array.from(room.users.values()).map(u => ({ + id: u.id, + username: u.username, + avatar: u.avatar, + isHost: u.isHost, + isGuest: u.isGuest + })), + hasPassword: !!room.password, + currentVideo: room.currentVideo + } + }); + } catch (err) { + console.error("Get Room Error:", err); + return reply.code(500).send({ error: "Failed to retrieve room" }); + } +} + +export async function deleteRoom(req: any, reply: FastifyReply) { + try { + const { id } = req.params as { id: string }; + const userId = req.user?.id; + + if (!userId) { + return reply.code(401).send({ error: "Authentication required" }); + } + + const room = roomService.getRoom(id); + if (!room) { + return reply.code(404).send({ error: "Room not found" }); + } + + if (room.host.userId !== userId) { + return reply.code(403).send({ error: "Only the host can delete the room" }); + } + + roomService.deleteRoom(id); + + return reply.send({ success: true }); + } catch (err) { + console.error("Delete Room Error:", err); + return reply.code(500).send({ error: "Failed to delete room" }); + } +} \ No newline at end of file diff --git a/desktop/src/api/rooms/rooms.routes.ts b/desktop/src/api/rooms/rooms.routes.ts new file mode 100644 index 0000000..0df76e8 --- /dev/null +++ b/desktop/src/api/rooms/rooms.routes.ts @@ -0,0 +1,11 @@ +import { FastifyInstance } from 'fastify'; +import * as controller from './rooms.controller'; + +async function roomRoutes(fastify: FastifyInstance) { + fastify.post('/rooms', controller.createRoom); + fastify.get('/rooms', controller.getRooms); + fastify.get('/rooms/:id', controller.getRoom); + fastify.delete('/rooms/:id', controller.deleteRoom); +} + +export default roomRoutes; \ No newline at end of file diff --git a/desktop/src/api/rooms/rooms.service.ts b/desktop/src/api/rooms/rooms.service.ts new file mode 100644 index 0000000..89b374c --- /dev/null +++ b/desktop/src/api/rooms/rooms.service.ts @@ -0,0 +1,130 @@ +import crypto from 'crypto'; + +interface RoomUser { + id: string; + username: string; + avatar?: string; + isHost: boolean; + isGuest: boolean; + userId?: number; // ID real del usuario si está logueado +} + +interface RoomMetadata { + id: string; + title: string; + episode: number; + image?: string; + source?: string; +} + +interface RoomData { + id: string; + name: string; + host: RoomUser; + users: Map; + createdAt: number; + currentVideo: { + animeId?: number; + episode?: number; + source?: string; + videoData?: any; + currentTime: number; + isPlaying: boolean; + } | null; + password?: string; + metadata?: RoomMetadata | null; +} + +const rooms = new Map(); + +export function generateRoomId(): string { + return crypto.randomBytes(8).toString('hex'); +} + +export function createRoom(name: string, host: RoomUser, password?: string): RoomData { + const roomId = generateRoomId(); + + const room: RoomData = { + id: roomId, + name, + host, + users: new Map([[host.id, host]]), + createdAt: Date.now(), + currentVideo: null, + password: password || undefined, + metadata: null, + }; + + rooms.set(roomId, room); + return room; +} + +export function getRoom(roomId: string): RoomData | null { + return rooms.get(roomId) || null; +} + +export function getAllRooms(): RoomData[] { + return Array.from(rooms.values()).map(room => ({ + ...room, + users: room.users + })); +} + +export function addUserToRoom(roomId: string, user: RoomUser): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + room.users.set(user.id, user); + return true; +} + +export function removeUserFromRoom(roomId: string, userId: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + room.users.delete(userId); + + // Si no quedan usuarios, eliminar la sala + if (room.users.size === 0) { + rooms.delete(roomId); + return true; + } + + // Si era el host, asignar nuevo host + if (room.host.id === userId && room.users.size > 0) { + const newHost = Array.from(room.users.values())[0]; + newHost.isHost = true; + room.host = newHost; + } + + return true; +} + +export function updateRoomVideo(roomId: string, videoData: any): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + room.currentVideo = videoData; + return true; +} + +export function deleteRoom(roomId: string): boolean { + return rooms.delete(roomId); +} + +export function verifyRoomPassword(roomId: string, password?: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + if (!room.password) return true; + if (!password) return false; + + return room.password === password; +} + +export function updateRoomMetadata(roomId: string, metadata: any): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + room.metadata = metadata; + return true; +} \ No newline at end of file diff --git a/desktop/src/api/rooms/rooms.websocket.ts b/desktop/src/api/rooms/rooms.websocket.ts new file mode 100644 index 0000000..bcd863e --- /dev/null +++ b/desktop/src/api/rooms/rooms.websocket.ts @@ -0,0 +1,397 @@ +import { FastifyInstance, FastifyRequest } from 'fastify'; +import * as roomService from './rooms.service'; +import { getUserById } from '../user/user.service'; +import jwt from 'jsonwebtoken'; + +interface WSClient { + socket: any; + userId: string; + username: string; + roomId: string; + isGuest: boolean; +} + +const clients = new Map(); + +interface WSParams { + roomId: string; +} + +interface WSQuery { + token?: string; + guestName?: string; + password?: string; +} + +export function setupRoomWebSocket(fastify: FastifyInstance) { + // @ts-ignore + fastify.get('/ws/room/:roomId', { websocket: true }, (connection: any, req: any) => { + handleWebSocketConnection(connection, req).catch(err => { + console.error('WebSocket error:', err); + try { + connection.socket.close(); + } catch (e) { + // Socket already closed + } + }); + }); +} + +async function handleWebSocketConnection(connection: any, req: any) { + const socket = connection.socket || connection; + const roomId = req.params.roomId; + const token = req.query.token; + const guestName = req.query.guestName; + const password = req.query.password; + + let userId: string; + let username: string; + let avatar: string | undefined; + let isGuest = false; + let realUserId: any; + + // Verificar si la sala existe + const room = roomService.getRoom(roomId); + if (!room) { + socket.send(JSON.stringify({ + type: 'error', + message: 'Room not found' + })); + socket.close(); + return; + } + + // Verificar contraseña si existe + if (room.password) { + if (!password || !roomService.verifyRoomPassword(roomId, password)) { + socket.send(JSON.stringify({ + type: 'error', + message: 'Invalid password' + })); + socket.close(); + return; + } + } + + // Autenticar usuario o crear invitado + if (token) { + try { + const decoded: any = jwt.verify(token, process.env.JWT_SECRET!); + realUserId = decoded.id; + const user = await getUserById(realUserId); + + if (user) { + userId = `user_${realUserId}`; + username = user.username; + avatar = user.profile_picture_url || undefined; + isGuest = false; + } else { + throw new Error('User not found'); + } + } catch (err) { + socket.send(JSON.stringify({ + type: 'error', + message: 'Invalid token' + })); + socket.close(); + return; + } + } else if (guestName && guestName.trim()) { + const nameToCheck = guestName.trim(); + + const isNameTaken = Array.from(room.users.values()).some( + u => u.username.toLowerCase() === nameToCheck.toLowerCase() + ); + + if (isNameTaken) { + socket.send(JSON.stringify({ + type: 'error', + message: 'Username is already taken' + })); + socket.close(); + return; + } + + userId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + username = nameToCheck; + isGuest = true; + } else { + socket.send(JSON.stringify({ + type: 'error', + message: 'Authentication required' + })); + socket.close(); + return; + } + + const isHost = room.host.userId === realUserId || room.host.id === userId; + + console.log('WebSocket Connection:', { + userId, + realUserId, + roomHostId: room.host.id, + roomHostUserId: room.host.userId, + isHost + }); + + // Agregar usuario a la sala + const userInRoom = { + id: userId, + username, + avatar, + isHost: isHost, // ← CORREGIDO: Usar la verificación correcta + isGuest, + userId: realUserId + }; + + roomService.addUserToRoom(roomId, userInRoom); + + // Registrar cliente + clients.set(userId, { + socket: socket, + userId, + username, + roomId, + isGuest + }); + + // Enviar estado inicial + socket.send(JSON.stringify({ + type: 'init', + userId, + username, + isGuest, + room: { + id: room.id, + name: room.name, + users: Array.from(room.users.values()).map(u => ({ + id: u.id, + username: u.username, + avatar: u.avatar, + isHost: u.isHost, + isGuest: u.isGuest + })), + currentVideo: room.currentVideo + } + })); + + // Notificar a otros usuarios + broadcastToRoom(roomId, { + type: 'user_joined', + user: { + id: userId, + username, + avatar, + isGuest + } + }, userId); + + // Manejar mensajes + socket.on('message', (message: Buffer) => { + try { + const data = JSON.parse(message.toString()); + handleMessage(roomId, userId, data); + } catch (err) { + console.error('WebSocket message error:', err); + } + }); + + // Manejar desconexión + socket.on('close', () => { + clients.delete(userId); + roomService.removeUserFromRoom(roomId, userId); + + broadcastToRoom(roomId, { + type: 'user_left', + user: { userId, username } + }); + }); +} + +function handleMessage(roomId: string, userId: string, data: any) { + const room = roomService.getRoom(roomId); + if (!room) return; + + console.log('Handling message:', data.type, 'from user:', userId, 'isHost:', room.host.id === userId); + + switch (data.type) { + case 'chat': + broadcastToRoom(roomId, { + type: 'chat', + userId, + username: room.users.get(userId)?.username || 'Unknown', + avatar: room.users.get(userId)?.avatar, + message: data.message, + timestamp: Date.now() + }); + break; + + case 'video_update': + if (room.host.id !== userId) return; + + roomService.updateRoomVideo(roomId, data.video); + roomService.updateRoomMetadata(roomId, data.metadata); + + broadcastToRoom(roomId, { + type: 'video_update', + video: data.video, + metadata: data.metadata // ✅ CLAVE + }); + + break; + + + case 'sync': + // Solo el host puede hacer sync + if (room.host.id !== userId) return; + + if (room.currentVideo) { + room.currentVideo.currentTime = data.currentTime; + room.currentVideo.isPlaying = data.isPlaying; + } + + broadcastToRoom(roomId, { + type: 'sync', + currentTime: data.currentTime, + isPlaying: data.isPlaying + }, userId); + break; + + case 'request_users': + const currentRoom = roomService.getRoom(roomId); + if (currentRoom) { + const client = clients.get(userId); + if (client && client.socket && client.socket.readyState === 1) { + client.socket.send(JSON.stringify({ + type: 'users_update', // Nuevo tipo de respuesta + users: Array.from(currentRoom.users.values()).map(u => ({ + id: u.id, + username: u.username, + avatar: u.avatar, + isHost: u.isHost, + isGuest: u.isGuest + })) + })); + } + } + break; + + case 'play': + // Solo el host puede controlar la reproducción + if (room.host.id !== userId) { + console.warn('Non-host attempted play:', userId); + return; + } + + console.log('Broadcasting play event to room:', roomId); + broadcastToRoom(roomId, { + type: 'play', + currentTime: data.currentTime, + timestamp: Date.now() + }, userId); // IMPORTANTE: excludeUserId para no enviar al host + break; + + case 'pause': + // Solo el host puede controlar la reproducción + if (room.host.id !== userId) { + console.warn('Non-host attempted pause:', userId); + return; + } + + console.log('Broadcasting pause event to room:', roomId); + broadcastToRoom(roomId, { + type: 'pause', + currentTime: data.currentTime, + timestamp: Date.now() + }, userId); // IMPORTANTE: excludeUserId para no enviar al host + break; + + case 'seek': + // Solo el host puede controlar la reproducción + if (room.host.id !== userId) { + console.warn('Non-host attempted seek:', userId); + return; + } + + console.log('Broadcasting seek event to room:', roomId); + broadcastToRoom(roomId, { + type: 'seek', + currentTime: data.currentTime, + timestamp: Date.now() + }, userId); // IMPORTANTE: excludeUserId para no enviar al host + break; + + case 'request_sync': + // Cualquier usuario puede pedir sync + const host = clients.get(room.host.id); + if (host && host.socket && host.socket.readyState === 1) { + host.socket.send(JSON.stringify({ + type: 'sync_requested', + requestedBy: userId + })); + } + break; + + case 'request_state': + // Enviar estado actual de la sala al usuario que lo solicita + const client = clients.get(userId); + if (client && client.socket && client.socket.readyState === 1) { + const updatedRoom = roomService.getRoom(roomId); + if (updatedRoom) { + client.socket.send(JSON.stringify({ + type: 'init', + userId, + username: client.username, + isGuest: client.isGuest, + room: { + id: updatedRoom.id, + name: updatedRoom.name, + users: Array.from(updatedRoom.users.values()).map(u => ({ + id: u.id, + username: u.username, + avatar: u.avatar, + isHost: u.isHost, + isGuest: u.isGuest + })), + currentVideo: room.currentVideo, + metadata: room.metadata + } + })); + } + } + break; + + default: + console.warn('Unknown message type:', data.type); + break; + } +} + +function broadcastToRoom(roomId: string, message: any, excludeUserId?: string) { + const room = roomService.getRoom(roomId); + if (!room) return; + + const messageStr = JSON.stringify(message); + + console.log('Broadcasting to room:', roomId, 'message type:', message.type, 'excluding:', excludeUserId); + + let sentCount = 0; + room.users.forEach((user) => { + if (user.id !== excludeUserId) { + const client = clients.get(user.id); + if (client && client.socket && client.socket.readyState === 1) { + try { + client.socket.send(messageStr); + sentCount++; + console.log('Sent to user:', user.id, user.username); + } catch (err) { + console.error('Error sending message to user:', user.id, err); + } + } else { + console.warn('User socket not ready:', user.id, 'readyState:', client?.socket?.readyState); + } + } + }); + + console.log(`Broadcast complete: sent to ${sentCount} users`); +} \ No newline at end of file diff --git a/desktop/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js index bd0fd0b..c672fb4 100644 --- a/desktop/src/scripts/anime/player.js +++ b/desktop/src/scripts/anime/player.js @@ -16,6 +16,9 @@ const AnimePlayer = (function() { let _totalEpisodes = 0; let _manualExtensionId = null; let _activeSubtitleIndex = -1; + let _roomMode = false; + let _isRoomHost = false; + let _roomWebSocket = null; let hlsInstance = null; let subtitleRenderer = null; @@ -61,23 +64,30 @@ const AnimePlayer = (function() { subtitlesCanvas: null }; - function init(animeId, initialSource, isLocal, animeData) { + function init(animeId, initialSource, isLocal, animeData, roomMode = false) { + _roomMode = roomMode; _animeId = animeId; _entrySource = initialSource || 'anilist'; _isLocal = isLocal; - _malId = animeData.idMal || null; _totalEpisodes = animeData.episodes || 1000; if (animeData.title) { - _animeTitle = animeData.title.romaji || animeData.title.english || animeData.title.native || animeData.title || "Anime"; + _animeTitle = animeData.title.romaji || animeData.title.english || "Anime"; } - _skipIntervals = []; - _localEntryId = null; - initElements(); setupEventListeners(); - loadExtensionsList(); + + // In Room Mode, we show the player immediately and hide extra controls + if (_roomMode) { + if(els.playerWrapper) { + els.playerWrapper.style.display = 'block'; + els.playerWrapper.classList.add('room-mode'); + } + // Hide extension list loading in room mode + } else { + loadExtensionsList(); + } } function initElements() { @@ -134,8 +144,10 @@ const AnimePlayer = (function() { function setupEventListeners() { // Close player - const closeBtn = document.getElementById('close-player-btn'); - if(closeBtn) closeBtn.addEventListener('click', closePlayer); + if(!_roomMode) { + const closeBtn = document.getElementById('close-player-btn'); + if(closeBtn) closeBtn.addEventListener('click', closePlayer); + } // Episode navigation if(els.prevBtn) els.prevBtn.onclick = () => playEpisode(_currentEpisode - 1); @@ -183,18 +195,35 @@ const AnimePlayer = (function() { setupKeyboardShortcuts(); } - function setupCustomControls() { - // Play/Pause - if(els.playPauseBtn) { - els.playPauseBtn.onclick = togglePlayPause; - } - if(els.video) { - // Remove old listeners to be safe (though usually new element) - els.video.onclick = togglePlayPause; - els.video.ondblclick = toggleFullscreen; + function loadVideoFromRoom(videoData) { + console.log('AnimePlayer.loadVideoFromRoom called with:', videoData); + + if (!videoData || !videoData.url) { + console.error('Invalid video data provided to loadVideoFromRoom'); + return; } - // Volume + _currentSubtitles = videoData.subtitles || []; + + if (els.loader) els.loader.style.display = 'none'; + + initVideoPlayer(videoData.url, videoData.type || 'm3u8', videoData.subtitles || []); + } + + function setupCustomControls() { + // ELIMINADO: if (_roomMode && !_isRoomHost) return; + // Ahora permitimos que el código fluya para habilitar volumen y ajustes a todos + + // 1. Play/Pause (SOLO HOST) + if(els.playPauseBtn) { + els.playPauseBtn.onclick = togglePlayPause; // La validación de permiso se hará dentro de togglePlayPause + } + if(els.video) { + els.video.onclick = togglePlayPause; // Click en video para pausar + els.video.ondblclick = toggleFullscreen; // Doble click siempre permitido + } + + // 2. Volume (TODOS) if(els.volumeBtn) { els.volumeBtn.onclick = toggleMute; } @@ -204,7 +233,7 @@ const AnimePlayer = (function() { }; } - // Settings + // 3. Settings (TODOS - Aquí están los subtítulos y audio) if(els.settingsBtn) { els.settingsBtn.onclick = (e) => { e.stopPropagation(); @@ -219,7 +248,7 @@ const AnimePlayer = (function() { }; } - // Close settings when clicking outside + // Close settings when clicking outside (TODOS) document.onclick = (e) => { if (settingsPanelActive && els.settingsPanel && !els.settingsPanel.contains(e.target) && @@ -229,19 +258,19 @@ const AnimePlayer = (function() { } }; - // Fullscreen + // 4. Fullscreen (TODOS) if(els.fullscreenBtn) { els.fullscreenBtn.onclick = toggleFullscreen; } - // Progress bar + // 5. Progress bar (SOLO HOST para buscar, TODOS para ver) if(els.progressContainer) { + // El listener se añade, pero seekToPosition bloqueará a los invitados els.progressContainer.onclick = seekToPosition; } - // Video events + // 6. Video events (TODOS - Necesarios para actualizar la UI localmente) if(els.video) { - // Remove previous listeners first if sticking to same element, but we replace element usually els.video.onplay = onPlay; els.video.onpause = onPause; els.video.ontimeupdate = onTimeUpdate; @@ -283,9 +312,27 @@ const AnimePlayer = (function() { document.addEventListener('keydown', (e) => { if (!els.playerWrapper || els.playerWrapper.style.display === 'none') return; - // Ignore if typing in input if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + // En room mode, solo el host puede usar shortcuts de control + if (_roomMode && !_isRoomHost) { + // Permitir fullscreen y volumen para todos + if (e.key.toLowerCase() === 'f') { + e.preventDefault(); + toggleFullscreen(); + } else if (e.key.toLowerCase() === 'm') { + e.preventDefault(); + toggleMute(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + adjustVolume(0.1); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + adjustVolume(-0.1); + } + return; + } + switch(e.key.toLowerCase()) { case ' ': case 'k': @@ -352,11 +399,23 @@ const AnimePlayer = (function() { // Control functions function togglePlayPause() { + if (_roomMode && !_isRoomHost) { + console.log('Guests cannot control playback'); + return; + } + if (!els.video) return; + if (els.video.paused) { els.video.play().catch(() => {}); + if (_roomMode && _isRoomHost) { + sendRoomEvent('play', { currentTime: els.video.currentTime }); + } } else { els.video.pause(); + if (_roomMode && _isRoomHost) { + sendRoomEvent('pause', { currentTime: els.video.currentTime }); + } } } @@ -398,9 +457,18 @@ const AnimePlayer = (function() { function seekToPosition(e) { if (!els.video || !els.progressContainer) return; + if (_roomMode && !_isRoomHost) return; + const rect = els.progressContainer.getBoundingClientRect(); const pos = (e.clientX - rect.left) / rect.width; - els.video.currentTime = pos * els.video.duration; + const newTime = pos * els.video.duration; + + els.video.currentTime = newTime; + + // En room mode, enviar evento de seek + if (_roomMode && _isRoomHost) { + sendRoomEvent('seek', { currentTime: newTime }); + } } function updateProgressHandle(e) { @@ -410,14 +478,30 @@ const AnimePlayer = (function() { els.progressHandle.style.left = `${pos * 100}%`; } + function seekRelative(seconds) { if (!els.video) return; - els.video.currentTime = Math.max(0, Math.min(els.video.duration, els.video.currentTime + seconds)); - } + if (_roomMode && !_isRoomHost) return; + const newTime = Math.max(0, Math.min(els.video.duration, els.video.currentTime + seconds)); + els.video.currentTime = newTime; + + // En room mode, enviar evento de seek + if (_roomMode && _isRoomHost) { + sendRoomEvent('seek', { currentTime: newTime }); + } + } function seekToPercent(percent) { if (!els.video) return; - els.video.currentTime = els.video.duration * percent; + if (_roomMode && !_isRoomHost) return; + + const newTime = els.video.duration * percent; + els.video.currentTime = newTime; + + // En room mode, enviar evento de seek + if (_roomMode && _isRoomHost) { + sendRoomEvent('seek', { currentTime: newTime }); + } } // Video event handlers @@ -476,7 +560,7 @@ const AnimePlayer = (function() { } function onEnded() { - if (_currentEpisode < _totalEpisodes) { + if (!_roomMode && _currentEpisode < _totalEpisodes) { playEpisode(_currentEpisode + 1); } } @@ -501,6 +585,22 @@ const AnimePlayer = (function() { els.volumeBtn.innerHTML = icon; } + function sendRoomEvent(eventType, data = {}) { + if (!_roomMode || !_isRoomHost || !_roomWebSocket) return; + if (_roomWebSocket.readyState !== WebSocket.OPEN) return; + + console.log('Sending room event:', eventType, data); + _roomWebSocket.send(JSON.stringify({ + type: eventType, + ...data + })); + } + + function setWebSocket(ws) { + console.log('Setting WebSocket reference in AnimePlayer'); + _roomWebSocket = ws; + } + function formatTime(seconds) { if (!isFinite(seconds) || isNaN(seconds)) return '0:00'; const h = Math.floor(seconds / 3600); @@ -563,7 +663,7 @@ const AnimePlayer = (function() { } // 4. Playback Speed - if (els.video) { + if (els.video && (!_roomMode || _isRoomHost)) { const label = els.video.playbackRate === 1 ? 'Normal' : `${els.video.playbackRate}x`; html += createMenuItem('speed', 'Playback Speed', label, Icons.speed); } @@ -812,7 +912,7 @@ const AnimePlayer = (function() { } // Update progress for AniList - if (!_progressUpdated && els.video.duration) { + if (!_roomMode && !_progressUpdated && els.video.duration) { const percentage = els.video.currentTime / els.video.duration; if (percentage >= 0.8) { updateProgress(); @@ -1183,8 +1283,9 @@ const AnimePlayer = (function() { } function initVideoPlayer(url, type, subtitles = []) { - // 1. CLEANUP FIRST: Destroy subtitle renderer while elements still exist - // This prevents "removeChild" errors because the DOM is still intact + console.log('initVideoPlayer called:', { url, type, subtitles }); + + // 1. CLEANUP FIRST if (subtitleRenderer) { try { subtitleRenderer.dispose(); @@ -1194,16 +1295,18 @@ const AnimePlayer = (function() { subtitleRenderer = null; } - // 2. Destroy HLS instance if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } const container = document.querySelector('.video-frame'); - if (!container) return; + if (!container) { + console.error('Video frame container not found!'); + return; + } - // 3. Remove OLD Elements + // 2. Remove OLD Elements const oldVideo = container.querySelector('video'); const oldCanvas = container.querySelector('#subtitles-canvas'); @@ -1216,65 +1319,65 @@ const AnimePlayer = (function() { oldCanvas.remove(); } - // 4. Create NEW Elements + // 3. Create NEW Elements - CANVAS FIRST, then VIDEO + const newCanvas = document.createElement('canvas'); + newCanvas.id = 'subtitles-canvas'; + const newVideo = document.createElement('video'); newVideo.id = 'player'; newVideo.crossOrigin = 'anonymous'; newVideo.playsInline = true; - const newCanvas = document.createElement('canvas'); - newCanvas.id = 'subtitles-canvas'; - container.appendChild(newCanvas) + container.appendChild(newCanvas); container.appendChild(newVideo); els.video = newVideo; els.subtitlesCanvas = newCanvas; + console.log('Video and canvas elements created:', { video: els.video, canvas: els.subtitlesCanvas }); + + // Re-setup controls with new video element setupCustomControls(); - // 5. Initialize Player (HLS or Native) + // Hide loader + if (els.loader) els.loader.style.display = 'none'; + + // 4. Initialize Player if (Hls.isSupported() && type === 'm3u8') { + console.log('Initializing HLS player'); + hlsInstance = new Hls({ enableWorker: true, lowLatencyMode: false, - backBufferLength: 90 + backBufferLength: 90, + debug: false + }); + + hlsInstance.on(Hls.Events.ERROR, (event, data) => { + console.error('HLS Error:', data); + if (data.fatal) { + if (els.loader) { + els.loader.style.display = 'flex'; + if (els.loaderText) els.loaderText.textContent = 'Stream error: ' + (data.details || 'Unknown'); + } + } }); hlsInstance.attachMedia(els.video); hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => { + console.log('HLS media attached, loading source:', url); hlsInstance.loadSource(url); }); hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { + console.log('HLS manifest parsed, attaching subtitles'); attachSubtitles(subtitles); buildSettingsPanel(); - if (els.downloadBtn) els.downloadBtn.style.display = 'flex'; - els.video.play().catch(() => {}); - if (els.loader) els.loader.style.display = 'none'; - }); - hlsInstance.on(Hls.Events.LEVEL_SWITCHED, () => buildSettingsPanel()); - hlsInstance.on(Hls.Events.AUDIO_TRACK_SWITCHED, () => buildSettingsPanel()); + if (els.downloadBtn && !_roomMode) els.downloadBtn.style.display = 'flex'; - } else { - els.video.src = url; - attachSubtitles(subtitles); - buildSettingsPanel(); - els.video.play().catch(() => {}); - if(els.loader) els.loader.style.display = 'none'; - if (els.downloadBtn) els.downloadBtn.style.display = 'flex'; - } - - // 6. Init Subtitles with explicit delay - // We use setTimeout instead of requestAnimationFrame to let the Layout Engine catch up - if (type === 'm3u8') { - hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { - attachSubtitles(subtitles); - buildSettingsPanel(); - if (els.downloadBtn) els.downloadBtn.style.display = 'flex'; - - // IMPORTANTE: Esperar a loadedmetadata antes de init subtitles + // --- FIX: Inicializar el renderizador de subtítulos para HLS --- if (els.video.readyState >= 1) { initSubtitleRenderer(); } else { @@ -1282,23 +1385,34 @@ const AnimePlayer = (function() { initSubtitleRenderer(); }, { once: true }); } + // ------------------------------------------------------------- - els.video.play().catch(() => {}); - if (els.loader) els.loader.style.display = 'none'; + console.log('Attempting to play video'); + els.video.play().catch(err => { + console.error('Play error:', err); + }); }); + + hlsInstance.on(Hls.Events.LEVEL_SWITCHED, () => buildSettingsPanel()); + hlsInstance.on(Hls.Events.AUDIO_TRACK_SWITCHED, () => buildSettingsPanel()); + } else { + console.log('Using native video player'); els.video.src = url; attachSubtitles(subtitles); buildSettingsPanel(); - // Para video directo, esperar metadata els.video.addEventListener('loadedmetadata', () => { + console.log('Video metadata loaded'); initSubtitleRenderer(); }, { once: true }); - els.video.play().catch(() => {}); - if(els.loader) els.loader.style.display = 'none'; - if (els.downloadBtn) els.downloadBtn.style.display = 'flex'; + console.log('Attempting to play video'); + els.video.play().catch(err => { + console.error('Play error:', err); + }); + + if (els.downloadBtn && !_roomMode) els.downloadBtn.style.display = 'flex'; } } @@ -1762,12 +1876,19 @@ const AnimePlayer = (function() { // RPC function sendRPC({ startTimestamp, endTimestamp, paused = false } = {}) { + let stateText = `Episode ${_currentEpisode}`; + let detailsText = _animeTitle; + + if (_roomMode) { + stateText = `Watch Party - Ep ${_currentEpisode}`; + } + fetch("/api/rpc", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - details: _animeTitle, - state: `Episode ${_currentEpisode}`, + details: detailsText, + state: stateText, mode: "watching", startTimestamp, endTimestamp, @@ -1800,9 +1921,29 @@ const AnimePlayer = (function() { } } + function setRoomHost(isHost) { + console.log('Setting player host status:', isHost); + _isRoomHost = isHost; + + // Re-ejecutar la configuración de controles con el nuevo permiso + setupCustomControls(); + + // Forzar actualización visual si es necesario + if (els.playerWrapper) { + if (isHost) els.playerWrapper.classList.add('is-host'); + else els.playerWrapper.classList.remove('is-host'); + } + } + return { init, playEpisode, - getCurrentEpisode: () => _currentEpisode + getCurrentEpisode: () => _currentEpisode, + loadVideoFromRoom, + getVideoElement: () => els.video, + setRoomHost, + setWebSocket }; -})(); \ No newline at end of file +})(); + +window.AnimePlayer = AnimePlayer; \ No newline at end of file diff --git a/desktop/src/scripts/auth-guard.js b/desktop/src/scripts/auth-guard.js index e0cfd85..965f91e 100644 --- a/desktop/src/scripts/auth-guard.js +++ b/desktop/src/scripts/auth-guard.js @@ -115,4 +115,16 @@ function setupDropdown() { }) } -loadMeUI() \ No newline at end of file +loadMeUI() +const createRoomModal = new CreateRoomModal(); + +const createBtn = document.getElementById('nav-create-party'); +if (createBtn) { + createBtn.addEventListener('click', (e) => { + e.preventDefault(); + const dropdown = document.getElementById('nav-dropdown'); + if(dropdown) dropdown.classList.remove('active'); + + createRoomModal.open(); + }); +} \ No newline at end of file diff --git a/desktop/src/scripts/room-modal.js b/desktop/src/scripts/room-modal.js new file mode 100644 index 0000000..2a03513 --- /dev/null +++ b/desktop/src/scripts/room-modal.js @@ -0,0 +1,128 @@ +class CreateRoomModal { + constructor() { + this.modalId = 'cr-modal-overlay'; + this.isRendered = false; + this.render(); // Crear el HTML en el DOM al instanciar + } + + render() { + if (document.getElementById(this.modalId)) return; + + const modalHtml = ` +
+
+ +

Create Watch Party

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ `; + + document.body.insertAdjacentHTML('beforeend', modalHtml); + this.bindEvents(); + this.isRendered = true; + } + + bindEvents() { + const modal = document.getElementById(this.modalId); + const closeBtn = document.getElementById('cr-close'); + const cancelBtn = document.getElementById('cr-cancel'); + const form = document.getElementById('cr-form'); + + const close = () => this.close(); + + closeBtn.onclick = close; + cancelBtn.onclick = close; + + // Cerrar si clicamos fuera del contenido + modal.onclick = (e) => { + if (e.target === modal) close(); + }; + + form.onsubmit = (e) => this.handleSubmit(e); + } + + open() { + const token = localStorage.getItem('token'); + if (!token) { + // Aquí puedes disparar tu modal de login o redirigir + alert('You must be logged in to create a room'); + window.location.href = '/login'; // Opcional + return; + } + + const modal = document.getElementById(this.modalId); + modal.classList.add('show'); + document.querySelector('#cr-form input[name="name"]').focus(); + } + + close() { + const modal = document.getElementById(this.modalId); + modal.classList.remove('show'); + document.getElementById('cr-form').reset(); + } + + async handleSubmit(e) { + e.preventDefault(); + const btn = e.target.querySelector('button[type="submit"]'); + const originalText = btn.textContent; + + btn.disabled = true; + btn.textContent = 'Creating...'; + + const formData = new FormData(e.target); + const name = formData.get('name').trim(); + const password = formData.get('password').trim(); + const token = localStorage.getItem('token'); + + try { + const res = await fetch('/api/rooms', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + name, + password: password || undefined + }) + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to create room'); + + this.close(); + + // REDIRECCIÓN: + // Si estamos en la página de rooms, recargamos o dejamos que el socket actualice. + // Si estamos en otra página, vamos a la sala creada. + // Asumo que tu ruta de sala es /room (o query params). + // Ajusta esta línea según tu router: + window.location.href = `/room?id=${data.room.id}`; + + } catch (err) { + alert(err.message); + } finally { + btn.disabled = false; + btn.textContent = originalText; + } + } +} + +window.CreateRoomModal = CreateRoomModal; diff --git a/desktop/src/scripts/room.js b/desktop/src/scripts/room.js new file mode 100644 index 0000000..8397884 --- /dev/null +++ b/desktop/src/scripts/room.js @@ -0,0 +1,1196 @@ +const RoomsApp = (function() { + let ws = null; + let currentRoomId = null; + let currentUserId = null; + let currentUsername = null; + let isHost = false; + let isGuest = false; + let hlsInstance = null; + let selectedAnimeData = null; + let currentLoadedUrl = null; + let extensionsReady = false; + + let configState = { + extension: null, + server: null, + category: 'sub', // 'sub' o 'dub' + episode: 1 + }; + + let extensionsStore = { + list: [], + settings: {} // { anilist: {...}, gogo: {...} } + }; + + const elements = { + // Main view & Header + roomView: document.getElementById('room-view'), + roomName: document.getElementById('room-name'), + roomViewers: document.getElementById('room-viewers'), + leaveRoomBtn: document.getElementById('leave-room-btn'), + selectAnimeBtn: document.getElementById('select-anime-btn'), + toggleChatBtn: document.getElementById('toggle-chat-btn'), + + // Host Quick Controls (Header) + hostControls: document.getElementById('host-controls'), + roomExtSelect: document.getElementById('room-ext-select'), + roomServerSelect: document.getElementById('room-server-select'), + roomSdToggle: document.getElementById('room-sd-toggle'), + + // Info Display + npInfo: document.getElementById('now-playing-info'), + npTitle: document.getElementById('np-title'), + npEpisode: document.getElementById('np-episode'), + + // Player elements + video: document.getElementById('player'), + loading: document.getElementById('player-loading'), + playBtn: document.getElementById('play-pause-btn'), + timeDisplay: document.getElementById('time-display'), + progressContainer: document.querySelector('.progress-container'), + progressPlayed: document.querySelector('.progress-played'), + progressHandle: document.querySelector('.progress-handle'), + fullscreenBtn: document.getElementById('fullscreen-btn'), + subtitlesCanvas: document.getElementById('subtitles-canvas'), + + // Chat + usersList: document.getElementById('users-list'), + toggleUsersBtn: document.getElementById('toggle-users-btn'), + chatMessages: document.getElementById('chat-messages'), + chatForm: document.getElementById('chat-form'), + chatInput: document.getElementById('chat-input'), + roomLayout: document.getElementById('room-layout'), // Corregido: referencia al layout + + // Modals + joinRoomModal: document.getElementById('join-room-modal'), + joinRoomForm: document.getElementById('join-room-form'), + animeSearchModal: document.getElementById('anime-search-modal'), + animeSearchInput: document.getElementById('anime-search-input'), + animeResults: document.getElementById('anime-results'), + + // Config Elements (Modal) + stepSearch: document.getElementById('step-search'), + stepConfig: document.getElementById('step-config'), + backToSearchBtn: document.getElementById('back-to-search'), + configTitle: document.getElementById('selected-anime-title'), + configCover: document.getElementById('config-cover'), + selExtension: document.getElementById('sel-extension'), + selServer: document.getElementById('sel-server'), + selCategory: document.getElementById('sel-category'), + inpEpisode: document.getElementById('inp-episode'), + btnLaunch: document.getElementById('btn-launch-stream'), + configError: document.getElementById('config-error'), + + toastContainer: document.getElementById('video-toast-container'), + }; + + const ui = { + extContainer: document.getElementById('ext-chips-container'), + serverContainer: document.getElementById('server-chips-container'), + sdToggle: document.getElementById('modal-sd-toggle'), + epInput: document.getElementById('inp-episode'), + epInc: document.getElementById('ep-inc'), + epDec: document.getElementById('ep-dec'), + launchBtn: document.getElementById('btn-launch-stream'), + configError: document.getElementById('config-error') + }; + + async function init() { + const params = new URLSearchParams(window.location.search); + const urlRoomId = params.get('id'); + if (!urlRoomId) { + window.location.href = '/anime'; + return; + } + + currentRoomId = urlRoomId; + + if (window.AnimePlayer) { + console.log('Initializing AnimePlayer in room mode'); + window.AnimePlayer.init(null, 'anilist', false, {}, true); + } + + setupEventListeners(); + await preloadExtensions(); + + // --- NUEVO: Obtener info de la sala primero --- + try { + const res = await fetch(`/api/rooms/${currentRoomId}`); + if (!res.ok) throw new Error('Room not found'); + const data = await res.json(); + handleInitialEntry(data.room); + } catch (e) { + console.error(e); + alert("Room not found or deleted"); + window.location.href = '/anime'; + } + } + + // --- NUEVO: Función para manejar la entrada lógica --- + function handleInitialEntry(roomInfo) { + const token = localStorage.getItem('token'); + const passwordGroup = document.getElementById('password-group'); + + // Configurar UI del Modal con datos del Host + const hostInfoDiv = document.getElementById('join-host-info'); + const hostAvatar = document.getElementById('join-host-avatar'); + const hostText = document.getElementById('join-host-text'); + + if (hostInfoDiv && roomInfo.host) { + hostInfoDiv.style.display = 'flex'; + // Usar avatar del host o un placeholder + hostAvatar.src = roomInfo.host.avatar || '/public/assets/placeholder.png'; + hostText.innerHTML = `${escapeHtml(roomInfo.host.username)} invited you to watch`; + } + + // Configurar si pide contraseña + if (passwordGroup) { + // Si la sala tiene pass, mostramos el campo + passwordGroup.style.display = roomInfo.hasPassword ? 'block' : 'none'; + // Marcar en un atributo dataset si es requerida para validación + passwordGroup.dataset.required = roomInfo.hasPassword ? 'true' : 'false'; + } + + if (token) { + // Si tiene token, intentamos conectar directamente. + // Si hay pass y no somos el host/dueño, el socket fallará y pedirá pass luego. + connectToRoom(currentRoomId); + } else { + // Es Guest: Mostrar modal directamente + console.log('Guest user, showing modal...'); + if (elements.joinRoomModal) { + elements.joinRoomModal.classList.add('show'); + const nameInput = document.getElementById('guest-name-input'); + if (nameInput) { + nameInput.value = ''; + setTimeout(() => nameInput.focus(), 100); + } + } + } + } + + async function preloadExtensions() { + const res = await fetch('/api/extensions/anime'); + const data = await res.json(); + + extensionsStore.list = data.extensions || []; + + await Promise.all( + extensionsStore.list.map(async ext => { + const r = await fetch(`/api/extensions/${ext}/settings`); + extensionsStore.settings[ext] = await r.json(); + }) + ); + + extensionsReady = true; + } + + function setupEventListeners() { + // Join Room Form + const cancelJoinBtn = document.getElementById('cancel-join-btn'); + if (cancelJoinBtn) cancelJoinBtn.onclick = leaveRoom; + if (elements.joinRoomForm) elements.joinRoomForm.onsubmit = submitJoinForm; + + // Header Controls + if (elements.selectAnimeBtn) elements.selectAnimeBtn.onclick = openAnimeSearchModal; + if (elements.toggleChatBtn) elements.toggleChatBtn.onclick = toggleChat; + if (elements.leaveRoomBtn) elements.leaveRoomBtn.onclick = leaveRoom; + + // Host Quick Controls Listeners + if (elements.roomExtSelect) elements.roomExtSelect.onchange = (e) => onQuickExtensionChange(e, false); + if (elements.roomServerSelect) elements.roomServerSelect.onchange = onQuickServerChange; + + // Sub/Dub Toggle Logic (Header) + if (elements.roomSdToggle) { + elements.roomSdToggle.onclick = () => { + if (!isHost) return; + const currentState = elements.roomSdToggle.getAttribute('data-state'); + const newState = currentState === 'sub' ? 'dub' : 'sub'; + + // Update UI visually immediately + elements.roomSdToggle.setAttribute('data-state', newState); + elements.roomSdToggle.querySelectorAll('.sd-option').forEach(opt => { + opt.classList.toggle('active', opt.dataset.val === newState); + }); + + // Trigger Stream Reload + onQuickServerChange(); + }; + } + + // Anime Search Modal + const closeSearchBtn = document.getElementById('close-search-modal'); + const animeSearchBtn = document.getElementById('anime-search-btn'); + + if (closeSearchBtn) closeSearchBtn.onclick = closeAnimeSearchModal; + if (animeSearchBtn) animeSearchBtn.onclick = searchAnime; + + if (elements.animeSearchInput) { + elements.animeSearchInput.onkeypress = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + searchAnime(); + } + }; + } + + // Config Step (Modal) + if (elements.backToSearchBtn) { + elements.backToSearchBtn.onclick = () => { + elements.stepConfig.style.display = 'none'; + elements.stepSearch.style.display = 'block'; + }; + } + if (elements.selExtension) elements.selExtension.onchange = handleModalExtensionChange; + if (elements.btnLaunch) elements.btnLaunch.onclick = () => launchStream(true); // true = from modal + + // Room & User List + if (elements.toggleUsersBtn) elements.toggleUsersBtn.onclick = toggleUsersList; + + // Chat + if (elements.chatForm) elements.chatForm.onsubmit = sendMessage; + + // Anime results delegation + if (elements.animeResults) { + elements.animeResults.addEventListener('click', handleAnimeResultClick); + } + } + + // --- QUICK CONTROLS LOGIC (Header) --- + + async function populateQuickControls() { + if (!isHost || !selectedAnimeData) return; + if (!extensionsReady) return; + + elements.roomExtSelect.innerHTML = ''; + + extensionsStore.list.forEach(ext => { + const opt = document.createElement('option'); + opt.value = ext; + opt.textContent = ext[0].toUpperCase() + ext.slice(1); + elements.roomExtSelect.appendChild(opt); + }); + + // 🔥 FORZAR valor real + const extToUse = selectedAnimeData.source || extensionsStore.list[0]; + elements.roomExtSelect.value = extToUse; + + // 🔥 FORZAR carga de servers + await onQuickExtensionChange(null, true); + } + + function onQuickExtensionChange(e, silent = false) { + if (!isHost) return; + + const ext = elements.roomExtSelect.value; + const settings = extensionsStore.settings[ext]; + if (!settings) return; + + elements.roomServerSelect.innerHTML = ''; + + const servers = settings.episodeServers?.length + ? settings.episodeServers + : ['Default']; + + servers.forEach(srv => { + const opt = document.createElement('option'); + opt.value = srv; + opt.textContent = srv; + elements.roomServerSelect.appendChild(opt); + }); + + // 🔥 FORZAR server seleccionado + elements.roomServerSelect.value = servers[0]; + + handleDubUI(settings, 'header'); + + if (!silent) onQuickServerChange(); + } + + function handleDubUI(settings, context = 'header') { + const supportsDub = !!settings.supportsDub; + + if (context === 'header') { + const dubOpt = elements.roomSdToggle + ?.querySelector('.sd-option[data-val="dub"]'); + + if (!dubOpt) return; + + dubOpt.style.opacity = supportsDub ? '1' : '0.3'; + dubOpt.style.pointerEvents = supportsDub ? 'auto' : 'none'; + + if (!supportsDub && + elements.roomSdToggle.getAttribute('data-state') === 'dub') { + + elements.roomSdToggle.setAttribute('data-state', 'sub'); + elements.roomSdToggle.querySelectorAll('.sd-option') + .forEach(o => o.classList.toggle('active', o.dataset.val === 'sub')); + } + } + + if (context === 'modal') { + const dubBtn = ui.sdToggle?.querySelector('[data-val="dub"]'); + if (!dubBtn) return; + + dubBtn.classList.toggle('disabled', !supportsDub); + + if (!supportsDub && configState.category === 'dub') { + configState.category = 'sub'; + updateSDUI(); + } + } + } + + function onQuickServerChange() { + if (!isHost) return; + if (!elements.roomExtSelect.value) return; + if (!elements.roomServerSelect.value) return; + launchStream(false); + } + + // --- MODAL LOGIC --- + + function handleAnimeResultClick(e) { + const itemLink = e.target.closest('.search-item, .anime-result-item, a[href*="/anime/"]'); + + if (itemLink) { + e.preventDefault(); + e.stopPropagation(); + + let title, img, id; + + const titleEl = itemLink.querySelector('.search-title'); + const imgEl = itemLink.querySelector('.search-poster, img'); + + title = titleEl ? titleEl.textContent : (itemLink.textContent.trim() || 'Unknown'); + img = imgEl ? (imgEl.src || imgEl.dataset.src || '/public/assets/placeholder.png') : '/public/assets/placeholder.png'; + + const href = itemLink.getAttribute('href') || ''; + const hrefParts = href.split('/').filter(p => p); + id = hrefParts[hrefParts.length - 1] || itemLink.dataset.id; + + if (!id) return; + + selectedAnimeData = { + id: id, + title: title, + image: img, + source: 'anilist' + }; + + showConfigStep(); + } + } + + function showConfigStep() { + if (!selectedAnimeData) return; + if (!extensionsReady) return; + + // 1. Resetear UI básica + elements.configTitle.textContent = selectedAnimeData.title; + elements.configCover.src = selectedAnimeData.image; + if(ui.configError) ui.configError.style.display = 'none'; + + // 2. Resetear Estado interno + configState.episode = 1; + configState.server = null; + configState.category = 'sub'; // Reset a sub por defecto + configState.extension = null; // Reset extensión + + // 3. Resetear controles visuales + if(ui.epInput) ui.epInput.value = 1; + if(ui.launchBtn) ui.launchBtn.disabled = true; + updateSDUI(); // Función visual para el toggle sub/dub + + // 4. Configurar listeners de botones +/- y toggle + setupConfigListeners(); + + // 5. Renderizar los botones de extensiones + renderExtensionChips(); + + // Mostrar pantalla + elements.stepSearch.style.display = 'none'; + elements.stepConfig.style.display = 'block'; + } + + // Configura los botones + / - y el toggle Sub/Dub + function setupConfigListeners() { + // Episode Stepper + if(ui.epInc) ui.epInc.onclick = () => { + ui.epInput.value = parseInt(ui.epInput.value || 0) + 1; + configState.episode = ui.epInput.value; + }; + if(ui.epDec) ui.epDec.onclick = () => { + if(ui.epInput.value > 1) ui.epInput.value = parseInt(ui.epInput.value) - 1; + configState.episode = ui.epInput.value; + }; + if(ui.epInput) ui.epInput.onchange = (e) => configState.episode = e.target.value; + + // Sub/Dub Toggle + if(ui.sdToggle) { + ui.sdToggle.querySelectorAll('.cat-opt').forEach(opt => { + opt.onclick = () => { + if(opt.classList.contains('disabled')) return; + configState.category = opt.dataset.val; + updateSDUI(); + // Al cambiar categoría, recargar servidores (quizás cambien los disponibles) + if(configState.extension) loadServersForExtension(configState.extension); + }; + }); + } + } + + function updateSDUI() { + if(!ui.sdToggle) return; + ui.sdToggle.querySelectorAll('.cat-opt').forEach(opt => { + opt.classList.toggle('active', opt.dataset.val === configState.category); + }); + } + + // Dibuja los botones de Extensiones + function renderExtensionChips() { + ui.extContainer.innerHTML = ''; + + if (!extensionsStore.list || extensionsStore.list.length === 0) { + ui.extContainer.innerHTML = 'No extensions'; + return; + } + + extensionsStore.list.forEach(ext => { + const chip = document.createElement('div'); + chip.className = 'chip'; + chip.textContent = ext.charAt(0).toUpperCase() + ext.slice(1); + + // Auto-seleccionar si ya estaba en el estado (o default a anilist) + if (!configState.extension && ext === 'anilist') configState.extension = 'anilist'; + if (ext === configState.extension) chip.classList.add('active'); + + chip.onclick = () => { + // Actualizar visual + document.querySelectorAll('#ext-chips-container .chip').forEach(c => c.classList.remove('active')); + chip.classList.add('active'); + + // Actualizar lógica + configState.extension = ext; + configState.server = null; // Reset servidor al cambiar extensión + ui.launchBtn.disabled = true; // Deshabilitar Play hasta elegir server + + loadServersForExtension(ext); + }; + ui.extContainer.appendChild(chip); + }); + + // Cargar servidores de la extensión inicial + if(configState.extension) loadServersForExtension(configState.extension); + } + + // Carga los servidores de la API (Asíncrono) + async function loadServersForExtension(extName) { + if (!extensionsReady) return; + ui.serverContainer.innerHTML = '
Loading servers...
'; + ui.launchBtn.disabled = true; + + try { + const settings = extensionsStore.settings[extName]; + handleDubUI(settings, 'modal'); + + const servers = settings.episodeServers?.length + ? settings.episodeServers + : ['Default']; + + renderServerChips(servers); + + // Manejar si la extensión soporta Dub + const dubBtn = ui.sdToggle.querySelector('[data-val="dub"]'); + if (dubBtn) { + if (!settings.supportsDub) { + dubBtn.classList.add('disabled'); + if(configState.category === 'dub') { + configState.category = 'sub'; // Forzar sub si no hay dub + updateSDUI(); + } + } else { + dubBtn.classList.remove('disabled'); + } + } + + } catch (e) { + console.error("Error loading settings", e); + ui.serverContainer.innerHTML = '
Error loading servers
'; + } + } + + // Dibuja los botones de Servidores + function renderServerChips(servers) { + ui.serverContainer.innerHTML = ''; + + servers.forEach(srv => { + const chip = document.createElement('div'); + chip.className = 'chip'; + chip.textContent = srv; + + chip.onclick = () => { + document.querySelectorAll('#server-chips-container .chip').forEach(c => c.classList.remove('active')); + chip.classList.add('active'); + + configState.server = srv; + // AHORA sí habilitamos el botón de Play + ui.launchBtn.disabled = false; + }; + + ui.serverContainer.appendChild(chip); + }); + } + + async function handleModalExtensionChange() { + const extName = elements.selExtension.value; + if (!extName) return; + + elements.selServer.innerHTML = ''; + elements.selServer.disabled = true; + + try { + const settings = extensionsStore.settings[extName]; + + elements.selServer.innerHTML = ''; + + if (settings.episodeServers && settings.episodeServers.length > 0) { + settings.episodeServers.forEach(srv => { + const opt = document.createElement('option'); + opt.value = srv; + opt.textContent = srv; + elements.selServer.appendChild(opt); + }); + elements.selServer.disabled = false; + } else { + elements.selServer.innerHTML = ''; + elements.selServer.disabled = false; + } + + const dubOption = elements.selCategory.querySelector('option[value="dub"]'); + if (dubOption) { + dubOption.disabled = !settings.supportsDub; + if (!settings.supportsDub && elements.selCategory.value === 'dub') { + elements.selCategory.value = 'sub'; + } + } + + } catch (e) { + console.error("Error loading extension settings", e); + elements.selServer.innerHTML = ''; + } + } + + // --- STREAM LAUNCHER (Unified) --- + + async function launchStream(fromModal = false) { + if (!selectedAnimeData) { + console.warn("No anime selected data found"); + return; + } + + let ext, server, episode, category; + + if (fromModal) { + ext = configState.extension; + server = configState.server; + episode = configState.episode; + category = configState.category; + } else { + // LÓGICA DEL HEADER (Corregida) + ext = elements.roomExtSelect.value; + server = elements.roomServerSelect.value; + + // Intentar leer episodio del texto + let epText = elements.npEpisode.textContent.replace('Ep ', '').trim(); + + // Fallback robusto: Si dice "--" o está vacío, usar los datos guardados o 1 + if (!epText || epText === '--' || isNaN(epText)) { + if (selectedAnimeData.episode) { + epText = selectedAnimeData.episode; + } else { + epText = "1"; // Default absoluto + } + } + episode = epText; + + category = elements.roomSdToggle.getAttribute('data-state'); + } + + // Validación + if (!ext || !server || !episode) { + console.error("Missing params:", { ext, server, episode }); + if(fromModal) { + elements.configError.textContent = "Please select an extension, server, and episode."; + elements.configError.style.display = 'block'; + } else { + alert("Please select a valid server/extension."); + } + return; + } + + // Feedback UI + if(fromModal) { + elements.btnLaunch.disabled = true; + elements.btnLaunch.innerHTML = '
Fetching...'; + elements.configError.style.display = 'none'; + } + + try { + const apiUrl = `/api/watch/stream?animeId=${selectedAnimeData.id}&episode=${episode}&server=${encodeURIComponent(server)}&category=${category}&ext=${ext}&source=${selectedAnimeData.source}`; + console.log('Fetching stream:', apiUrl); + + const res = await fetch(apiUrl); + if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); + + const data = await res.json(); + + // Lógica de fuentes (igual que antes) + const source = data.videoSources?.find(s => s.type === 'm3u8') || data.videoSources?.[0]; + if (!source) throw new Error('No video source found'); + + let proxyUrl = `/api/proxy?url=${encodeURIComponent(source.url)}`; + const headers = data.headers || {}; + if (headers['Referer'] && headers['Referer'] !== "null") proxyUrl += `&referer=${encodeURIComponent(headers['Referer'])}`; + + const subtitles = (source.subtitles || []).map(sub => ({ + label: sub.language, + srclang: sub.id || sub.language.toLowerCase().slice(0, 2), + src: `/api/proxy?url=${encodeURIComponent(sub.url)}` + })); + + const videoPayload = { + type: 'video_update', + video: { + videoData: { + url: proxyUrl, + type: source.type || 'm3u8', + headers: headers + }, + subtitles: subtitles, + currentTime: 0, + isPlaying: true + }, + metadata: { + title: selectedAnimeData.title, + episode: episode, // Usar el episodio corregido + image: selectedAnimeData.image, + id: selectedAnimeData.id + } + }; + + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(videoPayload)); + + // Carga local + loadVideo(videoPayload.video); + updateHeaderInfo(videoPayload.metadata); + + // Si venimos del Modal, sincronizamos los controles rápidos del header + if(fromModal) { + closeAnimeSearchModal(); + + // --- CORRECCIÓN INICIO --- + + // 1. Actualizamos el source en la data global para que coincida con lo que acabas de elegir + selectedAnimeData.source = ext; + + // 2. Forzamos el repoblado del dropdown del header (ahora que tenemos anime y extensión) + await populateQuickControls(); + + // --- CORRECCIÓN FIN --- + + // Sincronizar UI del header + if (extensionsStore.list.includes(ext)) { + elements.roomExtSelect.value = ext; + // Forzamos carga silenciosa para llenar los servidores en el select del header + await onQuickExtensionChange(null, true); + elements.roomServerSelect.value = server; + + // Sincronizar toggle Dub/Sub + elements.roomSdToggle.setAttribute('data-state', category); + elements.roomSdToggle.querySelectorAll('.sd-option').forEach(o => + o.classList.toggle('active', o.dataset.val === category) + ); + } + } + } + + } catch (err) { + console.error('Stream launch error:', err); + const msg = "Error loading stream. Try another server."; + if(fromModal) { + elements.configError.textContent = msg; + elements.configError.style.display = 'block'; + } else { + alert(msg); + } + } finally { + if(fromModal) { + elements.btnLaunch.disabled = false; + elements.btnLaunch.innerHTML = 'Play in Room'; + } + } + } + + // --- CONNECTION & WS --- + + function connectToRoom(roomId, guestName, password) { + const token = localStorage.getItem('token'); + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws/room/${roomId}`; + + const params = new URLSearchParams(); + if (token) params.append('token', token); + if (guestName) params.append('guestName', guestName); + if (password) params.append('password', password); + + if (ws) ws.close(); + + ws = new WebSocket(`${wsUrl}?${params.toString()}`); + + ws.onopen = () => { + console.log('WebSocket Connected'); + if (window.AnimePlayer && typeof window.AnimePlayer.setWebSocket === 'function') { + window.AnimePlayer.setWebSocket(ws); + } + }; + + ws.onmessage = (e) => handleWebSocketMessage(JSON.parse(e.data)); + ws.onerror = (e) => console.error('WebSocket error:', e); + ws.onclose = () => { + console.log('Disconnected'); + if (window.AnimePlayer && typeof window.AnimePlayer.setWebSocket === 'function') { + window.AnimePlayer.setWebSocket(null); + } + }; + } + + function handleWebSocketMessage(data) { + switch (data.type) { + case 'error': + handleConnectionError(data.message); + break; + case 'init': + elements.joinRoomModal.classList.remove('show'); + currentUserId = data.userId; + currentUsername = data.username; + isGuest = data.isGuest; + updateRoomUI(data.room); + if (data.room.currentVideo && data.room.metadata) { + updateHeaderInfo(data.room.metadata); + } + break; + + case 'users_update': + renderUsersList(data.users); + break; + + case 'user_joined': + addSystemMessage(`${data.user.username} joined`); + updateUsersList(); + + if (isHost) { + sendSync(); + } + break; + case 'user_left': + addSystemMessage(`${data.user.username} left`); + updateUsersList(); + break; + case 'chat': + addChatMessage(data); // Siempre añadir al historial del chat lateral + + // Comprobar si el chat está oculto + const isChatHidden = elements.roomLayout.classList.contains('chat-hidden'); + + if (isChatHidden) { + // 1. Mostrar Toast sobre el video + showChatToast(data); + + // 2. Poner punto rojo en el botón + if (elements.toggleChatBtn) { + elements.toggleChatBtn.classList.add('has-unread'); + } + } + break; + case 'video_update': + loadVideo(data.video); + if (data.metadata) { + selectedAnimeData = { + ...selectedAnimeData, + ...data.metadata + }; + updateHeaderInfo(data.metadata); + } + break; + case 'sync': + syncVideo(data.currentTime, data.isPlaying); + updatePlayPauseButton(data.isPlaying); + break; + case 'play': + const vP = getVideoElement(); + if(vP) { vP.currentTime = data.currentTime; vP.play().catch(console.error); updatePlayPauseButton(true); } + break; + case 'pause': + const vPa = getVideoElement(); + if(vPa) { vPa.currentTime = data.currentTime; vPa.pause(); updatePlayPauseButton(false); } + break; + case 'seek': + const vS = getVideoElement(); + if(vS) { vS.currentTime = data.currentTime; } + break; + case 'sync_requested': + if (isHost) sendSync(); + break; + } + } + + function updateRoomUI(room) { + elements.roomName.textContent = room.name; + elements.roomViewers.textContent = `${room.users.length}`; + + const currentUser = room.users.find(u => u.id === currentUserId); + isHost = currentUser?.isHost || false; + + // Mostrar controles solo si es Host + if (elements.selectAnimeBtn) elements.selectAnimeBtn.style.display = isHost ? 'flex' : 'none'; + if (elements.hostControls) elements.hostControls.style.display = isHost ? 'flex' : 'none'; + + if (window.AnimePlayer && typeof window.AnimePlayer.setRoomHost === 'function') { + window.AnimePlayer.setRoomHost(isHost); + } + + // Si somos host y tenemos metadatos, poblar los controles del header + if (isHost && room.metadata) { + if(!selectedAnimeData) selectedAnimeData = { ...room.metadata, source: 'anilist' }; + populateQuickControls(); + } + + renderUsersList(room.users); + if (room.currentVideo) loadVideo(room.currentVideo); + } + + function updateHeaderInfo(meta) { + if (!meta) return; + if (elements.npTitle) elements.npTitle.textContent = meta.title; + if (elements.npEpisode) elements.npEpisode.textContent = `Episode ${meta.episode}`; + if (elements.npInfo) elements.npInfo.style.opacity = '1'; + + // Save data locally so we can use quick controls + if(!selectedAnimeData) selectedAnimeData = { ...meta, source: 'anilist' }; + else { + selectedAnimeData.id = meta.id; + selectedAnimeData.title = meta.title; + // Episode is tracked visually in header + } + } + + // --- UTILS & HELPERS --- + + function handleConnectionError(message) { + const msg = message.toLowerCase(); + if (msg.includes('password') || msg.includes('authorized')) { + const passwordGroup = document.getElementById('password-group'); + const token = localStorage.getItem('token'); + elements.joinRoomModal.classList.add('show'); + if (msg.includes('password')) { + passwordGroup.style.display = 'block'; + document.getElementById('join-password-input').focus(); + } + if(!token) document.getElementById('guest-name-input').focus(); + } else { + alert(message); + window.location.href = '/anime'; + } + } + + function submitJoinForm(e) { + e.preventDefault(); + const guestName = document.getElementById('guest-name-input').value.trim(); + const password = document.getElementById('join-password-input').value.trim(); + const passwordGroup = document.getElementById('password-group'); + + // Validar Nombre para Guest + if (!guestName && !localStorage.getItem('token')) { + alert("Please enter a name"); + return; + } + + // Validar Password si es requerida y está visible + if (passwordGroup.style.display !== 'none' && !password) { + alert("This room requires a password"); + return; + } + + connectToRoom(currentRoomId, guestName, password); + } + + // room.js - Reemplazar toggleChat + + function toggleChat() { + if (elements.roomLayout) { + elements.roomLayout.classList.toggle('chat-hidden'); + + // Si acabamos de ABRIR el chat (ya no tiene la clase chat-hidden) + if (!elements.roomLayout.classList.contains('chat-hidden')) { + // Quitar notificación roja + elements.toggleChatBtn.classList.remove('has-unread'); + // Opcional: Limpiar los toasts flotantes para que no estorben + if(elements.toastContainer) elements.toastContainer.innerHTML = ''; + } + + setTimeout(() => window.dispatchEvent(new Event('resize')), 350); + } + } + + function showChatToast(data) { + if (!elements.toastContainer) return; + + // Crear elemento + const toast = document.createElement('div'); + toast.className = 'video-toast'; + + // Avatar (usar el mismo fallback que el chat) + const avatarSrc = data.avatar || '/public/assets/placeholder.png'; // Asegúrate de tener un placeholder o lógica de iniciales + + toast.innerHTML = ` + +
+ ${escapeHtml(data.username)} + ${escapeHtml(data.message)} +
+ `; + + // Añadir al contenedor + elements.toastContainer.appendChild(toast); + + // Eliminar del DOM después de que termine la animación (5s total: 0.3s in + 4.2s wait + 0.5s out) + setTimeout(() => { + if (toast.parentElement) { + toast.remove(); + } + }, 5000); + + // Limitar número de toasts (máximo 3 al mismo tiempo para no tapar todo el video) + while (elements.toastContainer.children.length > 3) { + elements.toastContainer.removeChild(elements.toastContainer.firstChild); + } + } + + function renderUsersList(users) { + elements.usersList.innerHTML = users.map(user => ` +
+
${user.avatar ? `` : user.username[0].toUpperCase()}
+ ${escapeHtml(user.username)} + ${user.isHost ? 'HOST' : ''} +
+ `).join(''); + } + + function updateUsersList() { + if(ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'request_users' })); + } + + function toggleUsersList() { + const isVisible = elements.usersList.style.display !== 'none'; + elements.usersList.style.display = isVisible ? 'none' : 'block'; + } + + function addSystemMessage(text) { + // 1. Agregar al chat (siempre) + const div = document.createElement('div'); + div.className = 'chat-message system'; + div.innerHTML = `
${escapeHtml(text)}
`; + elements.chatMessages.appendChild(div); + elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight; + + // 2. Si el chat está oculto, mostrar notificación flotante + if (elements.roomLayout && elements.roomLayout.classList.contains('chat-hidden')) { + showSystemToast(text); + } + } + + function showSystemToast(text) { + if (!elements.toastContainer) return; + + const toast = document.createElement('div'); + toast.className = 'video-toast system-toast'; // Clase especial para diferenciarlo + toast.innerHTML = `${escapeHtml(text)}`; + + elements.toastContainer.appendChild(toast); + + // Eliminar a los 4 segundos + setTimeout(() => toast.remove(), 4000); + } + + function addChatMessage(data) { + const time = new Date(data.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const div = document.createElement('div'); + div.className = 'chat-message'; + + // LÓGICA DE AVATAR ACTUALIZADA + let avatarHtml; + if (data.avatar) { + // Si tiene imagen, usamos img tag + avatarHtml = `${data.username}`; + } else { + // Fallback a inicial + avatarHtml = data.username[0].toUpperCase(); + } + + div.innerHTML = ` +
${avatarHtml}
+
+
+ ${escapeHtml(data.username)} + ${time} +
+
${escapeHtml(data.message)}
+
+ `; + elements.chatMessages.appendChild(div); + elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight; + } + + function sendMessage(e) { + e.preventDefault(); + const message = elements.chatInput.value.trim(); + if (!message) return; + if(ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'chat', message })); + elements.chatInput.value = ''; + } + + function loadVideo(videoData) { + if (!videoData || !videoData.videoData) return; + const { url, type } = videoData.videoData; + + if (currentLoadedUrl === url) { + if (videoData.currentTime !== undefined) syncVideo(videoData.currentTime, videoData.isPlaying); + return; + } + currentLoadedUrl = url; + + if (window.AnimePlayer && typeof window.AnimePlayer.loadVideoFromRoom === 'function') { + if(elements.loading) elements.loading.style.display = 'none'; + window.AnimePlayer.loadVideoFromRoom({ url: url, type: type, subtitles: videoData.subtitles }); + + setTimeout(() => { + const newVideoElement = document.getElementById('player'); + if (newVideoElement) { + elements.video = newVideoElement; + attachVideoEventListeners(newVideoElement); + updatePlayPauseButton(!newVideoElement.paused); + } + }, 500); + } else { + loadVideoBasic(url, type, videoData); + } + } + + function getVideoElement() { + if (window.AnimePlayer) return window.AnimePlayer.getVideoElement(); + return document.getElementById('player'); + } + + // Fallback simple video loader + function loadVideoBasic(url, type, videoData) { + elements.loading.style.display = 'none'; + if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } + + if (Hls.isSupported() && type === 'm3u8') { + hlsInstance = new Hls(); + hlsInstance.loadSource(url); + hlsInstance.attachMedia(elements.video); + hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { + if (videoData.isPlaying) elements.video.play().catch(e=>console.error(e)); + }); + } else { + elements.video.src = url; + if (videoData.isPlaying) elements.video.play(); + } + } + + function syncVideo(currentTime, isPlaying) { + const video = getVideoElement(); + if (!video) return; + const timeDiff = Math.abs(video.currentTime - currentTime); + if (timeDiff > 1) video.currentTime = currentTime; + + if (isPlaying && video.paused) { + video.play().then(() => updatePlayPauseButton(true)).catch(console.error); + } else if (!isPlaying && !video.paused) { + video.pause(); + updatePlayPauseButton(false); + } + } + + function sendSync() { + const video = getVideoElement(); + if (!video || !ws) return; + ws.send(JSON.stringify({ type: 'sync', currentTime: video.currentTime, isPlaying: !video.paused })); + } + + function updatePlayPauseButton(isPlaying) { + const playBtn = document.getElementById('play-pause-btn'); + if (!playBtn) return; + playBtn.innerHTML = isPlaying ? + `` : + ``; + } + + function attachVideoEventListeners(video) { + if(!video) return; + video.addEventListener('play', () => updatePlayPauseButton(true)); + video.addEventListener('pause', () => updatePlayPauseButton(false)); + video.addEventListener('timeupdate', () => { + if(elements.timeDisplay) elements.timeDisplay.textContent = formatTime(video.currentTime) + " / " + formatTime(video.duration); + if(elements.progressPlayed) elements.progressPlayed.style.width = (video.currentTime/video.duration*100) + "%"; + }); + } + + function formatTime(s) { + if(isNaN(s)) return "0:00"; + const m = Math.floor(s/60), sec = Math.floor(s%60); + return m + ":" + (sec<10?"0":"") + sec; + } + + function leaveRoom() { + if (ws) ws.close(); + if (hlsInstance) hlsInstance.destroy(); + window.location.href = '/anime'; + } + + function openAnimeSearchModal() { + elements.animeSearchModal.classList.add('show'); + elements.stepSearch.style.display = 'block'; + elements.stepConfig.style.display = 'none'; + setTimeout(() => elements.animeSearchInput.focus(), 100); + } + + function closeAnimeSearchModal() { + elements.animeSearchModal.classList.remove('show'); + } + + async function searchAnime() { + const query = elements.animeSearchInput.value.trim(); + if (!query) return; + elements.animeResults.innerHTML = '
Searching...
'; + if (window.SearchManager) { + await window.SearchManager.search(query, 'anime', elements.animeResults); + } else { + elements.animeResults.innerHTML = 'SearchManager not loaded'; + } + } + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + return { init }; +})(); + +document.addEventListener('DOMContentLoaded', () => RoomsApp.init()); \ No newline at end of file diff --git a/desktop/src/views/views.routes.ts b/desktop/src/views/views.routes.ts index 3796a87..57f50d2 100644 --- a/desktop/src/views/views.routes.ts +++ b/desktop/src/views/views.routes.ts @@ -130,6 +130,12 @@ async function viewsRoutes(fastify: FastifyInstance) { reply.type('text/html').send(html); }); + fastify.get('/room', (req: FastifyRequest, reply: FastifyReply) => { + const htmlPath = path.join(__dirname, '..', '..', 'views', 'room.html'); + const html = fs.readFileSync(htmlPath, 'utf-8'); + reply.type('text/html').send(html); + }); + fastify.setNotFoundHandler((req, reply) => { const htmlPath = path.join(__dirname, '..', '..', 'views', '404.html'); const html = fs.readFileSync(htmlPath, 'utf-8'); diff --git a/desktop/views/anime/animes.html b/desktop/views/anime/animes.html index be468d2..91eed55 100644 --- a/desktop/views/anime/animes.html +++ b/desktop/views/anime/animes.html @@ -11,6 +11,7 @@ + @@ -124,6 +125,7 @@ + diff --git a/desktop/views/books/books.html b/desktop/views/books/books.html index 86ca8ce..5c9abd2 100644 --- a/desktop/views/books/books.html +++ b/desktop/views/books/books.html @@ -10,9 +10,9 @@ + -
@@ -101,6 +101,7 @@
+ diff --git a/desktop/views/components/navbar.html b/desktop/views/components/navbar.html index c81a9dd..8dd9a4c 100644 --- a/desktop/views/components/navbar.html +++ b/desktop/views/components/navbar.html @@ -53,6 +53,13 @@ Profile + +
+ diff --git a/desktop/views/gallery/image.html b/desktop/views/gallery/image.html index e18d27c..6bb54a5 100644 --- a/desktop/views/gallery/image.html +++ b/desktop/views/gallery/image.html @@ -12,6 +12,7 @@ + @@ -56,6 +57,7 @@ + diff --git a/desktop/views/marketplace.html b/desktop/views/marketplace.html index 2358e10..346688b 100644 --- a/desktop/views/marketplace.html +++ b/desktop/views/marketplace.html @@ -10,6 +10,8 @@ + +
@@ -66,9 +68,9 @@ + - diff --git a/desktop/views/profile.html b/desktop/views/profile.html index 825f994..1d7fc78 100644 --- a/desktop/views/profile.html +++ b/desktop/views/profile.html @@ -11,6 +11,7 @@ + @@ -227,6 +228,7 @@
+ diff --git a/desktop/views/room.html b/desktop/views/room.html new file mode 100644 index 0000000..2347e6d --- /dev/null +++ b/desktop/views/room.html @@ -0,0 +1,267 @@ + + + + + + + Watch Party - WaifuBoard + + + + + + + + + +
+
+ + WaifuBoard +
+
+ + + +
+
+
+
+
+
+
+ +
+

Loading...

+
+ Waiting selection... + + Episode -- +
+
+
+ + + +
+
+ + 0 +
+ + + + +
+
+ +
+
+
+ +
+ + +
+
+

Waiting for host...

+
+
+ +
+
+
+
+
+
+ +
+
+ + + + 0:00 / 0:00 +
+ +
+ +
+ +
+ +
+
+
+
+
+
+ +
+
+

Chat

+ +
+ + +
+
+ + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/desktop/views/schedule.html b/desktop/views/schedule.html index da17df8..e0fa733 100644 --- a/desktop/views/schedule.html +++ b/desktop/views/schedule.html @@ -13,6 +13,7 @@ + @@ -89,6 +90,7 @@ + diff --git a/docker/package-lock.json b/docker/package-lock.json index 93995d1..99bcc70 100644 --- a/docker/package-lock.json +++ b/docker/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@fastify/static": "^8.3.0", + "@fastify/websocket": "^11.2.0", "adm-zip": "^0.5.16", "bcrypt": "^6.0.0", "bindings": "^1.5.0", @@ -22,13 +23,15 @@ "node-addon-api": "^8.5.0", "node-cron": "^4.2.1", "playwright-chromium": "^1.57.0", - "sqlite3": "^5.1.7" + "sqlite3": "^5.1.7", + "ws": "^8.18.3" }, "devDependencies": { "@types/adm-zip": "^0.5.7", "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", + "@types/ws": "^8.18.1", "node-gyp": "^12.1.0", "ts-node": "^10.9.0", "typescript": "^5.3.0" @@ -221,6 +224,27 @@ "glob": "^11.0.0" } }, + "node_modules/@fastify/websocket": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz", + "integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.3", + "fastify-plugin": "^5.0.0", + "ws": "^8.16.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -453,6 +477,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", @@ -1161,6 +1195,18 @@ "url": "https://dotenvx.com" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4027,6 +4073,12 @@ "node": ">= 0.8" } }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4558,6 +4610,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml2js": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", diff --git a/docker/package.json b/docker/package.json index 6375879..a58d92c 100644 --- a/docker/package.json +++ b/docker/package.json @@ -13,6 +13,7 @@ "type": "commonjs", "dependencies": { "@fastify/static": "^8.3.0", + "@fastify/websocket": "^11.2.0", "adm-zip": "^0.5.16", "bcrypt": "^6.0.0", "bindings": "^1.5.0", @@ -25,13 +26,15 @@ "node-addon-api": "^8.5.0", "node-cron": "^4.2.1", "playwright-chromium": "^1.57.0", - "sqlite3": "^5.1.7" + "sqlite3": "^5.1.7", + "ws": "^8.18.3" }, "devDependencies": { "@types/adm-zip": "^0.5.7", "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", + "@types/ws": "^8.18.1", "node-gyp": "^12.1.0", "ts-node": "^10.9.0", "typescript": "^5.3.0" diff --git a/docker/server.js b/docker/server.js index 2e17892..e1be721 100644 --- a/docker/server.js +++ b/docker/server.js @@ -26,6 +26,8 @@ const listRoutes = require("./dist/api/list/list.routes"); const anilistRoute = require("./dist/api/anilist/anilist"); const localRoutes = require("./dist/api/local/local.routes"); const configRoutes = require("./dist/api/config/config.routes"); +const roomRoutes = require("./dist/api/rooms/rooms.routes"); +const { setupRoomWebSocket } = require("./dist/api/rooms/rooms.websocket"); fastify.addHook("preHandler", async (request) => { const auth = request.headers.authorization; @@ -68,17 +70,20 @@ fastify.register(anilistRoute, { prefix: "/api" }); fastify.register(listRoutes, { prefix: "/api" }); fastify.register(localRoutes, { prefix: "/api" }); fastify.register(configRoutes, { prefix: "/api" }); +fastify.register(roomRoutes, { prefix: "/api" }); const sleep = ms => new Promise(r => setTimeout(r, ms)); const start = async () => { try { + await fastify.register(require('@fastify/websocket')); ensureConfigFile() initDatabase("anilist"); initDatabase("favorites"); initDatabase("cache"); initDatabase("userdata"); initDatabase("local_library"); + setupRoomWebSocket(fastify); const refreshAll = async () => { await refreshTrendingAnime(); diff --git a/docker/src/api/rooms/rooms.controller.ts b/docker/src/api/rooms/rooms.controller.ts new file mode 100644 index 0000000..6f599c3 --- /dev/null +++ b/docker/src/api/rooms/rooms.controller.ts @@ -0,0 +1,141 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import * as roomService from './rooms.service'; +import { getUserById } from '../user/user.service'; + +interface CreateRoomBody { + name: string; + password?: string; +} + +interface JoinRoomBody { + password?: string; + guestName?: string; +} + +export async function createRoom(req: any, reply: FastifyReply) { + try { + const { name, password } = req.body as CreateRoomBody; + const userId = req.user?.id; + + if (!userId) { + return reply.code(401).send({ error: "Authentication required to create room" }); + } + + if (!name || name.trim().length === 0) { + return reply.code(400).send({ error: "Room name is required" }); + } + + const user = await getUserById(userId); + if (!user) { + return reply.code(404).send({ error: "User not found" }); + } + + const host = { + id: `user_${userId}`, + username: user.username, + avatar: user.profile_picture_url || undefined, + isHost: true, + isGuest: false, + userId + }; + + const room = roomService.createRoom(name, host, password); + + return reply.send({ + success: true, + room: { + id: room.id, + name: room.name, + hasPassword: !!room.password, + userCount: room.users.size + } + }); + } catch (err) { + console.error("Create Room Error:", err); + return reply.code(500).send({ error: "Failed to create room" }); + } +} + +export async function getRooms(req: FastifyRequest, reply: FastifyReply) { + try { + const rooms = roomService.getAllRooms(); + + const roomList = rooms.map((room) => ({ + id: room.id, + name: room.name, + host: room.host.username, + userCount: room.users.size, + hasPassword: !!room.password, + currentlyWatching: room.currentVideo ? { + animeId: room.currentVideo.animeId, + episode: room.currentVideo.episode + } : null + })); + + return reply.send({ rooms: roomList }); + } catch (err) { + console.error("Get Rooms Error:", err); + return reply.code(500).send({ error: "Failed to retrieve rooms" }); + } +} + +export async function getRoom(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as { id: string }; + const room = roomService.getRoom(id); + + if (!room) { + return reply.code(404).send({ error: "Room not found" }); + } + + return reply.send({ + room: { + id: room.id, + name: room.name, + host: { + username: room.host.username, + avatar: room.host.avatar + }, + users: Array.from(room.users.values()).map(u => ({ + id: u.id, + username: u.username, + avatar: u.avatar, + isHost: u.isHost, + isGuest: u.isGuest + })), + hasPassword: !!room.password, + currentVideo: room.currentVideo + } + }); + } catch (err) { + console.error("Get Room Error:", err); + return reply.code(500).send({ error: "Failed to retrieve room" }); + } +} + +export async function deleteRoom(req: any, reply: FastifyReply) { + try { + const { id } = req.params as { id: string }; + const userId = req.user?.id; + + if (!userId) { + return reply.code(401).send({ error: "Authentication required" }); + } + + const room = roomService.getRoom(id); + if (!room) { + return reply.code(404).send({ error: "Room not found" }); + } + + if (room.host.userId !== userId) { + return reply.code(403).send({ error: "Only the host can delete the room" }); + } + + roomService.deleteRoom(id); + + return reply.send({ success: true }); + } catch (err) { + console.error("Delete Room Error:", err); + return reply.code(500).send({ error: "Failed to delete room" }); + } +} \ No newline at end of file diff --git a/docker/src/api/rooms/rooms.routes.ts b/docker/src/api/rooms/rooms.routes.ts new file mode 100644 index 0000000..0df76e8 --- /dev/null +++ b/docker/src/api/rooms/rooms.routes.ts @@ -0,0 +1,11 @@ +import { FastifyInstance } from 'fastify'; +import * as controller from './rooms.controller'; + +async function roomRoutes(fastify: FastifyInstance) { + fastify.post('/rooms', controller.createRoom); + fastify.get('/rooms', controller.getRooms); + fastify.get('/rooms/:id', controller.getRoom); + fastify.delete('/rooms/:id', controller.deleteRoom); +} + +export default roomRoutes; \ No newline at end of file diff --git a/docker/src/api/rooms/rooms.service.ts b/docker/src/api/rooms/rooms.service.ts new file mode 100644 index 0000000..89b374c --- /dev/null +++ b/docker/src/api/rooms/rooms.service.ts @@ -0,0 +1,130 @@ +import crypto from 'crypto'; + +interface RoomUser { + id: string; + username: string; + avatar?: string; + isHost: boolean; + isGuest: boolean; + userId?: number; // ID real del usuario si está logueado +} + +interface RoomMetadata { + id: string; + title: string; + episode: number; + image?: string; + source?: string; +} + +interface RoomData { + id: string; + name: string; + host: RoomUser; + users: Map; + createdAt: number; + currentVideo: { + animeId?: number; + episode?: number; + source?: string; + videoData?: any; + currentTime: number; + isPlaying: boolean; + } | null; + password?: string; + metadata?: RoomMetadata | null; +} + +const rooms = new Map(); + +export function generateRoomId(): string { + return crypto.randomBytes(8).toString('hex'); +} + +export function createRoom(name: string, host: RoomUser, password?: string): RoomData { + const roomId = generateRoomId(); + + const room: RoomData = { + id: roomId, + name, + host, + users: new Map([[host.id, host]]), + createdAt: Date.now(), + currentVideo: null, + password: password || undefined, + metadata: null, + }; + + rooms.set(roomId, room); + return room; +} + +export function getRoom(roomId: string): RoomData | null { + return rooms.get(roomId) || null; +} + +export function getAllRooms(): RoomData[] { + return Array.from(rooms.values()).map(room => ({ + ...room, + users: room.users + })); +} + +export function addUserToRoom(roomId: string, user: RoomUser): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + room.users.set(user.id, user); + return true; +} + +export function removeUserFromRoom(roomId: string, userId: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + room.users.delete(userId); + + // Si no quedan usuarios, eliminar la sala + if (room.users.size === 0) { + rooms.delete(roomId); + return true; + } + + // Si era el host, asignar nuevo host + if (room.host.id === userId && room.users.size > 0) { + const newHost = Array.from(room.users.values())[0]; + newHost.isHost = true; + room.host = newHost; + } + + return true; +} + +export function updateRoomVideo(roomId: string, videoData: any): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + room.currentVideo = videoData; + return true; +} + +export function deleteRoom(roomId: string): boolean { + return rooms.delete(roomId); +} + +export function verifyRoomPassword(roomId: string, password?: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + if (!room.password) return true; + if (!password) return false; + + return room.password === password; +} + +export function updateRoomMetadata(roomId: string, metadata: any): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + room.metadata = metadata; + return true; +} \ No newline at end of file diff --git a/docker/src/api/rooms/rooms.websocket.ts b/docker/src/api/rooms/rooms.websocket.ts new file mode 100644 index 0000000..bcd863e --- /dev/null +++ b/docker/src/api/rooms/rooms.websocket.ts @@ -0,0 +1,397 @@ +import { FastifyInstance, FastifyRequest } from 'fastify'; +import * as roomService from './rooms.service'; +import { getUserById } from '../user/user.service'; +import jwt from 'jsonwebtoken'; + +interface WSClient { + socket: any; + userId: string; + username: string; + roomId: string; + isGuest: boolean; +} + +const clients = new Map(); + +interface WSParams { + roomId: string; +} + +interface WSQuery { + token?: string; + guestName?: string; + password?: string; +} + +export function setupRoomWebSocket(fastify: FastifyInstance) { + // @ts-ignore + fastify.get('/ws/room/:roomId', { websocket: true }, (connection: any, req: any) => { + handleWebSocketConnection(connection, req).catch(err => { + console.error('WebSocket error:', err); + try { + connection.socket.close(); + } catch (e) { + // Socket already closed + } + }); + }); +} + +async function handleWebSocketConnection(connection: any, req: any) { + const socket = connection.socket || connection; + const roomId = req.params.roomId; + const token = req.query.token; + const guestName = req.query.guestName; + const password = req.query.password; + + let userId: string; + let username: string; + let avatar: string | undefined; + let isGuest = false; + let realUserId: any; + + // Verificar si la sala existe + const room = roomService.getRoom(roomId); + if (!room) { + socket.send(JSON.stringify({ + type: 'error', + message: 'Room not found' + })); + socket.close(); + return; + } + + // Verificar contraseña si existe + if (room.password) { + if (!password || !roomService.verifyRoomPassword(roomId, password)) { + socket.send(JSON.stringify({ + type: 'error', + message: 'Invalid password' + })); + socket.close(); + return; + } + } + + // Autenticar usuario o crear invitado + if (token) { + try { + const decoded: any = jwt.verify(token, process.env.JWT_SECRET!); + realUserId = decoded.id; + const user = await getUserById(realUserId); + + if (user) { + userId = `user_${realUserId}`; + username = user.username; + avatar = user.profile_picture_url || undefined; + isGuest = false; + } else { + throw new Error('User not found'); + } + } catch (err) { + socket.send(JSON.stringify({ + type: 'error', + message: 'Invalid token' + })); + socket.close(); + return; + } + } else if (guestName && guestName.trim()) { + const nameToCheck = guestName.trim(); + + const isNameTaken = Array.from(room.users.values()).some( + u => u.username.toLowerCase() === nameToCheck.toLowerCase() + ); + + if (isNameTaken) { + socket.send(JSON.stringify({ + type: 'error', + message: 'Username is already taken' + })); + socket.close(); + return; + } + + userId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + username = nameToCheck; + isGuest = true; + } else { + socket.send(JSON.stringify({ + type: 'error', + message: 'Authentication required' + })); + socket.close(); + return; + } + + const isHost = room.host.userId === realUserId || room.host.id === userId; + + console.log('WebSocket Connection:', { + userId, + realUserId, + roomHostId: room.host.id, + roomHostUserId: room.host.userId, + isHost + }); + + // Agregar usuario a la sala + const userInRoom = { + id: userId, + username, + avatar, + isHost: isHost, // ← CORREGIDO: Usar la verificación correcta + isGuest, + userId: realUserId + }; + + roomService.addUserToRoom(roomId, userInRoom); + + // Registrar cliente + clients.set(userId, { + socket: socket, + userId, + username, + roomId, + isGuest + }); + + // Enviar estado inicial + socket.send(JSON.stringify({ + type: 'init', + userId, + username, + isGuest, + room: { + id: room.id, + name: room.name, + users: Array.from(room.users.values()).map(u => ({ + id: u.id, + username: u.username, + avatar: u.avatar, + isHost: u.isHost, + isGuest: u.isGuest + })), + currentVideo: room.currentVideo + } + })); + + // Notificar a otros usuarios + broadcastToRoom(roomId, { + type: 'user_joined', + user: { + id: userId, + username, + avatar, + isGuest + } + }, userId); + + // Manejar mensajes + socket.on('message', (message: Buffer) => { + try { + const data = JSON.parse(message.toString()); + handleMessage(roomId, userId, data); + } catch (err) { + console.error('WebSocket message error:', err); + } + }); + + // Manejar desconexión + socket.on('close', () => { + clients.delete(userId); + roomService.removeUserFromRoom(roomId, userId); + + broadcastToRoom(roomId, { + type: 'user_left', + user: { userId, username } + }); + }); +} + +function handleMessage(roomId: string, userId: string, data: any) { + const room = roomService.getRoom(roomId); + if (!room) return; + + console.log('Handling message:', data.type, 'from user:', userId, 'isHost:', room.host.id === userId); + + switch (data.type) { + case 'chat': + broadcastToRoom(roomId, { + type: 'chat', + userId, + username: room.users.get(userId)?.username || 'Unknown', + avatar: room.users.get(userId)?.avatar, + message: data.message, + timestamp: Date.now() + }); + break; + + case 'video_update': + if (room.host.id !== userId) return; + + roomService.updateRoomVideo(roomId, data.video); + roomService.updateRoomMetadata(roomId, data.metadata); + + broadcastToRoom(roomId, { + type: 'video_update', + video: data.video, + metadata: data.metadata // ✅ CLAVE + }); + + break; + + + case 'sync': + // Solo el host puede hacer sync + if (room.host.id !== userId) return; + + if (room.currentVideo) { + room.currentVideo.currentTime = data.currentTime; + room.currentVideo.isPlaying = data.isPlaying; + } + + broadcastToRoom(roomId, { + type: 'sync', + currentTime: data.currentTime, + isPlaying: data.isPlaying + }, userId); + break; + + case 'request_users': + const currentRoom = roomService.getRoom(roomId); + if (currentRoom) { + const client = clients.get(userId); + if (client && client.socket && client.socket.readyState === 1) { + client.socket.send(JSON.stringify({ + type: 'users_update', // Nuevo tipo de respuesta + users: Array.from(currentRoom.users.values()).map(u => ({ + id: u.id, + username: u.username, + avatar: u.avatar, + isHost: u.isHost, + isGuest: u.isGuest + })) + })); + } + } + break; + + case 'play': + // Solo el host puede controlar la reproducción + if (room.host.id !== userId) { + console.warn('Non-host attempted play:', userId); + return; + } + + console.log('Broadcasting play event to room:', roomId); + broadcastToRoom(roomId, { + type: 'play', + currentTime: data.currentTime, + timestamp: Date.now() + }, userId); // IMPORTANTE: excludeUserId para no enviar al host + break; + + case 'pause': + // Solo el host puede controlar la reproducción + if (room.host.id !== userId) { + console.warn('Non-host attempted pause:', userId); + return; + } + + console.log('Broadcasting pause event to room:', roomId); + broadcastToRoom(roomId, { + type: 'pause', + currentTime: data.currentTime, + timestamp: Date.now() + }, userId); // IMPORTANTE: excludeUserId para no enviar al host + break; + + case 'seek': + // Solo el host puede controlar la reproducción + if (room.host.id !== userId) { + console.warn('Non-host attempted seek:', userId); + return; + } + + console.log('Broadcasting seek event to room:', roomId); + broadcastToRoom(roomId, { + type: 'seek', + currentTime: data.currentTime, + timestamp: Date.now() + }, userId); // IMPORTANTE: excludeUserId para no enviar al host + break; + + case 'request_sync': + // Cualquier usuario puede pedir sync + const host = clients.get(room.host.id); + if (host && host.socket && host.socket.readyState === 1) { + host.socket.send(JSON.stringify({ + type: 'sync_requested', + requestedBy: userId + })); + } + break; + + case 'request_state': + // Enviar estado actual de la sala al usuario que lo solicita + const client = clients.get(userId); + if (client && client.socket && client.socket.readyState === 1) { + const updatedRoom = roomService.getRoom(roomId); + if (updatedRoom) { + client.socket.send(JSON.stringify({ + type: 'init', + userId, + username: client.username, + isGuest: client.isGuest, + room: { + id: updatedRoom.id, + name: updatedRoom.name, + users: Array.from(updatedRoom.users.values()).map(u => ({ + id: u.id, + username: u.username, + avatar: u.avatar, + isHost: u.isHost, + isGuest: u.isGuest + })), + currentVideo: room.currentVideo, + metadata: room.metadata + } + })); + } + } + break; + + default: + console.warn('Unknown message type:', data.type); + break; + } +} + +function broadcastToRoom(roomId: string, message: any, excludeUserId?: string) { + const room = roomService.getRoom(roomId); + if (!room) return; + + const messageStr = JSON.stringify(message); + + console.log('Broadcasting to room:', roomId, 'message type:', message.type, 'excluding:', excludeUserId); + + let sentCount = 0; + room.users.forEach((user) => { + if (user.id !== excludeUserId) { + const client = clients.get(user.id); + if (client && client.socket && client.socket.readyState === 1) { + try { + client.socket.send(messageStr); + sentCount++; + console.log('Sent to user:', user.id, user.username); + } catch (err) { + console.error('Error sending message to user:', user.id, err); + } + } else { + console.warn('User socket not ready:', user.id, 'readyState:', client?.socket?.readyState); + } + } + }); + + console.log(`Broadcast complete: sent to ${sentCount} users`); +} \ No newline at end of file diff --git a/docker/src/scripts/anime/player.js b/docker/src/scripts/anime/player.js index bd0fd0b..c672fb4 100644 --- a/docker/src/scripts/anime/player.js +++ b/docker/src/scripts/anime/player.js @@ -16,6 +16,9 @@ const AnimePlayer = (function() { let _totalEpisodes = 0; let _manualExtensionId = null; let _activeSubtitleIndex = -1; + let _roomMode = false; + let _isRoomHost = false; + let _roomWebSocket = null; let hlsInstance = null; let subtitleRenderer = null; @@ -61,23 +64,30 @@ const AnimePlayer = (function() { subtitlesCanvas: null }; - function init(animeId, initialSource, isLocal, animeData) { + function init(animeId, initialSource, isLocal, animeData, roomMode = false) { + _roomMode = roomMode; _animeId = animeId; _entrySource = initialSource || 'anilist'; _isLocal = isLocal; - _malId = animeData.idMal || null; _totalEpisodes = animeData.episodes || 1000; if (animeData.title) { - _animeTitle = animeData.title.romaji || animeData.title.english || animeData.title.native || animeData.title || "Anime"; + _animeTitle = animeData.title.romaji || animeData.title.english || "Anime"; } - _skipIntervals = []; - _localEntryId = null; - initElements(); setupEventListeners(); - loadExtensionsList(); + + // In Room Mode, we show the player immediately and hide extra controls + if (_roomMode) { + if(els.playerWrapper) { + els.playerWrapper.style.display = 'block'; + els.playerWrapper.classList.add('room-mode'); + } + // Hide extension list loading in room mode + } else { + loadExtensionsList(); + } } function initElements() { @@ -134,8 +144,10 @@ const AnimePlayer = (function() { function setupEventListeners() { // Close player - const closeBtn = document.getElementById('close-player-btn'); - if(closeBtn) closeBtn.addEventListener('click', closePlayer); + if(!_roomMode) { + const closeBtn = document.getElementById('close-player-btn'); + if(closeBtn) closeBtn.addEventListener('click', closePlayer); + } // Episode navigation if(els.prevBtn) els.prevBtn.onclick = () => playEpisode(_currentEpisode - 1); @@ -183,18 +195,35 @@ const AnimePlayer = (function() { setupKeyboardShortcuts(); } - function setupCustomControls() { - // Play/Pause - if(els.playPauseBtn) { - els.playPauseBtn.onclick = togglePlayPause; - } - if(els.video) { - // Remove old listeners to be safe (though usually new element) - els.video.onclick = togglePlayPause; - els.video.ondblclick = toggleFullscreen; + function loadVideoFromRoom(videoData) { + console.log('AnimePlayer.loadVideoFromRoom called with:', videoData); + + if (!videoData || !videoData.url) { + console.error('Invalid video data provided to loadVideoFromRoom'); + return; } - // Volume + _currentSubtitles = videoData.subtitles || []; + + if (els.loader) els.loader.style.display = 'none'; + + initVideoPlayer(videoData.url, videoData.type || 'm3u8', videoData.subtitles || []); + } + + function setupCustomControls() { + // ELIMINADO: if (_roomMode && !_isRoomHost) return; + // Ahora permitimos que el código fluya para habilitar volumen y ajustes a todos + + // 1. Play/Pause (SOLO HOST) + if(els.playPauseBtn) { + els.playPauseBtn.onclick = togglePlayPause; // La validación de permiso se hará dentro de togglePlayPause + } + if(els.video) { + els.video.onclick = togglePlayPause; // Click en video para pausar + els.video.ondblclick = toggleFullscreen; // Doble click siempre permitido + } + + // 2. Volume (TODOS) if(els.volumeBtn) { els.volumeBtn.onclick = toggleMute; } @@ -204,7 +233,7 @@ const AnimePlayer = (function() { }; } - // Settings + // 3. Settings (TODOS - Aquí están los subtítulos y audio) if(els.settingsBtn) { els.settingsBtn.onclick = (e) => { e.stopPropagation(); @@ -219,7 +248,7 @@ const AnimePlayer = (function() { }; } - // Close settings when clicking outside + // Close settings when clicking outside (TODOS) document.onclick = (e) => { if (settingsPanelActive && els.settingsPanel && !els.settingsPanel.contains(e.target) && @@ -229,19 +258,19 @@ const AnimePlayer = (function() { } }; - // Fullscreen + // 4. Fullscreen (TODOS) if(els.fullscreenBtn) { els.fullscreenBtn.onclick = toggleFullscreen; } - // Progress bar + // 5. Progress bar (SOLO HOST para buscar, TODOS para ver) if(els.progressContainer) { + // El listener se añade, pero seekToPosition bloqueará a los invitados els.progressContainer.onclick = seekToPosition; } - // Video events + // 6. Video events (TODOS - Necesarios para actualizar la UI localmente) if(els.video) { - // Remove previous listeners first if sticking to same element, but we replace element usually els.video.onplay = onPlay; els.video.onpause = onPause; els.video.ontimeupdate = onTimeUpdate; @@ -283,9 +312,27 @@ const AnimePlayer = (function() { document.addEventListener('keydown', (e) => { if (!els.playerWrapper || els.playerWrapper.style.display === 'none') return; - // Ignore if typing in input if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + // En room mode, solo el host puede usar shortcuts de control + if (_roomMode && !_isRoomHost) { + // Permitir fullscreen y volumen para todos + if (e.key.toLowerCase() === 'f') { + e.preventDefault(); + toggleFullscreen(); + } else if (e.key.toLowerCase() === 'm') { + e.preventDefault(); + toggleMute(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + adjustVolume(0.1); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + adjustVolume(-0.1); + } + return; + } + switch(e.key.toLowerCase()) { case ' ': case 'k': @@ -352,11 +399,23 @@ const AnimePlayer = (function() { // Control functions function togglePlayPause() { + if (_roomMode && !_isRoomHost) { + console.log('Guests cannot control playback'); + return; + } + if (!els.video) return; + if (els.video.paused) { els.video.play().catch(() => {}); + if (_roomMode && _isRoomHost) { + sendRoomEvent('play', { currentTime: els.video.currentTime }); + } } else { els.video.pause(); + if (_roomMode && _isRoomHost) { + sendRoomEvent('pause', { currentTime: els.video.currentTime }); + } } } @@ -398,9 +457,18 @@ const AnimePlayer = (function() { function seekToPosition(e) { if (!els.video || !els.progressContainer) return; + if (_roomMode && !_isRoomHost) return; + const rect = els.progressContainer.getBoundingClientRect(); const pos = (e.clientX - rect.left) / rect.width; - els.video.currentTime = pos * els.video.duration; + const newTime = pos * els.video.duration; + + els.video.currentTime = newTime; + + // En room mode, enviar evento de seek + if (_roomMode && _isRoomHost) { + sendRoomEvent('seek', { currentTime: newTime }); + } } function updateProgressHandle(e) { @@ -410,14 +478,30 @@ const AnimePlayer = (function() { els.progressHandle.style.left = `${pos * 100}%`; } + function seekRelative(seconds) { if (!els.video) return; - els.video.currentTime = Math.max(0, Math.min(els.video.duration, els.video.currentTime + seconds)); - } + if (_roomMode && !_isRoomHost) return; + const newTime = Math.max(0, Math.min(els.video.duration, els.video.currentTime + seconds)); + els.video.currentTime = newTime; + + // En room mode, enviar evento de seek + if (_roomMode && _isRoomHost) { + sendRoomEvent('seek', { currentTime: newTime }); + } + } function seekToPercent(percent) { if (!els.video) return; - els.video.currentTime = els.video.duration * percent; + if (_roomMode && !_isRoomHost) return; + + const newTime = els.video.duration * percent; + els.video.currentTime = newTime; + + // En room mode, enviar evento de seek + if (_roomMode && _isRoomHost) { + sendRoomEvent('seek', { currentTime: newTime }); + } } // Video event handlers @@ -476,7 +560,7 @@ const AnimePlayer = (function() { } function onEnded() { - if (_currentEpisode < _totalEpisodes) { + if (!_roomMode && _currentEpisode < _totalEpisodes) { playEpisode(_currentEpisode + 1); } } @@ -501,6 +585,22 @@ const AnimePlayer = (function() { els.volumeBtn.innerHTML = icon; } + function sendRoomEvent(eventType, data = {}) { + if (!_roomMode || !_isRoomHost || !_roomWebSocket) return; + if (_roomWebSocket.readyState !== WebSocket.OPEN) return; + + console.log('Sending room event:', eventType, data); + _roomWebSocket.send(JSON.stringify({ + type: eventType, + ...data + })); + } + + function setWebSocket(ws) { + console.log('Setting WebSocket reference in AnimePlayer'); + _roomWebSocket = ws; + } + function formatTime(seconds) { if (!isFinite(seconds) || isNaN(seconds)) return '0:00'; const h = Math.floor(seconds / 3600); @@ -563,7 +663,7 @@ const AnimePlayer = (function() { } // 4. Playback Speed - if (els.video) { + if (els.video && (!_roomMode || _isRoomHost)) { const label = els.video.playbackRate === 1 ? 'Normal' : `${els.video.playbackRate}x`; html += createMenuItem('speed', 'Playback Speed', label, Icons.speed); } @@ -812,7 +912,7 @@ const AnimePlayer = (function() { } // Update progress for AniList - if (!_progressUpdated && els.video.duration) { + if (!_roomMode && !_progressUpdated && els.video.duration) { const percentage = els.video.currentTime / els.video.duration; if (percentage >= 0.8) { updateProgress(); @@ -1183,8 +1283,9 @@ const AnimePlayer = (function() { } function initVideoPlayer(url, type, subtitles = []) { - // 1. CLEANUP FIRST: Destroy subtitle renderer while elements still exist - // This prevents "removeChild" errors because the DOM is still intact + console.log('initVideoPlayer called:', { url, type, subtitles }); + + // 1. CLEANUP FIRST if (subtitleRenderer) { try { subtitleRenderer.dispose(); @@ -1194,16 +1295,18 @@ const AnimePlayer = (function() { subtitleRenderer = null; } - // 2. Destroy HLS instance if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } const container = document.querySelector('.video-frame'); - if (!container) return; + if (!container) { + console.error('Video frame container not found!'); + return; + } - // 3. Remove OLD Elements + // 2. Remove OLD Elements const oldVideo = container.querySelector('video'); const oldCanvas = container.querySelector('#subtitles-canvas'); @@ -1216,65 +1319,65 @@ const AnimePlayer = (function() { oldCanvas.remove(); } - // 4. Create NEW Elements + // 3. Create NEW Elements - CANVAS FIRST, then VIDEO + const newCanvas = document.createElement('canvas'); + newCanvas.id = 'subtitles-canvas'; + const newVideo = document.createElement('video'); newVideo.id = 'player'; newVideo.crossOrigin = 'anonymous'; newVideo.playsInline = true; - const newCanvas = document.createElement('canvas'); - newCanvas.id = 'subtitles-canvas'; - container.appendChild(newCanvas) + container.appendChild(newCanvas); container.appendChild(newVideo); els.video = newVideo; els.subtitlesCanvas = newCanvas; + console.log('Video and canvas elements created:', { video: els.video, canvas: els.subtitlesCanvas }); + + // Re-setup controls with new video element setupCustomControls(); - // 5. Initialize Player (HLS or Native) + // Hide loader + if (els.loader) els.loader.style.display = 'none'; + + // 4. Initialize Player if (Hls.isSupported() && type === 'm3u8') { + console.log('Initializing HLS player'); + hlsInstance = new Hls({ enableWorker: true, lowLatencyMode: false, - backBufferLength: 90 + backBufferLength: 90, + debug: false + }); + + hlsInstance.on(Hls.Events.ERROR, (event, data) => { + console.error('HLS Error:', data); + if (data.fatal) { + if (els.loader) { + els.loader.style.display = 'flex'; + if (els.loaderText) els.loaderText.textContent = 'Stream error: ' + (data.details || 'Unknown'); + } + } }); hlsInstance.attachMedia(els.video); hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => { + console.log('HLS media attached, loading source:', url); hlsInstance.loadSource(url); }); hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { + console.log('HLS manifest parsed, attaching subtitles'); attachSubtitles(subtitles); buildSettingsPanel(); - if (els.downloadBtn) els.downloadBtn.style.display = 'flex'; - els.video.play().catch(() => {}); - if (els.loader) els.loader.style.display = 'none'; - }); - hlsInstance.on(Hls.Events.LEVEL_SWITCHED, () => buildSettingsPanel()); - hlsInstance.on(Hls.Events.AUDIO_TRACK_SWITCHED, () => buildSettingsPanel()); + if (els.downloadBtn && !_roomMode) els.downloadBtn.style.display = 'flex'; - } else { - els.video.src = url; - attachSubtitles(subtitles); - buildSettingsPanel(); - els.video.play().catch(() => {}); - if(els.loader) els.loader.style.display = 'none'; - if (els.downloadBtn) els.downloadBtn.style.display = 'flex'; - } - - // 6. Init Subtitles with explicit delay - // We use setTimeout instead of requestAnimationFrame to let the Layout Engine catch up - if (type === 'm3u8') { - hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { - attachSubtitles(subtitles); - buildSettingsPanel(); - if (els.downloadBtn) els.downloadBtn.style.display = 'flex'; - - // IMPORTANTE: Esperar a loadedmetadata antes de init subtitles + // --- FIX: Inicializar el renderizador de subtítulos para HLS --- if (els.video.readyState >= 1) { initSubtitleRenderer(); } else { @@ -1282,23 +1385,34 @@ const AnimePlayer = (function() { initSubtitleRenderer(); }, { once: true }); } + // ------------------------------------------------------------- - els.video.play().catch(() => {}); - if (els.loader) els.loader.style.display = 'none'; + console.log('Attempting to play video'); + els.video.play().catch(err => { + console.error('Play error:', err); + }); }); + + hlsInstance.on(Hls.Events.LEVEL_SWITCHED, () => buildSettingsPanel()); + hlsInstance.on(Hls.Events.AUDIO_TRACK_SWITCHED, () => buildSettingsPanel()); + } else { + console.log('Using native video player'); els.video.src = url; attachSubtitles(subtitles); buildSettingsPanel(); - // Para video directo, esperar metadata els.video.addEventListener('loadedmetadata', () => { + console.log('Video metadata loaded'); initSubtitleRenderer(); }, { once: true }); - els.video.play().catch(() => {}); - if(els.loader) els.loader.style.display = 'none'; - if (els.downloadBtn) els.downloadBtn.style.display = 'flex'; + console.log('Attempting to play video'); + els.video.play().catch(err => { + console.error('Play error:', err); + }); + + if (els.downloadBtn && !_roomMode) els.downloadBtn.style.display = 'flex'; } } @@ -1762,12 +1876,19 @@ const AnimePlayer = (function() { // RPC function sendRPC({ startTimestamp, endTimestamp, paused = false } = {}) { + let stateText = `Episode ${_currentEpisode}`; + let detailsText = _animeTitle; + + if (_roomMode) { + stateText = `Watch Party - Ep ${_currentEpisode}`; + } + fetch("/api/rpc", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - details: _animeTitle, - state: `Episode ${_currentEpisode}`, + details: detailsText, + state: stateText, mode: "watching", startTimestamp, endTimestamp, @@ -1800,9 +1921,29 @@ const AnimePlayer = (function() { } } + function setRoomHost(isHost) { + console.log('Setting player host status:', isHost); + _isRoomHost = isHost; + + // Re-ejecutar la configuración de controles con el nuevo permiso + setupCustomControls(); + + // Forzar actualización visual si es necesario + if (els.playerWrapper) { + if (isHost) els.playerWrapper.classList.add('is-host'); + else els.playerWrapper.classList.remove('is-host'); + } + } + return { init, playEpisode, - getCurrentEpisode: () => _currentEpisode + getCurrentEpisode: () => _currentEpisode, + loadVideoFromRoom, + getVideoElement: () => els.video, + setRoomHost, + setWebSocket }; -})(); \ No newline at end of file +})(); + +window.AnimePlayer = AnimePlayer; \ No newline at end of file diff --git a/docker/src/scripts/auth-guard.js b/docker/src/scripts/auth-guard.js index e0cfd85..965f91e 100644 --- a/docker/src/scripts/auth-guard.js +++ b/docker/src/scripts/auth-guard.js @@ -115,4 +115,16 @@ function setupDropdown() { }) } -loadMeUI() \ No newline at end of file +loadMeUI() +const createRoomModal = new CreateRoomModal(); + +const createBtn = document.getElementById('nav-create-party'); +if (createBtn) { + createBtn.addEventListener('click', (e) => { + e.preventDefault(); + const dropdown = document.getElementById('nav-dropdown'); + if(dropdown) dropdown.classList.remove('active'); + + createRoomModal.open(); + }); +} \ No newline at end of file diff --git a/docker/src/scripts/room-modal.js b/docker/src/scripts/room-modal.js new file mode 100644 index 0000000..2a03513 --- /dev/null +++ b/docker/src/scripts/room-modal.js @@ -0,0 +1,128 @@ +class CreateRoomModal { + constructor() { + this.modalId = 'cr-modal-overlay'; + this.isRendered = false; + this.render(); // Crear el HTML en el DOM al instanciar + } + + render() { + if (document.getElementById(this.modalId)) return; + + const modalHtml = ` +
+
+ +

Create Watch Party

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ `; + + document.body.insertAdjacentHTML('beforeend', modalHtml); + this.bindEvents(); + this.isRendered = true; + } + + bindEvents() { + const modal = document.getElementById(this.modalId); + const closeBtn = document.getElementById('cr-close'); + const cancelBtn = document.getElementById('cr-cancel'); + const form = document.getElementById('cr-form'); + + const close = () => this.close(); + + closeBtn.onclick = close; + cancelBtn.onclick = close; + + // Cerrar si clicamos fuera del contenido + modal.onclick = (e) => { + if (e.target === modal) close(); + }; + + form.onsubmit = (e) => this.handleSubmit(e); + } + + open() { + const token = localStorage.getItem('token'); + if (!token) { + // Aquí puedes disparar tu modal de login o redirigir + alert('You must be logged in to create a room'); + window.location.href = '/login'; // Opcional + return; + } + + const modal = document.getElementById(this.modalId); + modal.classList.add('show'); + document.querySelector('#cr-form input[name="name"]').focus(); + } + + close() { + const modal = document.getElementById(this.modalId); + modal.classList.remove('show'); + document.getElementById('cr-form').reset(); + } + + async handleSubmit(e) { + e.preventDefault(); + const btn = e.target.querySelector('button[type="submit"]'); + const originalText = btn.textContent; + + btn.disabled = true; + btn.textContent = 'Creating...'; + + const formData = new FormData(e.target); + const name = formData.get('name').trim(); + const password = formData.get('password').trim(); + const token = localStorage.getItem('token'); + + try { + const res = await fetch('/api/rooms', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + name, + password: password || undefined + }) + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to create room'); + + this.close(); + + // REDIRECCIÓN: + // Si estamos en la página de rooms, recargamos o dejamos que el socket actualice. + // Si estamos en otra página, vamos a la sala creada. + // Asumo que tu ruta de sala es /room (o query params). + // Ajusta esta línea según tu router: + window.location.href = `/room?id=${data.room.id}`; + + } catch (err) { + alert(err.message); + } finally { + btn.disabled = false; + btn.textContent = originalText; + } + } +} + +window.CreateRoomModal = CreateRoomModal; diff --git a/docker/src/scripts/room.js b/docker/src/scripts/room.js new file mode 100644 index 0000000..8397884 --- /dev/null +++ b/docker/src/scripts/room.js @@ -0,0 +1,1196 @@ +const RoomsApp = (function() { + let ws = null; + let currentRoomId = null; + let currentUserId = null; + let currentUsername = null; + let isHost = false; + let isGuest = false; + let hlsInstance = null; + let selectedAnimeData = null; + let currentLoadedUrl = null; + let extensionsReady = false; + + let configState = { + extension: null, + server: null, + category: 'sub', // 'sub' o 'dub' + episode: 1 + }; + + let extensionsStore = { + list: [], + settings: {} // { anilist: {...}, gogo: {...} } + }; + + const elements = { + // Main view & Header + roomView: document.getElementById('room-view'), + roomName: document.getElementById('room-name'), + roomViewers: document.getElementById('room-viewers'), + leaveRoomBtn: document.getElementById('leave-room-btn'), + selectAnimeBtn: document.getElementById('select-anime-btn'), + toggleChatBtn: document.getElementById('toggle-chat-btn'), + + // Host Quick Controls (Header) + hostControls: document.getElementById('host-controls'), + roomExtSelect: document.getElementById('room-ext-select'), + roomServerSelect: document.getElementById('room-server-select'), + roomSdToggle: document.getElementById('room-sd-toggle'), + + // Info Display + npInfo: document.getElementById('now-playing-info'), + npTitle: document.getElementById('np-title'), + npEpisode: document.getElementById('np-episode'), + + // Player elements + video: document.getElementById('player'), + loading: document.getElementById('player-loading'), + playBtn: document.getElementById('play-pause-btn'), + timeDisplay: document.getElementById('time-display'), + progressContainer: document.querySelector('.progress-container'), + progressPlayed: document.querySelector('.progress-played'), + progressHandle: document.querySelector('.progress-handle'), + fullscreenBtn: document.getElementById('fullscreen-btn'), + subtitlesCanvas: document.getElementById('subtitles-canvas'), + + // Chat + usersList: document.getElementById('users-list'), + toggleUsersBtn: document.getElementById('toggle-users-btn'), + chatMessages: document.getElementById('chat-messages'), + chatForm: document.getElementById('chat-form'), + chatInput: document.getElementById('chat-input'), + roomLayout: document.getElementById('room-layout'), // Corregido: referencia al layout + + // Modals + joinRoomModal: document.getElementById('join-room-modal'), + joinRoomForm: document.getElementById('join-room-form'), + animeSearchModal: document.getElementById('anime-search-modal'), + animeSearchInput: document.getElementById('anime-search-input'), + animeResults: document.getElementById('anime-results'), + + // Config Elements (Modal) + stepSearch: document.getElementById('step-search'), + stepConfig: document.getElementById('step-config'), + backToSearchBtn: document.getElementById('back-to-search'), + configTitle: document.getElementById('selected-anime-title'), + configCover: document.getElementById('config-cover'), + selExtension: document.getElementById('sel-extension'), + selServer: document.getElementById('sel-server'), + selCategory: document.getElementById('sel-category'), + inpEpisode: document.getElementById('inp-episode'), + btnLaunch: document.getElementById('btn-launch-stream'), + configError: document.getElementById('config-error'), + + toastContainer: document.getElementById('video-toast-container'), + }; + + const ui = { + extContainer: document.getElementById('ext-chips-container'), + serverContainer: document.getElementById('server-chips-container'), + sdToggle: document.getElementById('modal-sd-toggle'), + epInput: document.getElementById('inp-episode'), + epInc: document.getElementById('ep-inc'), + epDec: document.getElementById('ep-dec'), + launchBtn: document.getElementById('btn-launch-stream'), + configError: document.getElementById('config-error') + }; + + async function init() { + const params = new URLSearchParams(window.location.search); + const urlRoomId = params.get('id'); + if (!urlRoomId) { + window.location.href = '/anime'; + return; + } + + currentRoomId = urlRoomId; + + if (window.AnimePlayer) { + console.log('Initializing AnimePlayer in room mode'); + window.AnimePlayer.init(null, 'anilist', false, {}, true); + } + + setupEventListeners(); + await preloadExtensions(); + + // --- NUEVO: Obtener info de la sala primero --- + try { + const res = await fetch(`/api/rooms/${currentRoomId}`); + if (!res.ok) throw new Error('Room not found'); + const data = await res.json(); + handleInitialEntry(data.room); + } catch (e) { + console.error(e); + alert("Room not found or deleted"); + window.location.href = '/anime'; + } + } + + // --- NUEVO: Función para manejar la entrada lógica --- + function handleInitialEntry(roomInfo) { + const token = localStorage.getItem('token'); + const passwordGroup = document.getElementById('password-group'); + + // Configurar UI del Modal con datos del Host + const hostInfoDiv = document.getElementById('join-host-info'); + const hostAvatar = document.getElementById('join-host-avatar'); + const hostText = document.getElementById('join-host-text'); + + if (hostInfoDiv && roomInfo.host) { + hostInfoDiv.style.display = 'flex'; + // Usar avatar del host o un placeholder + hostAvatar.src = roomInfo.host.avatar || '/public/assets/placeholder.png'; + hostText.innerHTML = `${escapeHtml(roomInfo.host.username)} invited you to watch`; + } + + // Configurar si pide contraseña + if (passwordGroup) { + // Si la sala tiene pass, mostramos el campo + passwordGroup.style.display = roomInfo.hasPassword ? 'block' : 'none'; + // Marcar en un atributo dataset si es requerida para validación + passwordGroup.dataset.required = roomInfo.hasPassword ? 'true' : 'false'; + } + + if (token) { + // Si tiene token, intentamos conectar directamente. + // Si hay pass y no somos el host/dueño, el socket fallará y pedirá pass luego. + connectToRoom(currentRoomId); + } else { + // Es Guest: Mostrar modal directamente + console.log('Guest user, showing modal...'); + if (elements.joinRoomModal) { + elements.joinRoomModal.classList.add('show'); + const nameInput = document.getElementById('guest-name-input'); + if (nameInput) { + nameInput.value = ''; + setTimeout(() => nameInput.focus(), 100); + } + } + } + } + + async function preloadExtensions() { + const res = await fetch('/api/extensions/anime'); + const data = await res.json(); + + extensionsStore.list = data.extensions || []; + + await Promise.all( + extensionsStore.list.map(async ext => { + const r = await fetch(`/api/extensions/${ext}/settings`); + extensionsStore.settings[ext] = await r.json(); + }) + ); + + extensionsReady = true; + } + + function setupEventListeners() { + // Join Room Form + const cancelJoinBtn = document.getElementById('cancel-join-btn'); + if (cancelJoinBtn) cancelJoinBtn.onclick = leaveRoom; + if (elements.joinRoomForm) elements.joinRoomForm.onsubmit = submitJoinForm; + + // Header Controls + if (elements.selectAnimeBtn) elements.selectAnimeBtn.onclick = openAnimeSearchModal; + if (elements.toggleChatBtn) elements.toggleChatBtn.onclick = toggleChat; + if (elements.leaveRoomBtn) elements.leaveRoomBtn.onclick = leaveRoom; + + // Host Quick Controls Listeners + if (elements.roomExtSelect) elements.roomExtSelect.onchange = (e) => onQuickExtensionChange(e, false); + if (elements.roomServerSelect) elements.roomServerSelect.onchange = onQuickServerChange; + + // Sub/Dub Toggle Logic (Header) + if (elements.roomSdToggle) { + elements.roomSdToggle.onclick = () => { + if (!isHost) return; + const currentState = elements.roomSdToggle.getAttribute('data-state'); + const newState = currentState === 'sub' ? 'dub' : 'sub'; + + // Update UI visually immediately + elements.roomSdToggle.setAttribute('data-state', newState); + elements.roomSdToggle.querySelectorAll('.sd-option').forEach(opt => { + opt.classList.toggle('active', opt.dataset.val === newState); + }); + + // Trigger Stream Reload + onQuickServerChange(); + }; + } + + // Anime Search Modal + const closeSearchBtn = document.getElementById('close-search-modal'); + const animeSearchBtn = document.getElementById('anime-search-btn'); + + if (closeSearchBtn) closeSearchBtn.onclick = closeAnimeSearchModal; + if (animeSearchBtn) animeSearchBtn.onclick = searchAnime; + + if (elements.animeSearchInput) { + elements.animeSearchInput.onkeypress = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + searchAnime(); + } + }; + } + + // Config Step (Modal) + if (elements.backToSearchBtn) { + elements.backToSearchBtn.onclick = () => { + elements.stepConfig.style.display = 'none'; + elements.stepSearch.style.display = 'block'; + }; + } + if (elements.selExtension) elements.selExtension.onchange = handleModalExtensionChange; + if (elements.btnLaunch) elements.btnLaunch.onclick = () => launchStream(true); // true = from modal + + // Room & User List + if (elements.toggleUsersBtn) elements.toggleUsersBtn.onclick = toggleUsersList; + + // Chat + if (elements.chatForm) elements.chatForm.onsubmit = sendMessage; + + // Anime results delegation + if (elements.animeResults) { + elements.animeResults.addEventListener('click', handleAnimeResultClick); + } + } + + // --- QUICK CONTROLS LOGIC (Header) --- + + async function populateQuickControls() { + if (!isHost || !selectedAnimeData) return; + if (!extensionsReady) return; + + elements.roomExtSelect.innerHTML = ''; + + extensionsStore.list.forEach(ext => { + const opt = document.createElement('option'); + opt.value = ext; + opt.textContent = ext[0].toUpperCase() + ext.slice(1); + elements.roomExtSelect.appendChild(opt); + }); + + // 🔥 FORZAR valor real + const extToUse = selectedAnimeData.source || extensionsStore.list[0]; + elements.roomExtSelect.value = extToUse; + + // 🔥 FORZAR carga de servers + await onQuickExtensionChange(null, true); + } + + function onQuickExtensionChange(e, silent = false) { + if (!isHost) return; + + const ext = elements.roomExtSelect.value; + const settings = extensionsStore.settings[ext]; + if (!settings) return; + + elements.roomServerSelect.innerHTML = ''; + + const servers = settings.episodeServers?.length + ? settings.episodeServers + : ['Default']; + + servers.forEach(srv => { + const opt = document.createElement('option'); + opt.value = srv; + opt.textContent = srv; + elements.roomServerSelect.appendChild(opt); + }); + + // 🔥 FORZAR server seleccionado + elements.roomServerSelect.value = servers[0]; + + handleDubUI(settings, 'header'); + + if (!silent) onQuickServerChange(); + } + + function handleDubUI(settings, context = 'header') { + const supportsDub = !!settings.supportsDub; + + if (context === 'header') { + const dubOpt = elements.roomSdToggle + ?.querySelector('.sd-option[data-val="dub"]'); + + if (!dubOpt) return; + + dubOpt.style.opacity = supportsDub ? '1' : '0.3'; + dubOpt.style.pointerEvents = supportsDub ? 'auto' : 'none'; + + if (!supportsDub && + elements.roomSdToggle.getAttribute('data-state') === 'dub') { + + elements.roomSdToggle.setAttribute('data-state', 'sub'); + elements.roomSdToggle.querySelectorAll('.sd-option') + .forEach(o => o.classList.toggle('active', o.dataset.val === 'sub')); + } + } + + if (context === 'modal') { + const dubBtn = ui.sdToggle?.querySelector('[data-val="dub"]'); + if (!dubBtn) return; + + dubBtn.classList.toggle('disabled', !supportsDub); + + if (!supportsDub && configState.category === 'dub') { + configState.category = 'sub'; + updateSDUI(); + } + } + } + + function onQuickServerChange() { + if (!isHost) return; + if (!elements.roomExtSelect.value) return; + if (!elements.roomServerSelect.value) return; + launchStream(false); + } + + // --- MODAL LOGIC --- + + function handleAnimeResultClick(e) { + const itemLink = e.target.closest('.search-item, .anime-result-item, a[href*="/anime/"]'); + + if (itemLink) { + e.preventDefault(); + e.stopPropagation(); + + let title, img, id; + + const titleEl = itemLink.querySelector('.search-title'); + const imgEl = itemLink.querySelector('.search-poster, img'); + + title = titleEl ? titleEl.textContent : (itemLink.textContent.trim() || 'Unknown'); + img = imgEl ? (imgEl.src || imgEl.dataset.src || '/public/assets/placeholder.png') : '/public/assets/placeholder.png'; + + const href = itemLink.getAttribute('href') || ''; + const hrefParts = href.split('/').filter(p => p); + id = hrefParts[hrefParts.length - 1] || itemLink.dataset.id; + + if (!id) return; + + selectedAnimeData = { + id: id, + title: title, + image: img, + source: 'anilist' + }; + + showConfigStep(); + } + } + + function showConfigStep() { + if (!selectedAnimeData) return; + if (!extensionsReady) return; + + // 1. Resetear UI básica + elements.configTitle.textContent = selectedAnimeData.title; + elements.configCover.src = selectedAnimeData.image; + if(ui.configError) ui.configError.style.display = 'none'; + + // 2. Resetear Estado interno + configState.episode = 1; + configState.server = null; + configState.category = 'sub'; // Reset a sub por defecto + configState.extension = null; // Reset extensión + + // 3. Resetear controles visuales + if(ui.epInput) ui.epInput.value = 1; + if(ui.launchBtn) ui.launchBtn.disabled = true; + updateSDUI(); // Función visual para el toggle sub/dub + + // 4. Configurar listeners de botones +/- y toggle + setupConfigListeners(); + + // 5. Renderizar los botones de extensiones + renderExtensionChips(); + + // Mostrar pantalla + elements.stepSearch.style.display = 'none'; + elements.stepConfig.style.display = 'block'; + } + + // Configura los botones + / - y el toggle Sub/Dub + function setupConfigListeners() { + // Episode Stepper + if(ui.epInc) ui.epInc.onclick = () => { + ui.epInput.value = parseInt(ui.epInput.value || 0) + 1; + configState.episode = ui.epInput.value; + }; + if(ui.epDec) ui.epDec.onclick = () => { + if(ui.epInput.value > 1) ui.epInput.value = parseInt(ui.epInput.value) - 1; + configState.episode = ui.epInput.value; + }; + if(ui.epInput) ui.epInput.onchange = (e) => configState.episode = e.target.value; + + // Sub/Dub Toggle + if(ui.sdToggle) { + ui.sdToggle.querySelectorAll('.cat-opt').forEach(opt => { + opt.onclick = () => { + if(opt.classList.contains('disabled')) return; + configState.category = opt.dataset.val; + updateSDUI(); + // Al cambiar categoría, recargar servidores (quizás cambien los disponibles) + if(configState.extension) loadServersForExtension(configState.extension); + }; + }); + } + } + + function updateSDUI() { + if(!ui.sdToggle) return; + ui.sdToggle.querySelectorAll('.cat-opt').forEach(opt => { + opt.classList.toggle('active', opt.dataset.val === configState.category); + }); + } + + // Dibuja los botones de Extensiones + function renderExtensionChips() { + ui.extContainer.innerHTML = ''; + + if (!extensionsStore.list || extensionsStore.list.length === 0) { + ui.extContainer.innerHTML = 'No extensions'; + return; + } + + extensionsStore.list.forEach(ext => { + const chip = document.createElement('div'); + chip.className = 'chip'; + chip.textContent = ext.charAt(0).toUpperCase() + ext.slice(1); + + // Auto-seleccionar si ya estaba en el estado (o default a anilist) + if (!configState.extension && ext === 'anilist') configState.extension = 'anilist'; + if (ext === configState.extension) chip.classList.add('active'); + + chip.onclick = () => { + // Actualizar visual + document.querySelectorAll('#ext-chips-container .chip').forEach(c => c.classList.remove('active')); + chip.classList.add('active'); + + // Actualizar lógica + configState.extension = ext; + configState.server = null; // Reset servidor al cambiar extensión + ui.launchBtn.disabled = true; // Deshabilitar Play hasta elegir server + + loadServersForExtension(ext); + }; + ui.extContainer.appendChild(chip); + }); + + // Cargar servidores de la extensión inicial + if(configState.extension) loadServersForExtension(configState.extension); + } + + // Carga los servidores de la API (Asíncrono) + async function loadServersForExtension(extName) { + if (!extensionsReady) return; + ui.serverContainer.innerHTML = '
Loading servers...
'; + ui.launchBtn.disabled = true; + + try { + const settings = extensionsStore.settings[extName]; + handleDubUI(settings, 'modal'); + + const servers = settings.episodeServers?.length + ? settings.episodeServers + : ['Default']; + + renderServerChips(servers); + + // Manejar si la extensión soporta Dub + const dubBtn = ui.sdToggle.querySelector('[data-val="dub"]'); + if (dubBtn) { + if (!settings.supportsDub) { + dubBtn.classList.add('disabled'); + if(configState.category === 'dub') { + configState.category = 'sub'; // Forzar sub si no hay dub + updateSDUI(); + } + } else { + dubBtn.classList.remove('disabled'); + } + } + + } catch (e) { + console.error("Error loading settings", e); + ui.serverContainer.innerHTML = '
Error loading servers
'; + } + } + + // Dibuja los botones de Servidores + function renderServerChips(servers) { + ui.serverContainer.innerHTML = ''; + + servers.forEach(srv => { + const chip = document.createElement('div'); + chip.className = 'chip'; + chip.textContent = srv; + + chip.onclick = () => { + document.querySelectorAll('#server-chips-container .chip').forEach(c => c.classList.remove('active')); + chip.classList.add('active'); + + configState.server = srv; + // AHORA sí habilitamos el botón de Play + ui.launchBtn.disabled = false; + }; + + ui.serverContainer.appendChild(chip); + }); + } + + async function handleModalExtensionChange() { + const extName = elements.selExtension.value; + if (!extName) return; + + elements.selServer.innerHTML = ''; + elements.selServer.disabled = true; + + try { + const settings = extensionsStore.settings[extName]; + + elements.selServer.innerHTML = ''; + + if (settings.episodeServers && settings.episodeServers.length > 0) { + settings.episodeServers.forEach(srv => { + const opt = document.createElement('option'); + opt.value = srv; + opt.textContent = srv; + elements.selServer.appendChild(opt); + }); + elements.selServer.disabled = false; + } else { + elements.selServer.innerHTML = ''; + elements.selServer.disabled = false; + } + + const dubOption = elements.selCategory.querySelector('option[value="dub"]'); + if (dubOption) { + dubOption.disabled = !settings.supportsDub; + if (!settings.supportsDub && elements.selCategory.value === 'dub') { + elements.selCategory.value = 'sub'; + } + } + + } catch (e) { + console.error("Error loading extension settings", e); + elements.selServer.innerHTML = ''; + } + } + + // --- STREAM LAUNCHER (Unified) --- + + async function launchStream(fromModal = false) { + if (!selectedAnimeData) { + console.warn("No anime selected data found"); + return; + } + + let ext, server, episode, category; + + if (fromModal) { + ext = configState.extension; + server = configState.server; + episode = configState.episode; + category = configState.category; + } else { + // LÓGICA DEL HEADER (Corregida) + ext = elements.roomExtSelect.value; + server = elements.roomServerSelect.value; + + // Intentar leer episodio del texto + let epText = elements.npEpisode.textContent.replace('Ep ', '').trim(); + + // Fallback robusto: Si dice "--" o está vacío, usar los datos guardados o 1 + if (!epText || epText === '--' || isNaN(epText)) { + if (selectedAnimeData.episode) { + epText = selectedAnimeData.episode; + } else { + epText = "1"; // Default absoluto + } + } + episode = epText; + + category = elements.roomSdToggle.getAttribute('data-state'); + } + + // Validación + if (!ext || !server || !episode) { + console.error("Missing params:", { ext, server, episode }); + if(fromModal) { + elements.configError.textContent = "Please select an extension, server, and episode."; + elements.configError.style.display = 'block'; + } else { + alert("Please select a valid server/extension."); + } + return; + } + + // Feedback UI + if(fromModal) { + elements.btnLaunch.disabled = true; + elements.btnLaunch.innerHTML = '
Fetching...'; + elements.configError.style.display = 'none'; + } + + try { + const apiUrl = `/api/watch/stream?animeId=${selectedAnimeData.id}&episode=${episode}&server=${encodeURIComponent(server)}&category=${category}&ext=${ext}&source=${selectedAnimeData.source}`; + console.log('Fetching stream:', apiUrl); + + const res = await fetch(apiUrl); + if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); + + const data = await res.json(); + + // Lógica de fuentes (igual que antes) + const source = data.videoSources?.find(s => s.type === 'm3u8') || data.videoSources?.[0]; + if (!source) throw new Error('No video source found'); + + let proxyUrl = `/api/proxy?url=${encodeURIComponent(source.url)}`; + const headers = data.headers || {}; + if (headers['Referer'] && headers['Referer'] !== "null") proxyUrl += `&referer=${encodeURIComponent(headers['Referer'])}`; + + const subtitles = (source.subtitles || []).map(sub => ({ + label: sub.language, + srclang: sub.id || sub.language.toLowerCase().slice(0, 2), + src: `/api/proxy?url=${encodeURIComponent(sub.url)}` + })); + + const videoPayload = { + type: 'video_update', + video: { + videoData: { + url: proxyUrl, + type: source.type || 'm3u8', + headers: headers + }, + subtitles: subtitles, + currentTime: 0, + isPlaying: true + }, + metadata: { + title: selectedAnimeData.title, + episode: episode, // Usar el episodio corregido + image: selectedAnimeData.image, + id: selectedAnimeData.id + } + }; + + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(videoPayload)); + + // Carga local + loadVideo(videoPayload.video); + updateHeaderInfo(videoPayload.metadata); + + // Si venimos del Modal, sincronizamos los controles rápidos del header + if(fromModal) { + closeAnimeSearchModal(); + + // --- CORRECCIÓN INICIO --- + + // 1. Actualizamos el source en la data global para que coincida con lo que acabas de elegir + selectedAnimeData.source = ext; + + // 2. Forzamos el repoblado del dropdown del header (ahora que tenemos anime y extensión) + await populateQuickControls(); + + // --- CORRECCIÓN FIN --- + + // Sincronizar UI del header + if (extensionsStore.list.includes(ext)) { + elements.roomExtSelect.value = ext; + // Forzamos carga silenciosa para llenar los servidores en el select del header + await onQuickExtensionChange(null, true); + elements.roomServerSelect.value = server; + + // Sincronizar toggle Dub/Sub + elements.roomSdToggle.setAttribute('data-state', category); + elements.roomSdToggle.querySelectorAll('.sd-option').forEach(o => + o.classList.toggle('active', o.dataset.val === category) + ); + } + } + } + + } catch (err) { + console.error('Stream launch error:', err); + const msg = "Error loading stream. Try another server."; + if(fromModal) { + elements.configError.textContent = msg; + elements.configError.style.display = 'block'; + } else { + alert(msg); + } + } finally { + if(fromModal) { + elements.btnLaunch.disabled = false; + elements.btnLaunch.innerHTML = 'Play in Room'; + } + } + } + + // --- CONNECTION & WS --- + + function connectToRoom(roomId, guestName, password) { + const token = localStorage.getItem('token'); + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws/room/${roomId}`; + + const params = new URLSearchParams(); + if (token) params.append('token', token); + if (guestName) params.append('guestName', guestName); + if (password) params.append('password', password); + + if (ws) ws.close(); + + ws = new WebSocket(`${wsUrl}?${params.toString()}`); + + ws.onopen = () => { + console.log('WebSocket Connected'); + if (window.AnimePlayer && typeof window.AnimePlayer.setWebSocket === 'function') { + window.AnimePlayer.setWebSocket(ws); + } + }; + + ws.onmessage = (e) => handleWebSocketMessage(JSON.parse(e.data)); + ws.onerror = (e) => console.error('WebSocket error:', e); + ws.onclose = () => { + console.log('Disconnected'); + if (window.AnimePlayer && typeof window.AnimePlayer.setWebSocket === 'function') { + window.AnimePlayer.setWebSocket(null); + } + }; + } + + function handleWebSocketMessage(data) { + switch (data.type) { + case 'error': + handleConnectionError(data.message); + break; + case 'init': + elements.joinRoomModal.classList.remove('show'); + currentUserId = data.userId; + currentUsername = data.username; + isGuest = data.isGuest; + updateRoomUI(data.room); + if (data.room.currentVideo && data.room.metadata) { + updateHeaderInfo(data.room.metadata); + } + break; + + case 'users_update': + renderUsersList(data.users); + break; + + case 'user_joined': + addSystemMessage(`${data.user.username} joined`); + updateUsersList(); + + if (isHost) { + sendSync(); + } + break; + case 'user_left': + addSystemMessage(`${data.user.username} left`); + updateUsersList(); + break; + case 'chat': + addChatMessage(data); // Siempre añadir al historial del chat lateral + + // Comprobar si el chat está oculto + const isChatHidden = elements.roomLayout.classList.contains('chat-hidden'); + + if (isChatHidden) { + // 1. Mostrar Toast sobre el video + showChatToast(data); + + // 2. Poner punto rojo en el botón + if (elements.toggleChatBtn) { + elements.toggleChatBtn.classList.add('has-unread'); + } + } + break; + case 'video_update': + loadVideo(data.video); + if (data.metadata) { + selectedAnimeData = { + ...selectedAnimeData, + ...data.metadata + }; + updateHeaderInfo(data.metadata); + } + break; + case 'sync': + syncVideo(data.currentTime, data.isPlaying); + updatePlayPauseButton(data.isPlaying); + break; + case 'play': + const vP = getVideoElement(); + if(vP) { vP.currentTime = data.currentTime; vP.play().catch(console.error); updatePlayPauseButton(true); } + break; + case 'pause': + const vPa = getVideoElement(); + if(vPa) { vPa.currentTime = data.currentTime; vPa.pause(); updatePlayPauseButton(false); } + break; + case 'seek': + const vS = getVideoElement(); + if(vS) { vS.currentTime = data.currentTime; } + break; + case 'sync_requested': + if (isHost) sendSync(); + break; + } + } + + function updateRoomUI(room) { + elements.roomName.textContent = room.name; + elements.roomViewers.textContent = `${room.users.length}`; + + const currentUser = room.users.find(u => u.id === currentUserId); + isHost = currentUser?.isHost || false; + + // Mostrar controles solo si es Host + if (elements.selectAnimeBtn) elements.selectAnimeBtn.style.display = isHost ? 'flex' : 'none'; + if (elements.hostControls) elements.hostControls.style.display = isHost ? 'flex' : 'none'; + + if (window.AnimePlayer && typeof window.AnimePlayer.setRoomHost === 'function') { + window.AnimePlayer.setRoomHost(isHost); + } + + // Si somos host y tenemos metadatos, poblar los controles del header + if (isHost && room.metadata) { + if(!selectedAnimeData) selectedAnimeData = { ...room.metadata, source: 'anilist' }; + populateQuickControls(); + } + + renderUsersList(room.users); + if (room.currentVideo) loadVideo(room.currentVideo); + } + + function updateHeaderInfo(meta) { + if (!meta) return; + if (elements.npTitle) elements.npTitle.textContent = meta.title; + if (elements.npEpisode) elements.npEpisode.textContent = `Episode ${meta.episode}`; + if (elements.npInfo) elements.npInfo.style.opacity = '1'; + + // Save data locally so we can use quick controls + if(!selectedAnimeData) selectedAnimeData = { ...meta, source: 'anilist' }; + else { + selectedAnimeData.id = meta.id; + selectedAnimeData.title = meta.title; + // Episode is tracked visually in header + } + } + + // --- UTILS & HELPERS --- + + function handleConnectionError(message) { + const msg = message.toLowerCase(); + if (msg.includes('password') || msg.includes('authorized')) { + const passwordGroup = document.getElementById('password-group'); + const token = localStorage.getItem('token'); + elements.joinRoomModal.classList.add('show'); + if (msg.includes('password')) { + passwordGroup.style.display = 'block'; + document.getElementById('join-password-input').focus(); + } + if(!token) document.getElementById('guest-name-input').focus(); + } else { + alert(message); + window.location.href = '/anime'; + } + } + + function submitJoinForm(e) { + e.preventDefault(); + const guestName = document.getElementById('guest-name-input').value.trim(); + const password = document.getElementById('join-password-input').value.trim(); + const passwordGroup = document.getElementById('password-group'); + + // Validar Nombre para Guest + if (!guestName && !localStorage.getItem('token')) { + alert("Please enter a name"); + return; + } + + // Validar Password si es requerida y está visible + if (passwordGroup.style.display !== 'none' && !password) { + alert("This room requires a password"); + return; + } + + connectToRoom(currentRoomId, guestName, password); + } + + // room.js - Reemplazar toggleChat + + function toggleChat() { + if (elements.roomLayout) { + elements.roomLayout.classList.toggle('chat-hidden'); + + // Si acabamos de ABRIR el chat (ya no tiene la clase chat-hidden) + if (!elements.roomLayout.classList.contains('chat-hidden')) { + // Quitar notificación roja + elements.toggleChatBtn.classList.remove('has-unread'); + // Opcional: Limpiar los toasts flotantes para que no estorben + if(elements.toastContainer) elements.toastContainer.innerHTML = ''; + } + + setTimeout(() => window.dispatchEvent(new Event('resize')), 350); + } + } + + function showChatToast(data) { + if (!elements.toastContainer) return; + + // Crear elemento + const toast = document.createElement('div'); + toast.className = 'video-toast'; + + // Avatar (usar el mismo fallback que el chat) + const avatarSrc = data.avatar || '/public/assets/placeholder.png'; // Asegúrate de tener un placeholder o lógica de iniciales + + toast.innerHTML = ` + +
+ ${escapeHtml(data.username)} + ${escapeHtml(data.message)} +
+ `; + + // Añadir al contenedor + elements.toastContainer.appendChild(toast); + + // Eliminar del DOM después de que termine la animación (5s total: 0.3s in + 4.2s wait + 0.5s out) + setTimeout(() => { + if (toast.parentElement) { + toast.remove(); + } + }, 5000); + + // Limitar número de toasts (máximo 3 al mismo tiempo para no tapar todo el video) + while (elements.toastContainer.children.length > 3) { + elements.toastContainer.removeChild(elements.toastContainer.firstChild); + } + } + + function renderUsersList(users) { + elements.usersList.innerHTML = users.map(user => ` +
+
${user.avatar ? `` : user.username[0].toUpperCase()}
+ ${escapeHtml(user.username)} + ${user.isHost ? 'HOST' : ''} +
+ `).join(''); + } + + function updateUsersList() { + if(ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'request_users' })); + } + + function toggleUsersList() { + const isVisible = elements.usersList.style.display !== 'none'; + elements.usersList.style.display = isVisible ? 'none' : 'block'; + } + + function addSystemMessage(text) { + // 1. Agregar al chat (siempre) + const div = document.createElement('div'); + div.className = 'chat-message system'; + div.innerHTML = `
${escapeHtml(text)}
`; + elements.chatMessages.appendChild(div); + elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight; + + // 2. Si el chat está oculto, mostrar notificación flotante + if (elements.roomLayout && elements.roomLayout.classList.contains('chat-hidden')) { + showSystemToast(text); + } + } + + function showSystemToast(text) { + if (!elements.toastContainer) return; + + const toast = document.createElement('div'); + toast.className = 'video-toast system-toast'; // Clase especial para diferenciarlo + toast.innerHTML = `${escapeHtml(text)}`; + + elements.toastContainer.appendChild(toast); + + // Eliminar a los 4 segundos + setTimeout(() => toast.remove(), 4000); + } + + function addChatMessage(data) { + const time = new Date(data.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const div = document.createElement('div'); + div.className = 'chat-message'; + + // LÓGICA DE AVATAR ACTUALIZADA + let avatarHtml; + if (data.avatar) { + // Si tiene imagen, usamos img tag + avatarHtml = `${data.username}`; + } else { + // Fallback a inicial + avatarHtml = data.username[0].toUpperCase(); + } + + div.innerHTML = ` +
${avatarHtml}
+
+
+ ${escapeHtml(data.username)} + ${time} +
+
${escapeHtml(data.message)}
+
+ `; + elements.chatMessages.appendChild(div); + elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight; + } + + function sendMessage(e) { + e.preventDefault(); + const message = elements.chatInput.value.trim(); + if (!message) return; + if(ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'chat', message })); + elements.chatInput.value = ''; + } + + function loadVideo(videoData) { + if (!videoData || !videoData.videoData) return; + const { url, type } = videoData.videoData; + + if (currentLoadedUrl === url) { + if (videoData.currentTime !== undefined) syncVideo(videoData.currentTime, videoData.isPlaying); + return; + } + currentLoadedUrl = url; + + if (window.AnimePlayer && typeof window.AnimePlayer.loadVideoFromRoom === 'function') { + if(elements.loading) elements.loading.style.display = 'none'; + window.AnimePlayer.loadVideoFromRoom({ url: url, type: type, subtitles: videoData.subtitles }); + + setTimeout(() => { + const newVideoElement = document.getElementById('player'); + if (newVideoElement) { + elements.video = newVideoElement; + attachVideoEventListeners(newVideoElement); + updatePlayPauseButton(!newVideoElement.paused); + } + }, 500); + } else { + loadVideoBasic(url, type, videoData); + } + } + + function getVideoElement() { + if (window.AnimePlayer) return window.AnimePlayer.getVideoElement(); + return document.getElementById('player'); + } + + // Fallback simple video loader + function loadVideoBasic(url, type, videoData) { + elements.loading.style.display = 'none'; + if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } + + if (Hls.isSupported() && type === 'm3u8') { + hlsInstance = new Hls(); + hlsInstance.loadSource(url); + hlsInstance.attachMedia(elements.video); + hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { + if (videoData.isPlaying) elements.video.play().catch(e=>console.error(e)); + }); + } else { + elements.video.src = url; + if (videoData.isPlaying) elements.video.play(); + } + } + + function syncVideo(currentTime, isPlaying) { + const video = getVideoElement(); + if (!video) return; + const timeDiff = Math.abs(video.currentTime - currentTime); + if (timeDiff > 1) video.currentTime = currentTime; + + if (isPlaying && video.paused) { + video.play().then(() => updatePlayPauseButton(true)).catch(console.error); + } else if (!isPlaying && !video.paused) { + video.pause(); + updatePlayPauseButton(false); + } + } + + function sendSync() { + const video = getVideoElement(); + if (!video || !ws) return; + ws.send(JSON.stringify({ type: 'sync', currentTime: video.currentTime, isPlaying: !video.paused })); + } + + function updatePlayPauseButton(isPlaying) { + const playBtn = document.getElementById('play-pause-btn'); + if (!playBtn) return; + playBtn.innerHTML = isPlaying ? + `` : + ``; + } + + function attachVideoEventListeners(video) { + if(!video) return; + video.addEventListener('play', () => updatePlayPauseButton(true)); + video.addEventListener('pause', () => updatePlayPauseButton(false)); + video.addEventListener('timeupdate', () => { + if(elements.timeDisplay) elements.timeDisplay.textContent = formatTime(video.currentTime) + " / " + formatTime(video.duration); + if(elements.progressPlayed) elements.progressPlayed.style.width = (video.currentTime/video.duration*100) + "%"; + }); + } + + function formatTime(s) { + if(isNaN(s)) return "0:00"; + const m = Math.floor(s/60), sec = Math.floor(s%60); + return m + ":" + (sec<10?"0":"") + sec; + } + + function leaveRoom() { + if (ws) ws.close(); + if (hlsInstance) hlsInstance.destroy(); + window.location.href = '/anime'; + } + + function openAnimeSearchModal() { + elements.animeSearchModal.classList.add('show'); + elements.stepSearch.style.display = 'block'; + elements.stepConfig.style.display = 'none'; + setTimeout(() => elements.animeSearchInput.focus(), 100); + } + + function closeAnimeSearchModal() { + elements.animeSearchModal.classList.remove('show'); + } + + async function searchAnime() { + const query = elements.animeSearchInput.value.trim(); + if (!query) return; + elements.animeResults.innerHTML = '
Searching...
'; + if (window.SearchManager) { + await window.SearchManager.search(query, 'anime', elements.animeResults); + } else { + elements.animeResults.innerHTML = 'SearchManager not loaded'; + } + } + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + return { init }; +})(); + +document.addEventListener('DOMContentLoaded', () => RoomsApp.init()); \ No newline at end of file diff --git a/docker/src/views/views.routes.ts b/docker/src/views/views.routes.ts index 3796a87..57f50d2 100644 --- a/docker/src/views/views.routes.ts +++ b/docker/src/views/views.routes.ts @@ -130,6 +130,12 @@ async function viewsRoutes(fastify: FastifyInstance) { reply.type('text/html').send(html); }); + fastify.get('/room', (req: FastifyRequest, reply: FastifyReply) => { + const htmlPath = path.join(__dirname, '..', '..', 'views', 'room.html'); + const html = fs.readFileSync(htmlPath, 'utf-8'); + reply.type('text/html').send(html); + }); + fastify.setNotFoundHandler((req, reply) => { const htmlPath = path.join(__dirname, '..', '..', 'views', '404.html'); const html = fs.readFileSync(htmlPath, 'utf-8'); diff --git a/docker/views/anime/animes.html b/docker/views/anime/animes.html index 73b23b4..32be719 100644 --- a/docker/views/anime/animes.html +++ b/docker/views/anime/animes.html @@ -9,6 +9,7 @@ + @@ -111,6 +112,7 @@ + diff --git a/docker/views/books/books.html b/docker/views/books/books.html index ba28db8..587d99f 100644 --- a/docker/views/books/books.html +++ b/docker/views/books/books.html @@ -10,6 +10,7 @@ + @@ -82,6 +83,7 @@ + diff --git a/docker/views/components/navbar.html b/docker/views/components/navbar.html index c81a9dd..8dd9a4c 100644 --- a/docker/views/components/navbar.html +++ b/docker/views/components/navbar.html @@ -53,6 +53,13 @@ Profile + + +
+

Loading...

+
+ Waiting selection... + + Episode -- +
+
+ + + + +
+
+ + 0 +
+ + + + +
+ + +
+
+
+ +
+ + +
+
+

Waiting for host...

+
+
+ +
+
+
+
+
+
+ +
+
+ + + + 0:00 / 0:00 +
+ +
+ +
+ +
+ +
+
+
+
+
+ + +
+
+

Chat

+ +
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker/views/schedule.html b/docker/views/schedule.html index 050448e..3070e15 100644 --- a/docker/views/schedule.html +++ b/docker/views/schedule.html @@ -11,6 +11,7 @@ + @@ -76,6 +77,7 @@ +