added public watch parties with cloudflared
This commit is contained in:
@@ -47,6 +47,76 @@ fastify.addHook("preHandler", async (request) => {
|
||||
}
|
||||
});
|
||||
|
||||
const roomService = require('./electron/api/rooms/rooms.service');
|
||||
|
||||
fastify.addHook('onRequest', async (req, reply) => {
|
||||
const isTunnel =
|
||||
!!req.headers['cf-connecting-ip'] ||
|
||||
!!req.headers['cf-ray'];
|
||||
|
||||
if (!isTunnel) return;
|
||||
|
||||
if (req.url.startsWith('/public/') ||
|
||||
req.url.startsWith('/views/') ||
|
||||
req.url.startsWith('/src/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url.startsWith('/room')) {
|
||||
const urlParams = new URLSearchParams(req.url.split('?')[1]);
|
||||
const roomId = urlParams.get('id');
|
||||
|
||||
if (!roomId) {
|
||||
return reply.code(404).send({ error: 'Room ID required' });
|
||||
}
|
||||
|
||||
const room = roomService.getRoom(roomId);
|
||||
if (!room || room.exposed !== true) {
|
||||
return reply.code(404).send({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const wsMatch = req.url.match(/^\/ws\/room\/([a-f0-9]+)/);
|
||||
if (wsMatch) {
|
||||
const roomId = wsMatch[1];
|
||||
const room = roomService.getRoom(roomId);
|
||||
|
||||
if (!room || room.exposed !== true) {
|
||||
return reply.code(404).send({ error: 'Room not found' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const apiMatch = req.url.match(/^\/api\/rooms\/([a-f0-9]+)/);
|
||||
if (apiMatch) {
|
||||
const roomId = apiMatch[1];
|
||||
const room = roomService.getRoom(roomId);
|
||||
|
||||
if (!room || room.exposed !== true) {
|
||||
return reply.code(404).send({ error: 'Room not found' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedEndpoints = [
|
||||
'/api/watch/stream',
|
||||
'/api/proxy',
|
||||
'/api/extensions',
|
||||
'/api/search'
|
||||
];
|
||||
|
||||
for (const endpoint of allowedEndpoints) {
|
||||
if (req.url.startsWith(endpoint)) {
|
||||
console.log('[Tunnel] ✓ Allowing utility endpoint:', endpoint);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return reply.code(404).send({ error: 'Not found' });
|
||||
});
|
||||
|
||||
fastify.register(require("@fastify/static"), {
|
||||
root: path.join(__dirname, "public"),
|
||||
prefix: "/public/",
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import * as roomService from './rooms.service';
|
||||
import { getUserById } from '../user/user.service';
|
||||
import { openTunnel } from "./tunnel.manager";
|
||||
|
||||
interface CreateRoomBody {
|
||||
name: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface JoinRoomBody {
|
||||
password?: string;
|
||||
guestName?: string;
|
||||
expose?: boolean;
|
||||
}
|
||||
|
||||
export async function createRoom(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const { name, password } = req.body as CreateRoomBody;
|
||||
const { name, password, expose } = req.body as CreateRoomBody;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
@@ -39,7 +36,23 @@ export async function createRoom(req: any, reply: FastifyReply) {
|
||||
userId
|
||||
};
|
||||
|
||||
const room = roomService.createRoom(name, host, password);
|
||||
let publicUrl: string | undefined;
|
||||
|
||||
if (expose) {
|
||||
publicUrl = await openTunnel();
|
||||
}
|
||||
|
||||
const room = roomService.createRoom(
|
||||
name,
|
||||
host,
|
||||
password,
|
||||
!!expose,
|
||||
publicUrl
|
||||
);
|
||||
|
||||
if (expose && publicUrl) {
|
||||
room.publicUrl = `${publicUrl}/room?id=${room.id}`;
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
@@ -47,7 +60,9 @@ export async function createRoom(req: any, reply: FastifyReply) {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
hasPassword: !!room.password,
|
||||
userCount: room.users.size
|
||||
userCount: room.users.size,
|
||||
exposed: room.exposed,
|
||||
publicUrl: room.publicUrl
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -104,7 +119,9 @@ export async function getRoom(req: FastifyRequest, reply: FastifyReply) {
|
||||
isGuest: u.isGuest
|
||||
})),
|
||||
hasPassword: !!room.password,
|
||||
currentVideo: room.currentVideo
|
||||
currentVideo: room.currentVideo,
|
||||
exposed: room.exposed,
|
||||
publicUrl: room.publicUrl
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
import { closeTunnelIfUnused } from "./tunnel.manager";
|
||||
|
||||
interface RoomUser {
|
||||
id: string;
|
||||
@@ -33,6 +34,8 @@ interface RoomData {
|
||||
} | null;
|
||||
password?: string;
|
||||
metadata?: RoomMetadata | null;
|
||||
exposed: boolean;
|
||||
publicUrl?: string;
|
||||
}
|
||||
|
||||
const rooms = new Map<string, RoomData>();
|
||||
@@ -41,7 +44,7 @@ export function generateRoomId(): string {
|
||||
return crypto.randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
export function createRoom(name: string, host: RoomUser, password?: string): RoomData {
|
||||
export function createRoom(name: string, host: RoomUser, password?: string, exposed = false, publicUrl?: string): RoomData {
|
||||
const roomId = generateRoomId();
|
||||
|
||||
const room: RoomData = {
|
||||
@@ -53,6 +56,8 @@ export function createRoom(name: string, host: RoomUser, password?: string): Roo
|
||||
currentVideo: null,
|
||||
password: password || undefined,
|
||||
metadata: null,
|
||||
exposed,
|
||||
publicUrl
|
||||
};
|
||||
|
||||
rooms.set(roomId, room);
|
||||
@@ -84,13 +89,14 @@ export function removeUserFromRoom(roomId: string, userId: string): boolean {
|
||||
|
||||
room.users.delete(userId);
|
||||
|
||||
// Si no quedan usuarios, eliminar la sala
|
||||
if (room.users.size === 0) {
|
||||
if (room.exposed) {
|
||||
closeTunnelIfUnused();
|
||||
}
|
||||
rooms.delete(roomId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si era el host, asignar nuevo host
|
||||
if (room.host.id === userId && room.users.size > 0) {
|
||||
const newHost = Array.from(room.users.values())[0];
|
||||
newHost.isHost = true;
|
||||
@@ -109,6 +115,13 @@ export function updateRoomVideo(roomId: string, videoData: any): boolean {
|
||||
}
|
||||
|
||||
export function deleteRoom(roomId: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
if (room.exposed) {
|
||||
closeTunnelIfUnused();
|
||||
}
|
||||
|
||||
return rooms.delete(roomId);
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,34 @@ function handleMessage(roomId: string, userId: string, data: any) {
|
||||
});
|
||||
break;
|
||||
|
||||
case 'request_sync':
|
||||
// Cualquier usuario puede pedir sync
|
||||
const host = clients.get(room.host.id);
|
||||
if (host && host.socket && host.socket.readyState === 1) {
|
||||
console.log(`[Sync Request] User ${userId} requested sync from host`);
|
||||
|
||||
host.socket.send(JSON.stringify({
|
||||
type: 'sync_requested',
|
||||
requestedBy: userId,
|
||||
username: room.users.get(userId)?.username
|
||||
}));
|
||||
} else {
|
||||
console.warn(`[Sync Request] Host not available for user ${userId}`);
|
||||
|
||||
if (room.currentVideo) {
|
||||
const client = clients.get(userId);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
console.log(`[Sync Request] Sending cached video state to ${userId}`);
|
||||
client.socket.send(JSON.stringify({
|
||||
type: 'sync',
|
||||
currentTime: room.currentVideo.currentTime || 0,
|
||||
isPlaying: room.currentVideo.isPlaying || false
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'video_update':
|
||||
if (room.host.id !== userId) return;
|
||||
|
||||
@@ -288,11 +316,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
|
||||
type: 'play',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
// Solo el host puede controlar la reproducción
|
||||
if (room.host.id !== userId) {
|
||||
console.warn('Non-host attempted pause:', userId);
|
||||
return;
|
||||
@@ -303,11 +330,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
|
||||
type: 'pause',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'seek':
|
||||
// Solo el host puede controlar la reproducción
|
||||
if (room.host.id !== userId) {
|
||||
console.warn('Non-host attempted seek:', userId);
|
||||
return;
|
||||
@@ -318,22 +344,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
|
||||
type: 'seek',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
|
||||
break;
|
||||
|
||||
case 'request_sync':
|
||||
// Cualquier usuario puede pedir sync
|
||||
const host = clients.get(room.host.id);
|
||||
if (host && host.socket && host.socket.readyState === 1) {
|
||||
host.socket.send(JSON.stringify({
|
||||
type: 'sync_requested',
|
||||
requestedBy: userId
|
||||
}));
|
||||
}
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'request_state':
|
||||
// Enviar estado actual de la sala al usuario que lo solicita
|
||||
const client = clients.get(userId);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
const updatedRoom = roomService.getRoom(roomId);
|
||||
|
||||
111
desktop/src/api/rooms/tunnel.manager.ts
Normal file
111
desktop/src/api/rooms/tunnel.manager.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { getConfig as loadConfig } from '../../shared/config';
|
||||
const { values } = loadConfig();
|
||||
const CLOUDFLARED_PATH = values.paths?.cloudflared || 'cloudflared';
|
||||
|
||||
let tunnelProcess: ChildProcess | null = null;
|
||||
let exposedRooms = 0;
|
||||
let publicUrl: string | null = null;
|
||||
let tunnelPromise: Promise<string> | null = null;
|
||||
|
||||
export function openTunnel(): Promise<string> {
|
||||
if (tunnelProcess && publicUrl) {
|
||||
exposedRooms++;
|
||||
return Promise.resolve(publicUrl);
|
||||
}
|
||||
|
||||
if (tunnelPromise) {
|
||||
return tunnelPromise;
|
||||
}
|
||||
|
||||
tunnelPromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Timeout esperando URL del túnel (30s)"));
|
||||
}, 30000);
|
||||
|
||||
tunnelProcess = spawn(CLOUDFLARED_PATH, [
|
||||
"tunnel",
|
||||
"--url",
|
||||
"http://localhost:54322",
|
||||
"--no-autoupdate"
|
||||
]);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
tunnelPromise = null;
|
||||
};
|
||||
|
||||
let outputBuffer = "";
|
||||
|
||||
const processOutput = (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
outputBuffer += text;
|
||||
|
||||
const match = outputBuffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
||||
if (match && !publicUrl) {
|
||||
publicUrl = match[0];
|
||||
exposedRooms = 1;
|
||||
cleanup();
|
||||
resolve(publicUrl);
|
||||
}
|
||||
};
|
||||
|
||||
tunnelProcess.stdout?.on("data", (data) => {
|
||||
processOutput(data);
|
||||
});
|
||||
|
||||
tunnelProcess.stderr?.on("data", (data) => {
|
||||
processOutput(data);
|
||||
});
|
||||
|
||||
tunnelProcess.on("error", (error) => {
|
||||
console.error("[Cloudflared Process Error]", error);
|
||||
cleanup();
|
||||
tunnelProcess = null;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
tunnelProcess.on("exit", (code, signal) => {
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
|
||||
if (!publicUrl) {
|
||||
cleanup();
|
||||
reject(new Error(`Proceso cloudflared terminó antes de obtener URL (código: ${code})`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return tunnelPromise;
|
||||
}
|
||||
|
||||
export function closeTunnelIfUnused() {
|
||||
exposedRooms--;
|
||||
console.log(`[Tunnel Manager] Rooms expuestas: ${exposedRooms}`);
|
||||
|
||||
if (exposedRooms <= 0 && tunnelProcess) {
|
||||
console.log("[Tunnel Manager] Cerrando túnel...");
|
||||
tunnelProcess.kill();
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
tunnelPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTunnelUrl(): string | null {
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
export function forceTunnelClose() {
|
||||
if (tunnelProcess) {
|
||||
console.log("[Tunnel Manager] Forzando cierre del túnel...");
|
||||
tunnelProcess.kill();
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
tunnelPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,13 @@ class CreateRoomModal {
|
||||
<label>Password (Optional)</label>
|
||||
<input type="password" class="cr-input" name="password" placeholder="Leave empty for public" maxlength="50" />
|
||||
</div>
|
||||
|
||||
<div class="cr-form-group cr-checkbox-group">
|
||||
<label class="cr-checkbox">
|
||||
<input type="checkbox" name="expose" />
|
||||
<span>Generate public link (via tunnel)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="cr-actions">
|
||||
<button type="button" class="cr-btn-cancel" id="cr-cancel">Cancel</button>
|
||||
@@ -87,6 +94,7 @@ class CreateRoomModal {
|
||||
btn.textContent = 'Creating...';
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const expose = formData.get('expose') === 'on';
|
||||
const name = formData.get('name').trim();
|
||||
const password = formData.get('password').trim();
|
||||
const token = localStorage.getItem('token');
|
||||
@@ -100,7 +108,8 @@ class CreateRoomModal {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
password: password || undefined
|
||||
password: password || undefined,
|
||||
expose
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ const RoomsApp = (function() {
|
||||
let configState = {
|
||||
extension: null,
|
||||
server: null,
|
||||
category: 'sub', // 'sub' o 'dub'
|
||||
category: 'sub',
|
||||
episode: 1
|
||||
};
|
||||
|
||||
let extensionsStore = {
|
||||
list: [],
|
||||
settings: {} // { anilist: {...}, gogo: {...} }
|
||||
settings: {}
|
||||
};
|
||||
|
||||
const elements = {
|
||||
@@ -27,7 +27,6 @@ const RoomsApp = (function() {
|
||||
roomView: document.getElementById('room-view'),
|
||||
roomName: document.getElementById('room-name'),
|
||||
roomViewers: document.getElementById('room-viewers'),
|
||||
leaveRoomBtn: document.getElementById('leave-room-btn'),
|
||||
selectAnimeBtn: document.getElementById('select-anime-btn'),
|
||||
toggleChatBtn: document.getElementById('toggle-chat-btn'),
|
||||
|
||||
@@ -59,7 +58,7 @@ const RoomsApp = (function() {
|
||||
chatMessages: document.getElementById('chat-messages'),
|
||||
chatForm: document.getElementById('chat-form'),
|
||||
chatInput: document.getElementById('chat-input'),
|
||||
roomLayout: document.getElementById('room-layout'), // Corregido: referencia al layout
|
||||
roomLayout: document.getElementById('room-layout'),
|
||||
|
||||
// Modals
|
||||
joinRoomModal: document.getElementById('join-room-modal'),
|
||||
@@ -113,7 +112,6 @@ const RoomsApp = (function() {
|
||||
setupEventListeners();
|
||||
await preloadExtensions();
|
||||
|
||||
// --- NUEVO: Obtener info de la sala primero ---
|
||||
try {
|
||||
const res = await fetch(`/api/rooms/${currentRoomId}`);
|
||||
if (!res.ok) throw new Error('Room not found');
|
||||
@@ -122,41 +120,39 @@ const RoomsApp = (function() {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Room not found or deleted");
|
||||
window.location.href = '/anime';
|
||||
}
|
||||
}
|
||||
|
||||
// --- NUEVO: Función para manejar la entrada lógica ---
|
||||
function handleInitialEntry(roomInfo) {
|
||||
const token = localStorage.getItem('token');
|
||||
const passwordGroup = document.getElementById('password-group');
|
||||
|
||||
// Configurar UI del Modal con datos del Host
|
||||
const hostInfoDiv = document.getElementById('join-host-info');
|
||||
const hostAvatar = document.getElementById('join-host-avatar');
|
||||
const hostText = document.getElementById('join-host-text');
|
||||
|
||||
if (hostInfoDiv && roomInfo.host) {
|
||||
hostInfoDiv.style.display = 'flex';
|
||||
// Usar avatar del host o un placeholder
|
||||
hostAvatar.src = roomInfo.host.avatar || '/public/assets/placeholder.png';
|
||||
hostText.innerHTML = `<span>${escapeHtml(roomInfo.host.username)}</span> invited you to watch`;
|
||||
}
|
||||
|
||||
// Configurar si pide contraseña
|
||||
if (passwordGroup) {
|
||||
// Si la sala tiene pass, mostramos el campo
|
||||
passwordGroup.style.display = roomInfo.hasPassword ? 'block' : 'none';
|
||||
// Marcar en un atributo dataset si es requerida para validación
|
||||
passwordGroup.dataset.required = roomInfo.hasPassword ? 'true' : 'false';
|
||||
}
|
||||
|
||||
window.__roomPublicUrl = roomInfo.publicUrl || null;
|
||||
window.__roomExposed = roomInfo.exposed || false;
|
||||
|
||||
console.log('Room info loaded:', {
|
||||
exposed: window.__roomExposed,
|
||||
publicUrl: window.__roomPublicUrl
|
||||
});
|
||||
|
||||
if (token) {
|
||||
// Si tiene token, intentamos conectar directamente.
|
||||
// Si hay pass y no somos el host/dueño, el socket fallará y pedirá pass luego.
|
||||
connectToRoom(currentRoomId);
|
||||
} else {
|
||||
// Es Guest: Mostrar modal directamente
|
||||
console.log('Guest user, showing modal...');
|
||||
if (elements.joinRoomModal) {
|
||||
elements.joinRoomModal.classList.add('show');
|
||||
@@ -186,39 +182,32 @@ const RoomsApp = (function() {
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Join Room Form
|
||||
const cancelJoinBtn = document.getElementById('cancel-join-btn');
|
||||
if (cancelJoinBtn) cancelJoinBtn.onclick = leaveRoom;
|
||||
if (elements.joinRoomForm) elements.joinRoomForm.onsubmit = submitJoinForm;
|
||||
|
||||
// Header Controls
|
||||
if (elements.selectAnimeBtn) elements.selectAnimeBtn.onclick = openAnimeSearchModal;
|
||||
if (elements.toggleChatBtn) elements.toggleChatBtn.onclick = toggleChat;
|
||||
if (elements.leaveRoomBtn) elements.leaveRoomBtn.onclick = leaveRoom;
|
||||
|
||||
// Host Quick Controls Listeners
|
||||
if (elements.roomExtSelect) elements.roomExtSelect.onchange = (e) => onQuickExtensionChange(e, false);
|
||||
if (elements.roomServerSelect) elements.roomServerSelect.onchange = onQuickServerChange;
|
||||
|
||||
// Sub/Dub Toggle Logic (Header)
|
||||
if (elements.roomSdToggle) {
|
||||
elements.roomSdToggle.onclick = () => {
|
||||
if (!isHost) return;
|
||||
const currentState = elements.roomSdToggle.getAttribute('data-state');
|
||||
const newState = currentState === 'sub' ? 'dub' : 'sub';
|
||||
|
||||
// Update UI visually immediately
|
||||
elements.roomSdToggle.setAttribute('data-state', newState);
|
||||
elements.roomSdToggle.querySelectorAll('.sd-option').forEach(opt => {
|
||||
opt.classList.toggle('active', opt.dataset.val === newState);
|
||||
});
|
||||
|
||||
// Trigger Stream Reload
|
||||
onQuickServerChange();
|
||||
};
|
||||
}
|
||||
|
||||
// Anime Search Modal
|
||||
const closeSearchBtn = document.getElementById('close-search-modal');
|
||||
const animeSearchBtn = document.getElementById('anime-search-btn');
|
||||
|
||||
@@ -234,7 +223,6 @@ const RoomsApp = (function() {
|
||||
};
|
||||
}
|
||||
|
||||
// Config Step (Modal)
|
||||
if (elements.backToSearchBtn) {
|
||||
elements.backToSearchBtn.onclick = () => {
|
||||
elements.stepConfig.style.display = 'none';
|
||||
@@ -244,13 +232,10 @@ const RoomsApp = (function() {
|
||||
if (elements.selExtension) elements.selExtension.onchange = handleModalExtensionChange;
|
||||
if (elements.btnLaunch) elements.btnLaunch.onclick = () => launchStream(true); // true = from modal
|
||||
|
||||
// Room & User List
|
||||
if (elements.toggleUsersBtn) elements.toggleUsersBtn.onclick = toggleUsersList;
|
||||
|
||||
// Chat
|
||||
if (elements.chatForm) elements.chatForm.onsubmit = sendMessage;
|
||||
|
||||
// Anime results delegation
|
||||
if (elements.animeResults) {
|
||||
elements.animeResults.addEventListener('click', handleAnimeResultClick);
|
||||
}
|
||||
@@ -271,11 +256,8 @@ const RoomsApp = (function() {
|
||||
elements.roomExtSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// 🔥 FORZAR valor real
|
||||
const extToUse = selectedAnimeData.source || extensionsStore.list[0];
|
||||
elements.roomExtSelect.value = extToUse;
|
||||
elements.roomExtSelect.value = selectedAnimeData.source || extensionsStore.list[0];
|
||||
|
||||
// 🔥 FORZAR carga de servers
|
||||
await onQuickExtensionChange(null, true);
|
||||
}
|
||||
|
||||
@@ -299,7 +281,6 @@ const RoomsApp = (function() {
|
||||
elements.roomServerSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// 🔥 FORZAR server seleccionado
|
||||
elements.roomServerSelect.value = servers[0];
|
||||
|
||||
handleDubUI(settings, 'header');
|
||||
@@ -348,8 +329,6 @@ const RoomsApp = (function() {
|
||||
launchStream(false);
|
||||
}
|
||||
|
||||
// --- MODAL LOGIC ---
|
||||
|
||||
function handleAnimeResultClick(e) {
|
||||
const itemLink = e.target.closest('.search-item, .anime-result-item, a[href*="/anime/"]');
|
||||
|
||||
@@ -363,7 +342,7 @@ const RoomsApp = (function() {
|
||||
const imgEl = itemLink.querySelector('.search-poster, img');
|
||||
|
||||
title = titleEl ? titleEl.textContent : (itemLink.textContent.trim() || 'Unknown');
|
||||
img = imgEl ? (imgEl.src || imgEl.dataset.src || '/public/assets/placeholder.png') : '/public/assets/placeholder.png';
|
||||
img = imgEl ? (imgEl.src || imgEl.dataset.src || '/public/assets/placeholder.svg') : '/public/assets/placeholder.svg';
|
||||
|
||||
const href = itemLink.getAttribute('href') || '';
|
||||
const hrefParts = href.split('/').filter(p => p);
|
||||
@@ -386,36 +365,28 @@ const RoomsApp = (function() {
|
||||
if (!selectedAnimeData) return;
|
||||
if (!extensionsReady) return;
|
||||
|
||||
// 1. Resetear UI básica
|
||||
elements.configTitle.textContent = selectedAnimeData.title;
|
||||
elements.configCover.src = selectedAnimeData.image;
|
||||
if(ui.configError) ui.configError.style.display = 'none';
|
||||
|
||||
// 2. Resetear Estado interno
|
||||
configState.episode = 1;
|
||||
configState.server = null;
|
||||
configState.category = 'sub'; // Reset a sub por defecto
|
||||
configState.extension = null; // Reset extensión
|
||||
configState.category = 'sub';
|
||||
configState.extension = null;
|
||||
|
||||
// 3. Resetear controles visuales
|
||||
if(ui.epInput) ui.epInput.value = 1;
|
||||
if(ui.launchBtn) ui.launchBtn.disabled = true;
|
||||
updateSDUI(); // Función visual para el toggle sub/dub
|
||||
updateSDUI();
|
||||
|
||||
// 4. Configurar listeners de botones +/- y toggle
|
||||
setupConfigListeners();
|
||||
|
||||
// 5. Renderizar los botones de extensiones
|
||||
renderExtensionChips();
|
||||
|
||||
// Mostrar pantalla
|
||||
elements.stepSearch.style.display = 'none';
|
||||
elements.stepConfig.style.display = 'block';
|
||||
}
|
||||
|
||||
// Configura los botones + / - y el toggle Sub/Dub
|
||||
function setupConfigListeners() {
|
||||
// Episode Stepper
|
||||
if(ui.epInc) ui.epInc.onclick = () => {
|
||||
ui.epInput.value = parseInt(ui.epInput.value || 0) + 1;
|
||||
configState.episode = ui.epInput.value;
|
||||
@@ -426,14 +397,12 @@ const RoomsApp = (function() {
|
||||
};
|
||||
if(ui.epInput) ui.epInput.onchange = (e) => configState.episode = e.target.value;
|
||||
|
||||
// Sub/Dub Toggle
|
||||
if(ui.sdToggle) {
|
||||
ui.sdToggle.querySelectorAll('.cat-opt').forEach(opt => {
|
||||
opt.onclick = () => {
|
||||
if(opt.classList.contains('disabled')) return;
|
||||
configState.category = opt.dataset.val;
|
||||
updateSDUI();
|
||||
// Al cambiar categoría, recargar servidores (quizás cambien los disponibles)
|
||||
if(configState.extension) loadServersForExtension(configState.extension);
|
||||
};
|
||||
});
|
||||
@@ -447,7 +416,6 @@ const RoomsApp = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Dibuja los botones de Extensiones
|
||||
function renderExtensionChips() {
|
||||
ui.extContainer.innerHTML = '';
|
||||
|
||||
@@ -461,30 +429,25 @@ const RoomsApp = (function() {
|
||||
chip.className = 'chip';
|
||||
chip.textContent = ext.charAt(0).toUpperCase() + ext.slice(1);
|
||||
|
||||
// Auto-seleccionar si ya estaba en el estado (o default a anilist)
|
||||
if (!configState.extension && ext === 'anilist') configState.extension = 'anilist';
|
||||
if (ext === configState.extension) chip.classList.add('active');
|
||||
|
||||
chip.onclick = () => {
|
||||
// Actualizar visual
|
||||
document.querySelectorAll('#ext-chips-container .chip').forEach(c => c.classList.remove('active'));
|
||||
chip.classList.add('active');
|
||||
|
||||
// Actualizar lógica
|
||||
configState.extension = ext;
|
||||
configState.server = null; // Reset servidor al cambiar extensión
|
||||
ui.launchBtn.disabled = true; // Deshabilitar Play hasta elegir server
|
||||
configState.server = null;
|
||||
ui.launchBtn.disabled = true;
|
||||
|
||||
loadServersForExtension(ext);
|
||||
};
|
||||
ui.extContainer.appendChild(chip);
|
||||
});
|
||||
|
||||
// Cargar servidores de la extensión inicial
|
||||
if(configState.extension) loadServersForExtension(configState.extension);
|
||||
}
|
||||
|
||||
// Carga los servidores de la API (Asíncrono)
|
||||
async function loadServersForExtension(extName) {
|
||||
if (!extensionsReady) return;
|
||||
ui.serverContainer.innerHTML = '<div class="grid-loader"><div class="spinner" style="width:20px;height:20px;"></div> Loading servers...</div>';
|
||||
@@ -500,7 +463,6 @@ const RoomsApp = (function() {
|
||||
|
||||
renderServerChips(servers);
|
||||
|
||||
// Manejar si la extensión soporta Dub
|
||||
const dubBtn = ui.sdToggle.querySelector('[data-val="dub"]');
|
||||
if (dubBtn) {
|
||||
if (!settings.supportsDub) {
|
||||
@@ -520,7 +482,6 @@ const RoomsApp = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
// Dibuja los botones de Servidores
|
||||
function renderServerChips(servers) {
|
||||
ui.serverContainer.innerHTML = '';
|
||||
|
||||
@@ -534,7 +495,6 @@ const RoomsApp = (function() {
|
||||
chip.classList.add('active');
|
||||
|
||||
configState.server = srv;
|
||||
// AHORA sí habilitamos el botón de Play
|
||||
ui.launchBtn.disabled = false;
|
||||
};
|
||||
|
||||
@@ -597,19 +557,16 @@ const RoomsApp = (function() {
|
||||
episode = configState.episode;
|
||||
category = configState.category;
|
||||
} else {
|
||||
// LÓGICA DEL HEADER (Corregida)
|
||||
ext = elements.roomExtSelect.value;
|
||||
server = elements.roomServerSelect.value;
|
||||
|
||||
// Intentar leer episodio del texto
|
||||
let epText = elements.npEpisode.textContent.replace('Ep ', '').trim();
|
||||
|
||||
// Fallback robusto: Si dice "--" o está vacío, usar los datos guardados o 1
|
||||
if (!epText || epText === '--' || isNaN(epText)) {
|
||||
if (selectedAnimeData.episode) {
|
||||
epText = selectedAnimeData.episode;
|
||||
} else {
|
||||
epText = "1"; // Default absoluto
|
||||
epText = "1";
|
||||
}
|
||||
}
|
||||
episode = epText;
|
||||
@@ -617,7 +574,6 @@ const RoomsApp = (function() {
|
||||
category = elements.roomSdToggle.getAttribute('data-state');
|
||||
}
|
||||
|
||||
// Validación
|
||||
if (!ext || !server || !episode) {
|
||||
console.error("Missing params:", { ext, server, episode });
|
||||
if(fromModal) {
|
||||
@@ -629,7 +585,6 @@ const RoomsApp = (function() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Feedback UI
|
||||
if(fromModal) {
|
||||
elements.btnLaunch.disabled = true;
|
||||
elements.btnLaunch.innerHTML = '<div class="spinner" style="width:20px;height:20px;"></div> Fetching...';
|
||||
@@ -645,7 +600,6 @@ const RoomsApp = (function() {
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Lógica de fuentes (igual que antes)
|
||||
const source = data.videoSources?.find(s => s.type === 'm3u8') || data.videoSources?.[0];
|
||||
if (!source) throw new Error('No video source found');
|
||||
|
||||
@@ -673,7 +627,7 @@ const RoomsApp = (function() {
|
||||
},
|
||||
metadata: {
|
||||
title: selectedAnimeData.title,
|
||||
episode: episode, // Usar el episodio corregido
|
||||
episode: episode,
|
||||
image: selectedAnimeData.image,
|
||||
id: selectedAnimeData.id
|
||||
}
|
||||
@@ -682,32 +636,21 @@ const RoomsApp = (function() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(videoPayload));
|
||||
|
||||
// Carga local
|
||||
loadVideo(videoPayload.video);
|
||||
updateHeaderInfo(videoPayload.metadata);
|
||||
|
||||
// Si venimos del Modal, sincronizamos los controles rápidos del header
|
||||
if(fromModal) {
|
||||
closeAnimeSearchModal();
|
||||
|
||||
// --- CORRECCIÓN INICIO ---
|
||||
|
||||
// 1. Actualizamos el source en la data global para que coincida con lo que acabas de elegir
|
||||
selectedAnimeData.source = ext;
|
||||
|
||||
// 2. Forzamos el repoblado del dropdown del header (ahora que tenemos anime y extensión)
|
||||
await populateQuickControls();
|
||||
|
||||
// --- CORRECCIÓN FIN ---
|
||||
|
||||
// Sincronizar UI del header
|
||||
if (extensionsStore.list.includes(ext)) {
|
||||
elements.roomExtSelect.value = ext;
|
||||
// Forzamos carga silenciosa para llenar los servidores en el select del header
|
||||
await onQuickExtensionChange(null, true);
|
||||
elements.roomServerSelect.value = server;
|
||||
|
||||
// Sincronizar toggle Dub/Sub
|
||||
elements.roomSdToggle.setAttribute('data-state', category);
|
||||
elements.roomSdToggle.querySelectorAll('.sd-option').forEach(o =>
|
||||
o.classList.toggle('active', o.dataset.val === category)
|
||||
@@ -737,48 +680,165 @@ const RoomsApp = (function() {
|
||||
|
||||
function connectToRoom(roomId, guestName, password) {
|
||||
const token = localStorage.getItem('token');
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/room/${roomId}`;
|
||||
|
||||
const isTunnel = window.location.hostname.includes('trycloudflare.com');
|
||||
|
||||
let wsUrl;
|
||||
|
||||
if (isTunnel) {
|
||||
wsUrl = `wss://${window.location.host}/ws/room/${roomId}`;
|
||||
console.log('[Tunnel Mode] Using secure WebSocket:', wsUrl);
|
||||
} else {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/ws/room/${roomId}`;
|
||||
console.log('[Local Mode] Using WebSocket:', wsUrl);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (token) params.append('token', token);
|
||||
if (guestName) params.append('guestName', guestName);
|
||||
if (password) params.append('password', password);
|
||||
|
||||
if (ws) ws.close();
|
||||
if (ws) {
|
||||
console.log('Closing existing WebSocket...');
|
||||
ws.close();
|
||||
}
|
||||
|
||||
console.log('Connecting to:', `${wsUrl}?${params.toString()}`);
|
||||
ws = new WebSocket(`${wsUrl}?${params.toString()}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket Connected');
|
||||
console.log('✓ WebSocket Connected');
|
||||
if (window.AnimePlayer && typeof window.AnimePlayer.setWebSocket === 'function') {
|
||||
window.AnimePlayer.setWebSocket(ws);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
console.log('Requesting sync from host...');
|
||||
ws.send(JSON.stringify({ type: 'request_sync' }));
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => handleWebSocketMessage(JSON.parse(e.data));
|
||||
ws.onerror = (e) => console.error('WebSocket error:', e);
|
||||
ws.onclose = () => {
|
||||
console.log('Disconnected');
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('✗ WebSocket error:', e);
|
||||
showConnectionError('WebSocket connection failed. Check your connection.');
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('WebSocket Disconnected:', event.code, event.reason);
|
||||
|
||||
if (window.AnimePlayer && typeof window.AnimePlayer.setWebSocket === 'function') {
|
||||
window.AnimePlayer.setWebSocket(null);
|
||||
}
|
||||
|
||||
if (event.code !== 1000 && event.code !== 1001) {
|
||||
console.log('Attempting reconnection in 3 seconds...');
|
||||
showReconnectingToast();
|
||||
setTimeout(() => {
|
||||
if (currentRoomId) {
|
||||
console.log('Reconnecting to room...');
|
||||
connectToRoom(currentRoomId, guestName, password);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function showReconnectingToast() {
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'reconnecting-toast';
|
||||
toast.className = 'connection-error-toast';
|
||||
toast.innerHTML = `
|
||||
<div style="display:flex; align-items:center; gap:10px;">
|
||||
<div class="spinner" style="width:16px; height:16px; border-width:2px;"></div>
|
||||
<span>Reconnecting...</span>
|
||||
</div>
|
||||
`;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(251, 191, 36, 0.95);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
animation: slideDown 0.3s ease-out;
|
||||
`;
|
||||
|
||||
const existing = document.getElementById('reconnecting-toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
function showConnectionError(message) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'connection-error-toast';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.cssText = `
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(239, 68, 68, 0.95);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
animation: slideDown 0.3s ease-out;
|
||||
`;
|
||||
|
||||
document.body.appendChild(errorDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
errorDiv.style.animation = 'fadeOut 0.3s ease-out';
|
||||
setTimeout(() => errorDiv.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function handleWebSocketMessage(data) {
|
||||
switch (data.type) {
|
||||
case 'error':
|
||||
handleConnectionError(data.message);
|
||||
break;
|
||||
|
||||
case 'init':
|
||||
const reconnectToast = document.getElementById('reconnecting-toast');
|
||||
if (reconnectToast) reconnectToast.remove();
|
||||
|
||||
elements.joinRoomModal.classList.remove('show');
|
||||
currentUserId = data.userId;
|
||||
currentUsername = data.username;
|
||||
isGuest = data.isGuest;
|
||||
updateRoomUI(data.room);
|
||||
if (data.room.currentVideo && data.room.metadata) {
|
||||
updateHeaderInfo(data.room.metadata);
|
||||
|
||||
if (data.room.currentVideo) {
|
||||
loadVideo(data.room.currentVideo);
|
||||
|
||||
if (data.room.metadata) {
|
||||
updateHeaderInfo(data.room.metadata);
|
||||
}
|
||||
|
||||
if (!isHost) {
|
||||
console.log('Video detected on join, requesting sync...');
|
||||
setTimeout(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'request_sync' }));
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -791,29 +851,27 @@ const RoomsApp = (function() {
|
||||
updateUsersList();
|
||||
|
||||
if (isHost) {
|
||||
sendSync();
|
||||
console.log('New user joined, sending sync...');
|
||||
setTimeout(() => sendSync(), 500);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user_left':
|
||||
addSystemMessage(`${data.user.username} left`);
|
||||
updateUsersList();
|
||||
break;
|
||||
|
||||
case 'chat':
|
||||
addChatMessage(data); // Siempre añadir al historial del chat lateral
|
||||
|
||||
// Comprobar si el chat está oculto
|
||||
addChatMessage(data);
|
||||
const isChatHidden = elements.roomLayout.classList.contains('chat-hidden');
|
||||
|
||||
if (isChatHidden) {
|
||||
// 1. Mostrar Toast sobre el video
|
||||
showChatToast(data);
|
||||
|
||||
// 2. Poner punto rojo en el botón
|
||||
if (elements.toggleChatBtn) {
|
||||
elements.toggleChatBtn.classList.add('has-unread');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'video_update':
|
||||
loadVideo(data.video);
|
||||
if (data.metadata) {
|
||||
@@ -823,25 +881,53 @@ const RoomsApp = (function() {
|
||||
};
|
||||
updateHeaderInfo(data.metadata);
|
||||
}
|
||||
|
||||
if (!isHost) {
|
||||
setTimeout(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
console.log('New video loaded, requesting sync...');
|
||||
ws.send(JSON.stringify({ type: 'request_sync' }));
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sync':
|
||||
console.log('Received sync:', data.currentTime, data.isPlaying ? 'playing' : 'paused');
|
||||
syncVideo(data.currentTime, data.isPlaying);
|
||||
updatePlayPauseButton(data.isPlaying);
|
||||
break;
|
||||
|
||||
case 'play':
|
||||
const vP = getVideoElement();
|
||||
if(vP) { vP.currentTime = data.currentTime; vP.play().catch(console.error); updatePlayPauseButton(true); }
|
||||
if(vP) {
|
||||
vP.currentTime = data.currentTime;
|
||||
vP.play().catch(console.error);
|
||||
updatePlayPauseButton(true);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
const vPa = getVideoElement();
|
||||
if(vPa) { vPa.currentTime = data.currentTime; vPa.pause(); updatePlayPauseButton(false); }
|
||||
if(vPa) {
|
||||
vPa.currentTime = data.currentTime;
|
||||
vPa.pause();
|
||||
updatePlayPauseButton(false);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'seek':
|
||||
const vS = getVideoElement();
|
||||
if(vS) { vS.currentTime = data.currentTime; }
|
||||
if(vS) {
|
||||
vS.currentTime = data.currentTime;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sync_requested':
|
||||
if (isHost) sendSync();
|
||||
if (isHost) {
|
||||
console.log('Sync requested, sending current state...');
|
||||
sendSync();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -853,7 +939,6 @@ const RoomsApp = (function() {
|
||||
const currentUser = room.users.find(u => u.id === currentUserId);
|
||||
isHost = currentUser?.isHost || false;
|
||||
|
||||
// Mostrar controles solo si es Host
|
||||
if (elements.selectAnimeBtn) elements.selectAnimeBtn.style.display = isHost ? 'flex' : 'none';
|
||||
if (elements.hostControls) elements.hostControls.style.display = isHost ? 'flex' : 'none';
|
||||
|
||||
@@ -861,7 +946,49 @@ const RoomsApp = (function() {
|
||||
window.AnimePlayer.setRoomHost(isHost);
|
||||
}
|
||||
|
||||
// Si somos host y tenemos metadatos, poblar los controles del header
|
||||
const copyInviteBtn = document.getElementById('copy-invite-btn');
|
||||
|
||||
if (copyInviteBtn) {
|
||||
let inviteUrl = null;
|
||||
|
||||
if (window.__roomExposed && window.__roomPublicUrl) {
|
||||
inviteUrl = window.__roomPublicUrl;
|
||||
} else {
|
||||
inviteUrl = `${window.location.origin}/room?id=${room.id}`;
|
||||
}
|
||||
|
||||
console.log('Copy button configured with URL:', inviteUrl);
|
||||
|
||||
copyInviteBtn.style.display = 'inline-flex';
|
||||
copyInviteBtn.title = window.__roomExposed
|
||||
? 'Copy public invite link (works outside your network)'
|
||||
: 'Copy local invite link (only works on your network)';
|
||||
|
||||
copyInviteBtn.onclick = async () => {
|
||||
try {
|
||||
console.log('Copying to clipboard:', inviteUrl);
|
||||
await navigator.clipboard.writeText(inviteUrl);
|
||||
|
||||
const originalHTML = copyInviteBtn.innerHTML;
|
||||
copyInviteBtn.innerHTML = `
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
`;
|
||||
copyInviteBtn.style.color = '#4ade80';
|
||||
|
||||
setTimeout(() => {
|
||||
copyInviteBtn.innerHTML = originalHTML;
|
||||
copyInviteBtn.style.color = '';
|
||||
}, 2000);
|
||||
|
||||
showCopyToast(window.__roomExposed ? 'Public link copied!' : 'Local link copied!');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (isHost && room.metadata) {
|
||||
if(!selectedAnimeData) selectedAnimeData = { ...room.metadata, source: 'anilist' };
|
||||
populateQuickControls();
|
||||
@@ -871,18 +998,44 @@ const RoomsApp = (function() {
|
||||
if (room.currentVideo) loadVideo(room.currentVideo);
|
||||
}
|
||||
|
||||
function showCopyToast(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'copy-toast';
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(74, 222, 128, 0.95);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
animation: slideUp 0.3s ease-out;
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'fadeOut 0.3s ease-out';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function updateHeaderInfo(meta) {
|
||||
if (!meta) return;
|
||||
if (elements.npTitle) elements.npTitle.textContent = meta.title;
|
||||
if (elements.npEpisode) elements.npEpisode.textContent = `Episode ${meta.episode}`;
|
||||
if (elements.npInfo) elements.npInfo.style.opacity = '1';
|
||||
|
||||
// Save data locally so we can use quick controls
|
||||
if(!selectedAnimeData) selectedAnimeData = { ...meta, source: 'anilist' };
|
||||
else {
|
||||
selectedAnimeData.id = meta.id;
|
||||
selectedAnimeData.title = meta.title;
|
||||
// Episode is tracked visually in header
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,7 +1054,6 @@ const RoomsApp = (function() {
|
||||
if(!token) document.getElementById('guest-name-input').focus();
|
||||
} else {
|
||||
alert(message);
|
||||
window.location.href = '/anime';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,13 +1063,11 @@ const RoomsApp = (function() {
|
||||
const password = document.getElementById('join-password-input').value.trim();
|
||||
const passwordGroup = document.getElementById('password-group');
|
||||
|
||||
// Validar Nombre para Guest
|
||||
if (!guestName && !localStorage.getItem('token')) {
|
||||
alert("Please enter a name");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar Password si es requerida y está visible
|
||||
if (passwordGroup.style.display !== 'none' && !password) {
|
||||
alert("This room requires a password");
|
||||
return;
|
||||
@@ -926,17 +1076,13 @@ const RoomsApp = (function() {
|
||||
connectToRoom(currentRoomId, guestName, password);
|
||||
}
|
||||
|
||||
// room.js - Reemplazar toggleChat
|
||||
|
||||
function toggleChat() {
|
||||
if (elements.roomLayout) {
|
||||
elements.roomLayout.classList.toggle('chat-hidden');
|
||||
|
||||
// Si acabamos de ABRIR el chat (ya no tiene la clase chat-hidden)
|
||||
if (!elements.roomLayout.classList.contains('chat-hidden')) {
|
||||
// Quitar notificación roja
|
||||
elements.toggleChatBtn.classList.remove('has-unread');
|
||||
// Opcional: Limpiar los toasts flotantes para que no estorben
|
||||
if(elements.toastContainer) elements.toastContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
@@ -947,12 +1093,10 @@ const RoomsApp = (function() {
|
||||
function showChatToast(data) {
|
||||
if (!elements.toastContainer) return;
|
||||
|
||||
// Crear elemento
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'video-toast';
|
||||
|
||||
// Avatar (usar el mismo fallback que el chat)
|
||||
const avatarSrc = data.avatar || '/public/assets/placeholder.png'; // Asegúrate de tener un placeholder o lógica de iniciales
|
||||
const avatarSrc = data.avatar || '/public/assets/placeholder.png';
|
||||
|
||||
toast.innerHTML = `
|
||||
<img src="${avatarSrc}" class="toast-avatar" onerror="this.style.display='none'">
|
||||
@@ -962,17 +1106,14 @@ const RoomsApp = (function() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Añadir al contenedor
|
||||
elements.toastContainer.appendChild(toast);
|
||||
|
||||
// Eliminar del DOM después de que termine la animación (5s total: 0.3s in + 4.2s wait + 0.5s out)
|
||||
setTimeout(() => {
|
||||
if (toast.parentElement) {
|
||||
toast.remove();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Limitar número de toasts (máximo 3 al mismo tiempo para no tapar todo el video)
|
||||
while (elements.toastContainer.children.length > 3) {
|
||||
elements.toastContainer.removeChild(elements.toastContainer.firstChild);
|
||||
}
|
||||
@@ -998,14 +1139,12 @@ const RoomsApp = (function() {
|
||||
}
|
||||
|
||||
function addSystemMessage(text) {
|
||||
// 1. Agregar al chat (siempre)
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-message system';
|
||||
div.innerHTML = `<div class="message-content">${escapeHtml(text)}</div>`;
|
||||
elements.chatMessages.appendChild(div);
|
||||
elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight;
|
||||
|
||||
// 2. Si el chat está oculto, mostrar notificación flotante
|
||||
if (elements.roomLayout && elements.roomLayout.classList.contains('chat-hidden')) {
|
||||
showSystemToast(text);
|
||||
}
|
||||
@@ -1015,12 +1154,11 @@ const RoomsApp = (function() {
|
||||
if (!elements.toastContainer) return;
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'video-toast system-toast'; // Clase especial para diferenciarlo
|
||||
toast.className = 'video-toast system-toast';
|
||||
toast.innerHTML = `<span class="toast-msg">${escapeHtml(text)}</span>`;
|
||||
|
||||
elements.toastContainer.appendChild(toast);
|
||||
|
||||
// Eliminar a los 4 segundos
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
}
|
||||
|
||||
@@ -1029,13 +1167,10 @@ const RoomsApp = (function() {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-message';
|
||||
|
||||
// LÓGICA DE AVATAR ACTUALIZADA
|
||||
let avatarHtml;
|
||||
if (data.avatar) {
|
||||
// Si tiene imagen, usamos img tag
|
||||
avatarHtml = `<img src="${data.avatar}" alt="${data.username}" style="width:100%; height:100%; object-fit:cover; border-radius:50%;">`;
|
||||
} else {
|
||||
// Fallback a inicial
|
||||
avatarHtml = data.username[0].toUpperCase();
|
||||
}
|
||||
|
||||
@@ -1093,7 +1228,6 @@ const RoomsApp = (function() {
|
||||
return document.getElementById('player');
|
||||
}
|
||||
|
||||
// Fallback simple video loader
|
||||
function loadVideoBasic(url, type, videoData) {
|
||||
elements.loading.style.display = 'none';
|
||||
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
|
||||
@@ -1113,13 +1247,39 @@ const RoomsApp = (function() {
|
||||
|
||||
function syncVideo(currentTime, isPlaying) {
|
||||
const video = getVideoElement();
|
||||
if (!video) return;
|
||||
if (!video) {
|
||||
console.warn('Cannot sync: video element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const timeDiff = Math.abs(video.currentTime - currentTime);
|
||||
if (timeDiff > 1) video.currentTime = currentTime;
|
||||
|
||||
console.log('Syncing video:', {
|
||||
targetTime: currentTime,
|
||||
currentTime: video.currentTime,
|
||||
diff: timeDiff.toFixed(2) + 's',
|
||||
targetState: isPlaying ? 'playing' : 'paused',
|
||||
currentState: video.paused ? 'paused' : 'playing'
|
||||
});
|
||||
|
||||
if (timeDiff > 0.5) {
|
||||
console.log('Time diff exceeds threshold, seeking to:', currentTime);
|
||||
video.currentTime = currentTime;
|
||||
}
|
||||
|
||||
if (isPlaying && video.paused) {
|
||||
video.play().then(() => updatePlayPauseButton(true)).catch(console.error);
|
||||
console.log('Starting playback...');
|
||||
video.play()
|
||||
.then(() => {
|
||||
console.log('✓ Playback started');
|
||||
updatePlayPauseButton(true);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('✗ Playback failed:', err);
|
||||
showPlaybackBlockedToast();
|
||||
});
|
||||
} else if (!isPlaying && !video.paused) {
|
||||
console.log('Pausing playback...');
|
||||
video.pause();
|
||||
updatePlayPauseButton(false);
|
||||
}
|
||||
@@ -1127,8 +1287,62 @@ const RoomsApp = (function() {
|
||||
|
||||
function sendSync() {
|
||||
const video = getVideoElement();
|
||||
if (!video || !ws) return;
|
||||
ws.send(JSON.stringify({ type: 'sync', currentTime: video.currentTime, isPlaying: !video.paused }));
|
||||
if (!video) {
|
||||
console.warn('Cannot send sync: video element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('Cannot send sync: WebSocket not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const syncData = {
|
||||
type: 'sync',
|
||||
currentTime: video.currentTime,
|
||||
isPlaying: !video.paused
|
||||
};
|
||||
|
||||
console.log('Sending sync:', syncData);
|
||||
ws.send(JSON.stringify(syncData));
|
||||
}
|
||||
|
||||
function showPlaybackBlockedToast() {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'playback-blocked-toast';
|
||||
toast.innerHTML = `
|
||||
<div style="display:flex; flex-direction:column; gap:8px; align-items:center;">
|
||||
<span>⚠️ Autoplay blocked by browser</span>
|
||||
<button onclick="this.parentElement.parentElement.remove(); getVideoElement()?.play();"
|
||||
style="background:white; color:#1a1a2e; border:none; padding:6px 12px; border-radius:6px; cursor:pointer; font-weight:600;">
|
||||
Click to Play
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(251, 191, 36, 0.95);
|
||||
color: white;
|
||||
padding: 20px 30px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
animation: scaleIn 0.3s ease-out;
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
if (toast.parentElement) {
|
||||
toast.style.animation = 'fadeOut 0.3s ease-out';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function updatePlayPauseButton(isPlaying) {
|
||||
@@ -1158,7 +1372,6 @@ const RoomsApp = (function() {
|
||||
function leaveRoom() {
|
||||
if (ws) ws.close();
|
||||
if (hlsInstance) hlsInstance.destroy();
|
||||
window.location.href = '/anime';
|
||||
}
|
||||
|
||||
function openAnimeSearchModal() {
|
||||
|
||||
@@ -14,7 +14,8 @@ const DEFAULT_CONFIG = {
|
||||
},
|
||||
paths: {
|
||||
mpv: null,
|
||||
ffmpeg: null
|
||||
ffmpeg: null,
|
||||
cloudflared: null,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,7 +27,8 @@ export const CONFIG_SCHEMA = {
|
||||
},
|
||||
paths: {
|
||||
mpv: { description: "Required to open anime episodes in mpv on desktop version." },
|
||||
ffmpeg: { description: "Required for downloading anime episodes." }
|
||||
ffmpeg: { description: "Required for downloading anime episodes." },
|
||||
cloudflared: { description: "Required for creating pubic rooms." }
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/* =========================================
|
||||
1. VARIABLES & UTILITIES
|
||||
========================================= */
|
||||
:root {
|
||||
--brand-color: #8b5cf6;
|
||||
--brand-gradient: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);
|
||||
@@ -11,7 +8,6 @@
|
||||
--text-muted: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Scrollbar Styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -38,11 +34,6 @@
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* =========================================
|
||||
2. UI COMPONENTS (Buttons, Inputs, Chips)
|
||||
========================================= */
|
||||
|
||||
/* Glass Buttons & Icons */
|
||||
.btn-icon-glass, .btn-icon-small, .modal-close {
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
@@ -91,7 +82,6 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Primary/Confirm Buttons */
|
||||
.btn-confirm, .btn-primary {
|
||||
background: var(--brand-color);
|
||||
border: none;
|
||||
@@ -126,7 +116,6 @@
|
||||
}
|
||||
.btn-cancel:hover { background: rgba(255, 255, 255, 0.1); color: white; }
|
||||
|
||||
/* Inputs & Selects */
|
||||
input[type="text"], input[type="password"], input[type="number"], .form-input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
@@ -144,16 +133,15 @@ input:focus, .form-input:focus {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Glass Select (Header Style - FIX: Better alignment) */
|
||||
.glass-select-sm {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #eee;
|
||||
padding: 0 32px 0 12px; /* Extra padding right for arrow */
|
||||
padding: 0 32px 0 12px;
|
||||
height: 32px;
|
||||
line-height: 30px; /* Vertically center text */
|
||||
line-height: 30px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
@@ -164,7 +152,6 @@ input:focus, .form-input:focus {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
/* SVG Arrow */
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.6)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
@@ -178,7 +165,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
.glass-select-sm option { background: #1a1a1a; color: #e0e0e0; }
|
||||
|
||||
/* Chips (Config Modal) */
|
||||
.chips-grid { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.chip {
|
||||
padding: 8px 16px;
|
||||
@@ -200,9 +186,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
.chip.disabled { opacity: 0.5; pointer-events: none; filter: grayscale(1); }
|
||||
|
||||
/* =========================================
|
||||
3. ROOM LAYOUT (The Watch Page)
|
||||
========================================= */
|
||||
.room-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
@@ -225,7 +208,6 @@ input:focus, .form-input:focus {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Responsive Layout */
|
||||
@media (max-width: 1200px) {
|
||||
.room-layout {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -241,9 +223,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
4. ROOM HEADER
|
||||
========================================= */
|
||||
.room-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -265,7 +244,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
.header-right { justify-content: flex-end; }
|
||||
|
||||
/* Info Section */
|
||||
.room-info { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; }
|
||||
#room-name {
|
||||
margin: 0;
|
||||
@@ -286,7 +264,6 @@ input:focus, .form-input:focus {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Host Controls (Center) - FIX: Better alignment container */
|
||||
.header-center { flex: 2; display: flex; justify-content: center; z-index: 50; }
|
||||
|
||||
.quick-controls-group {
|
||||
@@ -295,16 +272,15 @@ input:focus, .form-input:focus {
|
||||
gap: 8px;
|
||||
background: rgba(20, 20, 20, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 4px 8px; /* Slightly tighter padding */
|
||||
padding: 4px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
transition: all 0.2s ease;
|
||||
height: 42px; /* Explicit height to align children */
|
||||
height: 42px;
|
||||
}
|
||||
.quick-controls-group:hover { border-color: rgba(255,255,255,0.15); background: rgba(30, 30, 30, 0.7); }
|
||||
|
||||
/* Toggle Sub/Dub Mini */
|
||||
.sd-toggle.small {
|
||||
height: 32px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
@@ -340,7 +316,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
.sd-toggle.small .sd-option.active { color: #fff; }
|
||||
|
||||
/* Viewers Pill */
|
||||
.viewers-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -354,9 +329,6 @@ input:focus, .form-input:focus {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
5. VIDEO PLAYER AREA
|
||||
========================================= */
|
||||
.player-wrapper {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
@@ -394,7 +366,6 @@ input:focus, .form-input:focus {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* Custom Controls Layout Fixes */
|
||||
.custom-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -405,7 +376,6 @@ input:focus, .form-input:focus {
|
||||
padding: 20px 10px 10px;
|
||||
}
|
||||
|
||||
/* FIX: Ensure left controls stay in one line (time display fix) */
|
||||
.controls-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -413,14 +383,13 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
|
||||
.time-display {
|
||||
white-space: nowrap; /* Prevent line break */
|
||||
font-variant-numeric: tabular-nums; /* Monospaced numbers prevent jitter */
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9rem;
|
||||
color: #ddd;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
/* Subtitles Canvas */
|
||||
#subtitles-canvas {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
@@ -429,7 +398,6 @@ input:focus, .form-input:focus {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Hide unused player buttons in Room Mode */
|
||||
#download-btn,
|
||||
#manual-match-btn,
|
||||
#server-select,
|
||||
@@ -439,7 +407,6 @@ input:focus, .form-input:focus {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Settings Panel Position Fix */
|
||||
.settings-panel {
|
||||
position: absolute;
|
||||
bottom: 70px;
|
||||
@@ -452,15 +419,13 @@ input:focus, .form-input:focus {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
6. CHAT SIDEBAR
|
||||
========================================= */
|
||||
.chat-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(15, 15, 15, 0.95);
|
||||
border-left: 1px solid var(--glass-border);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.room-layout.chat-hidden .chat-sidebar {
|
||||
opacity: 0;
|
||||
@@ -477,7 +442,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
.chat-header h3 { margin: 0; font-size: 1.1rem; font-weight: 700; color: white; }
|
||||
|
||||
/* User List */
|
||||
.users-list {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
@@ -516,7 +480,6 @@ input:focus, .form-input:focus {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -524,6 +487,7 @@ input:focus, .form-input:focus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
.chat-message { display: flex; gap: 10px; }
|
||||
.chat-message.system { justify-content: center; margin: 8px 0; }
|
||||
@@ -558,7 +522,6 @@ input:focus, .form-input:focus {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Input Area */
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -585,9 +548,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
.chat-input button:hover { background: #7c3aed; }
|
||||
|
||||
/* =========================================
|
||||
7. MODALS & CONFIGURATION
|
||||
========================================= */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
@@ -617,12 +577,10 @@ input:focus, .form-input:focus {
|
||||
.modal-header-row { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
|
||||
.modal-header-row .modal-title { margin: 0; }
|
||||
|
||||
/* Forms inside modal */
|
||||
.form-group { margin-bottom: 20px; }
|
||||
.form-group label { display: block; margin-bottom: 8px; font-weight: 600; color: white; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
|
||||
|
||||
/* Search Step */
|
||||
.anime-search-content { max-width: 800px; max-height: 85vh; display: flex; flex-direction: column; }
|
||||
.search-bar { display: flex; gap: 12px; margin-bottom: 20px; }
|
||||
.search-bar button {
|
||||
@@ -653,7 +611,6 @@ input:focus, .form-input:focus {
|
||||
.search-title { font-weight: 700; color: white; margin-bottom: 4px; font-size: 1rem; }
|
||||
.search-meta { font-size: 0.85rem; color: var(--text-muted); }
|
||||
|
||||
/* Config Step (Unified UI) */
|
||||
.config-layout { display: flex; gap: 24px; margin-top: 20px; }
|
||||
.config-sidebar { width: 140px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
.config-cover {
|
||||
@@ -668,7 +625,6 @@ input:focus, .form-input:focus {
|
||||
margin-bottom: 8px; font-weight: 700;
|
||||
}
|
||||
|
||||
/* Episode Stepper */
|
||||
.ep-control {
|
||||
display: flex; align-items: center;
|
||||
background: rgba(255,255,255,0.05);
|
||||
@@ -689,7 +645,6 @@ input:focus, .form-input:focus {
|
||||
font-weight: 700; outline: none;
|
||||
}
|
||||
|
||||
/* Category Toggle */
|
||||
.cat-toggle {
|
||||
display: flex; background: rgba(0,0,0,0.3);
|
||||
padding: 4px; border-radius: 10px; width: fit-content;
|
||||
@@ -714,9 +669,6 @@ input:focus, .form-input:focus {
|
||||
.ep-control { width: auto; flex: 1; }
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
8. ROOM LIST / LOBBY (If used externally)
|
||||
========================================= */
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 80px 40px 40px; }
|
||||
.header h1 {
|
||||
font-size: 2.5rem; font-weight: 800;
|
||||
@@ -772,7 +724,7 @@ input:focus, .form-input:focus {
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #1a1a1a; /* Borde oscuro para separar del gradiente */
|
||||
border: 3px solid #1a1a1a;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
@@ -796,13 +748,13 @@ input:focus, .form-input:focus {
|
||||
|
||||
.video-toast-container {
|
||||
position: absolute;
|
||||
bottom: 100px; /* Encima de la barra de controles */
|
||||
bottom: 100px;
|
||||
left: 20px;
|
||||
z-index: 80; /* Por encima del video, debajo de los controles */
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none; /* Permitir clicks a través de ellos */
|
||||
pointer-events: none;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
@@ -819,9 +771,8 @@ input:focus, .form-input:focus {
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
|
||||
/* Animación de entrada y salida */
|
||||
animation: toastSlideIn 0.3s ease forwards, toastFadeOut 0.5s ease 4.5s forwards;
|
||||
pointer-events: auto; /* Permitir seleccionar texto si se quiere */
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -850,7 +801,6 @@ input:focus, .form-input:focus {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* --- BADGE DE NOTIFICACIÓN (Punto Rojo) --- */
|
||||
#toggle-chat-btn {
|
||||
position: relative;
|
||||
}
|
||||
@@ -862,7 +812,7 @@ input:focus, .form-input:focus {
|
||||
right: 2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: #ef4444; /* Rojo */
|
||||
background-color: #ef4444;
|
||||
border: 2px solid #1a1a1a;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
@@ -885,11 +835,11 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
|
||||
.video-toast.system-toast {
|
||||
border-left-color: #9ca3af; /* Borde gris */
|
||||
background: rgba(20, 20, 20, 0.7); /* Un poco más transparente */
|
||||
border-left-color: #9ca3af;
|
||||
background: rgba(20, 20, 20, 0.7);
|
||||
justify-content: center;
|
||||
padding: 6px 12px;
|
||||
min-height: auto; /* Más compacto */
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.video-toast.system-toast .toast-msg {
|
||||
@@ -897,4 +847,78 @@ input:focus, .form-input:focus {
|
||||
font-style: italic;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#copy-invite-btn {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#copy-invite-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
#copy-invite-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="video-area">
|
||||
<div class="room-header">
|
||||
<div class="header-left">
|
||||
<button id="leave-room-btn" class="btn-icon-glass" title="Leave">
|
||||
<button id="leave-room-btn" class="btn-icon-glass" title="Leave" style="visibility: hidden;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<div class="room-info">
|
||||
@@ -61,6 +61,17 @@
|
||||
<select id="room-server-select" class="glass-select-sm" title="Server">
|
||||
<option value="" disabled selected>Server</option>
|
||||
</select>
|
||||
<button
|
||||
id="copy-invite-btn"
|
||||
class="btn-icon-glass"
|
||||
title="Copy invite link"
|
||||
style="display:none;"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.07 0l1.41-1.41a5 5 0 0 0-7.07-7.07L10 5"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.07 0L5.5 12.41a5 5 0 0 0 7.07 7.07L14 19"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,6 +41,76 @@ fastify.addHook("preHandler", async (request) => {
|
||||
}
|
||||
});
|
||||
|
||||
const roomService = require('./dist/api/rooms/rooms.service');
|
||||
|
||||
fastify.addHook('onRequest', async (req, reply) => {
|
||||
const isTunnel =
|
||||
!!req.headers['cf-connecting-ip'] ||
|
||||
!!req.headers['cf-ray'];
|
||||
|
||||
if (!isTunnel) return;
|
||||
|
||||
if (req.url.startsWith('/public/') ||
|
||||
req.url.startsWith('/views/') ||
|
||||
req.url.startsWith('/src/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url.startsWith('/room')) {
|
||||
const urlParams = new URLSearchParams(req.url.split('?')[1]);
|
||||
const roomId = urlParams.get('id');
|
||||
|
||||
if (!roomId) {
|
||||
return reply.code(404).send({ error: 'Room ID required' });
|
||||
}
|
||||
|
||||
const room = roomService.getRoom(roomId);
|
||||
if (!room || room.exposed !== true) {
|
||||
return reply.code(404).send({ error: 'Room not found' });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const wsMatch = req.url.match(/^\/ws\/room\/([a-f0-9]+)/);
|
||||
if (wsMatch) {
|
||||
const roomId = wsMatch[1];
|
||||
const room = roomService.getRoom(roomId);
|
||||
|
||||
if (!room || room.exposed !== true) {
|
||||
return reply.code(404).send({ error: 'Room not found' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const apiMatch = req.url.match(/^\/api\/rooms\/([a-f0-9]+)/);
|
||||
if (apiMatch) {
|
||||
const roomId = apiMatch[1];
|
||||
const room = roomService.getRoom(roomId);
|
||||
|
||||
if (!room || room.exposed !== true) {
|
||||
return reply.code(404).send({ error: 'Room not found' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedEndpoints = [
|
||||
'/api/watch/stream',
|
||||
'/api/proxy',
|
||||
'/api/extensions',
|
||||
'/api/search'
|
||||
];
|
||||
|
||||
for (const endpoint of allowedEndpoints) {
|
||||
if (req.url.startsWith(endpoint)) {
|
||||
console.log('[Tunnel] ✓ Allowing utility endpoint:', endpoint);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return reply.code(404).send({ error: 'Not found' });
|
||||
});
|
||||
|
||||
fastify.register(require("@fastify/static"), {
|
||||
root: path.join(__dirname, "public"),
|
||||
prefix: "/public/",
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import * as roomService from './rooms.service';
|
||||
import { getUserById } from '../user/user.service';
|
||||
import { openTunnel } from "./tunnel.manager";
|
||||
|
||||
interface CreateRoomBody {
|
||||
name: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface JoinRoomBody {
|
||||
password?: string;
|
||||
guestName?: string;
|
||||
expose?: boolean;
|
||||
}
|
||||
|
||||
export async function createRoom(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const { name, password } = req.body as CreateRoomBody;
|
||||
const { name, password, expose } = req.body as CreateRoomBody;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
@@ -39,7 +36,23 @@ export async function createRoom(req: any, reply: FastifyReply) {
|
||||
userId
|
||||
};
|
||||
|
||||
const room = roomService.createRoom(name, host, password);
|
||||
let publicUrl: string | undefined;
|
||||
|
||||
if (expose) {
|
||||
publicUrl = await openTunnel();
|
||||
}
|
||||
|
||||
const room = roomService.createRoom(
|
||||
name,
|
||||
host,
|
||||
password,
|
||||
!!expose,
|
||||
publicUrl
|
||||
);
|
||||
|
||||
if (expose && publicUrl) {
|
||||
room.publicUrl = `${publicUrl}/room?id=${room.id}`;
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
@@ -47,7 +60,9 @@ export async function createRoom(req: any, reply: FastifyReply) {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
hasPassword: !!room.password,
|
||||
userCount: room.users.size
|
||||
userCount: room.users.size,
|
||||
exposed: room.exposed,
|
||||
publicUrl: room.publicUrl
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -104,7 +119,9 @@ export async function getRoom(req: FastifyRequest, reply: FastifyReply) {
|
||||
isGuest: u.isGuest
|
||||
})),
|
||||
hasPassword: !!room.password,
|
||||
currentVideo: room.currentVideo
|
||||
currentVideo: room.currentVideo,
|
||||
exposed: room.exposed,
|
||||
publicUrl: room.publicUrl
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
import { closeTunnelIfUnused } from "./tunnel.manager";
|
||||
|
||||
interface RoomUser {
|
||||
id: string;
|
||||
@@ -33,6 +34,8 @@ interface RoomData {
|
||||
} | null;
|
||||
password?: string;
|
||||
metadata?: RoomMetadata | null;
|
||||
exposed: boolean;
|
||||
publicUrl?: string;
|
||||
}
|
||||
|
||||
const rooms = new Map<string, RoomData>();
|
||||
@@ -41,7 +44,7 @@ export function generateRoomId(): string {
|
||||
return crypto.randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
export function createRoom(name: string, host: RoomUser, password?: string): RoomData {
|
||||
export function createRoom(name: string, host: RoomUser, password?: string, exposed = false, publicUrl?: string): RoomData {
|
||||
const roomId = generateRoomId();
|
||||
|
||||
const room: RoomData = {
|
||||
@@ -53,6 +56,8 @@ export function createRoom(name: string, host: RoomUser, password?: string): Roo
|
||||
currentVideo: null,
|
||||
password: password || undefined,
|
||||
metadata: null,
|
||||
exposed,
|
||||
publicUrl
|
||||
};
|
||||
|
||||
rooms.set(roomId, room);
|
||||
@@ -84,13 +89,14 @@ export function removeUserFromRoom(roomId: string, userId: string): boolean {
|
||||
|
||||
room.users.delete(userId);
|
||||
|
||||
// Si no quedan usuarios, eliminar la sala
|
||||
if (room.users.size === 0) {
|
||||
if (room.exposed) {
|
||||
closeTunnelIfUnused();
|
||||
}
|
||||
rooms.delete(roomId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si era el host, asignar nuevo host
|
||||
if (room.host.id === userId && room.users.size > 0) {
|
||||
const newHost = Array.from(room.users.values())[0];
|
||||
newHost.isHost = true;
|
||||
@@ -109,6 +115,13 @@ export function updateRoomVideo(roomId: string, videoData: any): boolean {
|
||||
}
|
||||
|
||||
export function deleteRoom(roomId: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
if (room.exposed) {
|
||||
closeTunnelIfUnused();
|
||||
}
|
||||
|
||||
return rooms.delete(roomId);
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,34 @@ function handleMessage(roomId: string, userId: string, data: any) {
|
||||
});
|
||||
break;
|
||||
|
||||
case 'request_sync':
|
||||
// Cualquier usuario puede pedir sync
|
||||
const host = clients.get(room.host.id);
|
||||
if (host && host.socket && host.socket.readyState === 1) {
|
||||
console.log(`[Sync Request] User ${userId} requested sync from host`);
|
||||
|
||||
host.socket.send(JSON.stringify({
|
||||
type: 'sync_requested',
|
||||
requestedBy: userId,
|
||||
username: room.users.get(userId)?.username
|
||||
}));
|
||||
} else {
|
||||
console.warn(`[Sync Request] Host not available for user ${userId}`);
|
||||
|
||||
if (room.currentVideo) {
|
||||
const client = clients.get(userId);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
console.log(`[Sync Request] Sending cached video state to ${userId}`);
|
||||
client.socket.send(JSON.stringify({
|
||||
type: 'sync',
|
||||
currentTime: room.currentVideo.currentTime || 0,
|
||||
isPlaying: room.currentVideo.isPlaying || false
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'video_update':
|
||||
if (room.host.id !== userId) return;
|
||||
|
||||
@@ -288,11 +316,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
|
||||
type: 'play',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
// Solo el host puede controlar la reproducción
|
||||
if (room.host.id !== userId) {
|
||||
console.warn('Non-host attempted pause:', userId);
|
||||
return;
|
||||
@@ -303,11 +330,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
|
||||
type: 'pause',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'seek':
|
||||
// Solo el host puede controlar la reproducción
|
||||
if (room.host.id !== userId) {
|
||||
console.warn('Non-host attempted seek:', userId);
|
||||
return;
|
||||
@@ -318,22 +344,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
|
||||
type: 'seek',
|
||||
currentTime: data.currentTime,
|
||||
timestamp: Date.now()
|
||||
}, userId); // IMPORTANTE: excludeUserId para no enviar al host
|
||||
break;
|
||||
|
||||
case 'request_sync':
|
||||
// Cualquier usuario puede pedir sync
|
||||
const host = clients.get(room.host.id);
|
||||
if (host && host.socket && host.socket.readyState === 1) {
|
||||
host.socket.send(JSON.stringify({
|
||||
type: 'sync_requested',
|
||||
requestedBy: userId
|
||||
}));
|
||||
}
|
||||
}, userId);
|
||||
break;
|
||||
|
||||
case 'request_state':
|
||||
// Enviar estado actual de la sala al usuario que lo solicita
|
||||
const client = clients.get(userId);
|
||||
if (client && client.socket && client.socket.readyState === 1) {
|
||||
const updatedRoom = roomService.getRoom(roomId);
|
||||
|
||||
111
docker/src/api/rooms/tunnel.manager.ts
Normal file
111
docker/src/api/rooms/tunnel.manager.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { getConfig as loadConfig } from '../../shared/config';
|
||||
const { values } = loadConfig();
|
||||
const CLOUDFLARED_PATH = values.paths?.cloudflared || 'cloudflared';
|
||||
|
||||
let tunnelProcess: ChildProcess | null = null;
|
||||
let exposedRooms = 0;
|
||||
let publicUrl: string | null = null;
|
||||
let tunnelPromise: Promise<string> | null = null;
|
||||
|
||||
export function openTunnel(): Promise<string> {
|
||||
if (tunnelProcess && publicUrl) {
|
||||
exposedRooms++;
|
||||
return Promise.resolve(publicUrl);
|
||||
}
|
||||
|
||||
if (tunnelPromise) {
|
||||
return tunnelPromise;
|
||||
}
|
||||
|
||||
tunnelPromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Timeout esperando URL del túnel (30s)"));
|
||||
}, 30000);
|
||||
|
||||
tunnelProcess = spawn(CLOUDFLARED_PATH, [
|
||||
"tunnel",
|
||||
"--url",
|
||||
"http://localhost:54322",
|
||||
"--no-autoupdate"
|
||||
]);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
tunnelPromise = null;
|
||||
};
|
||||
|
||||
let outputBuffer = "";
|
||||
|
||||
const processOutput = (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
outputBuffer += text;
|
||||
|
||||
const match = outputBuffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
||||
if (match && !publicUrl) {
|
||||
publicUrl = match[0];
|
||||
exposedRooms = 1;
|
||||
cleanup();
|
||||
resolve(publicUrl);
|
||||
}
|
||||
};
|
||||
|
||||
tunnelProcess.stdout?.on("data", (data) => {
|
||||
processOutput(data);
|
||||
});
|
||||
|
||||
tunnelProcess.stderr?.on("data", (data) => {
|
||||
processOutput(data);
|
||||
});
|
||||
|
||||
tunnelProcess.on("error", (error) => {
|
||||
console.error("[Cloudflared Process Error]", error);
|
||||
cleanup();
|
||||
tunnelProcess = null;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
tunnelProcess.on("exit", (code, signal) => {
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
|
||||
if (!publicUrl) {
|
||||
cleanup();
|
||||
reject(new Error(`Proceso cloudflared terminó antes de obtener URL (código: ${code})`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return tunnelPromise;
|
||||
}
|
||||
|
||||
export function closeTunnelIfUnused() {
|
||||
exposedRooms--;
|
||||
console.log(`[Tunnel Manager] Rooms expuestas: ${exposedRooms}`);
|
||||
|
||||
if (exposedRooms <= 0 && tunnelProcess) {
|
||||
console.log("[Tunnel Manager] Cerrando túnel...");
|
||||
tunnelProcess.kill();
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
tunnelPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTunnelUrl(): string | null {
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
export function forceTunnelClose() {
|
||||
if (tunnelProcess) {
|
||||
console.log("[Tunnel Manager] Forzando cierre del túnel...");
|
||||
tunnelProcess.kill();
|
||||
tunnelProcess = null;
|
||||
publicUrl = null;
|
||||
exposedRooms = 0;
|
||||
tunnelPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,13 @@ class CreateRoomModal {
|
||||
<label>Password (Optional)</label>
|
||||
<input type="password" class="cr-input" name="password" placeholder="Leave empty for public" maxlength="50" />
|
||||
</div>
|
||||
|
||||
<div class="cr-form-group cr-checkbox-group">
|
||||
<label class="cr-checkbox">
|
||||
<input type="checkbox" name="expose" />
|
||||
<span>Generate public link (via tunnel)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="cr-actions">
|
||||
<button type="button" class="cr-btn-cancel" id="cr-cancel">Cancel</button>
|
||||
@@ -87,6 +94,7 @@ class CreateRoomModal {
|
||||
btn.textContent = 'Creating...';
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const expose = formData.get('expose') === 'on';
|
||||
const name = formData.get('name').trim();
|
||||
const password = formData.get('password').trim();
|
||||
const token = localStorage.getItem('token');
|
||||
@@ -100,7 +108,8 @@ class CreateRoomModal {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
password: password || undefined
|
||||
password: password || undefined,
|
||||
expose
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ const RoomsApp = (function() {
|
||||
let configState = {
|
||||
extension: null,
|
||||
server: null,
|
||||
category: 'sub', // 'sub' o 'dub'
|
||||
category: 'sub',
|
||||
episode: 1
|
||||
};
|
||||
|
||||
let extensionsStore = {
|
||||
list: [],
|
||||
settings: {} // { anilist: {...}, gogo: {...} }
|
||||
settings: {}
|
||||
};
|
||||
|
||||
const elements = {
|
||||
@@ -27,7 +27,6 @@ const RoomsApp = (function() {
|
||||
roomView: document.getElementById('room-view'),
|
||||
roomName: document.getElementById('room-name'),
|
||||
roomViewers: document.getElementById('room-viewers'),
|
||||
leaveRoomBtn: document.getElementById('leave-room-btn'),
|
||||
selectAnimeBtn: document.getElementById('select-anime-btn'),
|
||||
toggleChatBtn: document.getElementById('toggle-chat-btn'),
|
||||
|
||||
@@ -59,7 +58,7 @@ const RoomsApp = (function() {
|
||||
chatMessages: document.getElementById('chat-messages'),
|
||||
chatForm: document.getElementById('chat-form'),
|
||||
chatInput: document.getElementById('chat-input'),
|
||||
roomLayout: document.getElementById('room-layout'), // Corregido: referencia al layout
|
||||
roomLayout: document.getElementById('room-layout'),
|
||||
|
||||
// Modals
|
||||
joinRoomModal: document.getElementById('join-room-modal'),
|
||||
@@ -113,7 +112,6 @@ const RoomsApp = (function() {
|
||||
setupEventListeners();
|
||||
await preloadExtensions();
|
||||
|
||||
// --- NUEVO: Obtener info de la sala primero ---
|
||||
try {
|
||||
const res = await fetch(`/api/rooms/${currentRoomId}`);
|
||||
if (!res.ok) throw new Error('Room not found');
|
||||
@@ -122,41 +120,39 @@ const RoomsApp = (function() {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Room not found or deleted");
|
||||
window.location.href = '/anime';
|
||||
}
|
||||
}
|
||||
|
||||
// --- NUEVO: Función para manejar la entrada lógica ---
|
||||
function handleInitialEntry(roomInfo) {
|
||||
const token = localStorage.getItem('token');
|
||||
const passwordGroup = document.getElementById('password-group');
|
||||
|
||||
// Configurar UI del Modal con datos del Host
|
||||
const hostInfoDiv = document.getElementById('join-host-info');
|
||||
const hostAvatar = document.getElementById('join-host-avatar');
|
||||
const hostText = document.getElementById('join-host-text');
|
||||
|
||||
if (hostInfoDiv && roomInfo.host) {
|
||||
hostInfoDiv.style.display = 'flex';
|
||||
// Usar avatar del host o un placeholder
|
||||
hostAvatar.src = roomInfo.host.avatar || '/public/assets/placeholder.png';
|
||||
hostText.innerHTML = `<span>${escapeHtml(roomInfo.host.username)}</span> invited you to watch`;
|
||||
}
|
||||
|
||||
// Configurar si pide contraseña
|
||||
if (passwordGroup) {
|
||||
// Si la sala tiene pass, mostramos el campo
|
||||
passwordGroup.style.display = roomInfo.hasPassword ? 'block' : 'none';
|
||||
// Marcar en un atributo dataset si es requerida para validación
|
||||
passwordGroup.dataset.required = roomInfo.hasPassword ? 'true' : 'false';
|
||||
}
|
||||
|
||||
window.__roomPublicUrl = roomInfo.publicUrl || null;
|
||||
window.__roomExposed = roomInfo.exposed || false;
|
||||
|
||||
console.log('Room info loaded:', {
|
||||
exposed: window.__roomExposed,
|
||||
publicUrl: window.__roomPublicUrl
|
||||
});
|
||||
|
||||
if (token) {
|
||||
// Si tiene token, intentamos conectar directamente.
|
||||
// Si hay pass y no somos el host/dueño, el socket fallará y pedirá pass luego.
|
||||
connectToRoom(currentRoomId);
|
||||
} else {
|
||||
// Es Guest: Mostrar modal directamente
|
||||
console.log('Guest user, showing modal...');
|
||||
if (elements.joinRoomModal) {
|
||||
elements.joinRoomModal.classList.add('show');
|
||||
@@ -186,39 +182,32 @@ const RoomsApp = (function() {
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Join Room Form
|
||||
const cancelJoinBtn = document.getElementById('cancel-join-btn');
|
||||
if (cancelJoinBtn) cancelJoinBtn.onclick = leaveRoom;
|
||||
if (elements.joinRoomForm) elements.joinRoomForm.onsubmit = submitJoinForm;
|
||||
|
||||
// Header Controls
|
||||
if (elements.selectAnimeBtn) elements.selectAnimeBtn.onclick = openAnimeSearchModal;
|
||||
if (elements.toggleChatBtn) elements.toggleChatBtn.onclick = toggleChat;
|
||||
if (elements.leaveRoomBtn) elements.leaveRoomBtn.onclick = leaveRoom;
|
||||
|
||||
// Host Quick Controls Listeners
|
||||
if (elements.roomExtSelect) elements.roomExtSelect.onchange = (e) => onQuickExtensionChange(e, false);
|
||||
if (elements.roomServerSelect) elements.roomServerSelect.onchange = onQuickServerChange;
|
||||
|
||||
// Sub/Dub Toggle Logic (Header)
|
||||
if (elements.roomSdToggle) {
|
||||
elements.roomSdToggle.onclick = () => {
|
||||
if (!isHost) return;
|
||||
const currentState = elements.roomSdToggle.getAttribute('data-state');
|
||||
const newState = currentState === 'sub' ? 'dub' : 'sub';
|
||||
|
||||
// Update UI visually immediately
|
||||
elements.roomSdToggle.setAttribute('data-state', newState);
|
||||
elements.roomSdToggle.querySelectorAll('.sd-option').forEach(opt => {
|
||||
opt.classList.toggle('active', opt.dataset.val === newState);
|
||||
});
|
||||
|
||||
// Trigger Stream Reload
|
||||
onQuickServerChange();
|
||||
};
|
||||
}
|
||||
|
||||
// Anime Search Modal
|
||||
const closeSearchBtn = document.getElementById('close-search-modal');
|
||||
const animeSearchBtn = document.getElementById('anime-search-btn');
|
||||
|
||||
@@ -234,7 +223,6 @@ const RoomsApp = (function() {
|
||||
};
|
||||
}
|
||||
|
||||
// Config Step (Modal)
|
||||
if (elements.backToSearchBtn) {
|
||||
elements.backToSearchBtn.onclick = () => {
|
||||
elements.stepConfig.style.display = 'none';
|
||||
@@ -244,13 +232,10 @@ const RoomsApp = (function() {
|
||||
if (elements.selExtension) elements.selExtension.onchange = handleModalExtensionChange;
|
||||
if (elements.btnLaunch) elements.btnLaunch.onclick = () => launchStream(true); // true = from modal
|
||||
|
||||
// Room & User List
|
||||
if (elements.toggleUsersBtn) elements.toggleUsersBtn.onclick = toggleUsersList;
|
||||
|
||||
// Chat
|
||||
if (elements.chatForm) elements.chatForm.onsubmit = sendMessage;
|
||||
|
||||
// Anime results delegation
|
||||
if (elements.animeResults) {
|
||||
elements.animeResults.addEventListener('click', handleAnimeResultClick);
|
||||
}
|
||||
@@ -271,11 +256,8 @@ const RoomsApp = (function() {
|
||||
elements.roomExtSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// 🔥 FORZAR valor real
|
||||
const extToUse = selectedAnimeData.source || extensionsStore.list[0];
|
||||
elements.roomExtSelect.value = extToUse;
|
||||
elements.roomExtSelect.value = selectedAnimeData.source || extensionsStore.list[0];
|
||||
|
||||
// 🔥 FORZAR carga de servers
|
||||
await onQuickExtensionChange(null, true);
|
||||
}
|
||||
|
||||
@@ -299,7 +281,6 @@ const RoomsApp = (function() {
|
||||
elements.roomServerSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// 🔥 FORZAR server seleccionado
|
||||
elements.roomServerSelect.value = servers[0];
|
||||
|
||||
handleDubUI(settings, 'header');
|
||||
@@ -348,8 +329,6 @@ const RoomsApp = (function() {
|
||||
launchStream(false);
|
||||
}
|
||||
|
||||
// --- MODAL LOGIC ---
|
||||
|
||||
function handleAnimeResultClick(e) {
|
||||
const itemLink = e.target.closest('.search-item, .anime-result-item, a[href*="/anime/"]');
|
||||
|
||||
@@ -363,7 +342,7 @@ const RoomsApp = (function() {
|
||||
const imgEl = itemLink.querySelector('.search-poster, img');
|
||||
|
||||
title = titleEl ? titleEl.textContent : (itemLink.textContent.trim() || 'Unknown');
|
||||
img = imgEl ? (imgEl.src || imgEl.dataset.src || '/public/assets/placeholder.png') : '/public/assets/placeholder.png';
|
||||
img = imgEl ? (imgEl.src || imgEl.dataset.src || '/public/assets/placeholder.svg') : '/public/assets/placeholder.svg';
|
||||
|
||||
const href = itemLink.getAttribute('href') || '';
|
||||
const hrefParts = href.split('/').filter(p => p);
|
||||
@@ -386,36 +365,28 @@ const RoomsApp = (function() {
|
||||
if (!selectedAnimeData) return;
|
||||
if (!extensionsReady) return;
|
||||
|
||||
// 1. Resetear UI básica
|
||||
elements.configTitle.textContent = selectedAnimeData.title;
|
||||
elements.configCover.src = selectedAnimeData.image;
|
||||
if(ui.configError) ui.configError.style.display = 'none';
|
||||
|
||||
// 2. Resetear Estado interno
|
||||
configState.episode = 1;
|
||||
configState.server = null;
|
||||
configState.category = 'sub'; // Reset a sub por defecto
|
||||
configState.extension = null; // Reset extensión
|
||||
configState.category = 'sub';
|
||||
configState.extension = null;
|
||||
|
||||
// 3. Resetear controles visuales
|
||||
if(ui.epInput) ui.epInput.value = 1;
|
||||
if(ui.launchBtn) ui.launchBtn.disabled = true;
|
||||
updateSDUI(); // Función visual para el toggle sub/dub
|
||||
updateSDUI();
|
||||
|
||||
// 4. Configurar listeners de botones +/- y toggle
|
||||
setupConfigListeners();
|
||||
|
||||
// 5. Renderizar los botones de extensiones
|
||||
renderExtensionChips();
|
||||
|
||||
// Mostrar pantalla
|
||||
elements.stepSearch.style.display = 'none';
|
||||
elements.stepConfig.style.display = 'block';
|
||||
}
|
||||
|
||||
// Configura los botones + / - y el toggle Sub/Dub
|
||||
function setupConfigListeners() {
|
||||
// Episode Stepper
|
||||
if(ui.epInc) ui.epInc.onclick = () => {
|
||||
ui.epInput.value = parseInt(ui.epInput.value || 0) + 1;
|
||||
configState.episode = ui.epInput.value;
|
||||
@@ -426,14 +397,12 @@ const RoomsApp = (function() {
|
||||
};
|
||||
if(ui.epInput) ui.epInput.onchange = (e) => configState.episode = e.target.value;
|
||||
|
||||
// Sub/Dub Toggle
|
||||
if(ui.sdToggle) {
|
||||
ui.sdToggle.querySelectorAll('.cat-opt').forEach(opt => {
|
||||
opt.onclick = () => {
|
||||
if(opt.classList.contains('disabled')) return;
|
||||
configState.category = opt.dataset.val;
|
||||
updateSDUI();
|
||||
// Al cambiar categoría, recargar servidores (quizás cambien los disponibles)
|
||||
if(configState.extension) loadServersForExtension(configState.extension);
|
||||
};
|
||||
});
|
||||
@@ -447,7 +416,6 @@ const RoomsApp = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Dibuja los botones de Extensiones
|
||||
function renderExtensionChips() {
|
||||
ui.extContainer.innerHTML = '';
|
||||
|
||||
@@ -461,30 +429,25 @@ const RoomsApp = (function() {
|
||||
chip.className = 'chip';
|
||||
chip.textContent = ext.charAt(0).toUpperCase() + ext.slice(1);
|
||||
|
||||
// Auto-seleccionar si ya estaba en el estado (o default a anilist)
|
||||
if (!configState.extension && ext === 'anilist') configState.extension = 'anilist';
|
||||
if (ext === configState.extension) chip.classList.add('active');
|
||||
|
||||
chip.onclick = () => {
|
||||
// Actualizar visual
|
||||
document.querySelectorAll('#ext-chips-container .chip').forEach(c => c.classList.remove('active'));
|
||||
chip.classList.add('active');
|
||||
|
||||
// Actualizar lógica
|
||||
configState.extension = ext;
|
||||
configState.server = null; // Reset servidor al cambiar extensión
|
||||
ui.launchBtn.disabled = true; // Deshabilitar Play hasta elegir server
|
||||
configState.server = null;
|
||||
ui.launchBtn.disabled = true;
|
||||
|
||||
loadServersForExtension(ext);
|
||||
};
|
||||
ui.extContainer.appendChild(chip);
|
||||
});
|
||||
|
||||
// Cargar servidores de la extensión inicial
|
||||
if(configState.extension) loadServersForExtension(configState.extension);
|
||||
}
|
||||
|
||||
// Carga los servidores de la API (Asíncrono)
|
||||
async function loadServersForExtension(extName) {
|
||||
if (!extensionsReady) return;
|
||||
ui.serverContainer.innerHTML = '<div class="grid-loader"><div class="spinner" style="width:20px;height:20px;"></div> Loading servers...</div>';
|
||||
@@ -500,7 +463,6 @@ const RoomsApp = (function() {
|
||||
|
||||
renderServerChips(servers);
|
||||
|
||||
// Manejar si la extensión soporta Dub
|
||||
const dubBtn = ui.sdToggle.querySelector('[data-val="dub"]');
|
||||
if (dubBtn) {
|
||||
if (!settings.supportsDub) {
|
||||
@@ -520,7 +482,6 @@ const RoomsApp = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
// Dibuja los botones de Servidores
|
||||
function renderServerChips(servers) {
|
||||
ui.serverContainer.innerHTML = '';
|
||||
|
||||
@@ -534,7 +495,6 @@ const RoomsApp = (function() {
|
||||
chip.classList.add('active');
|
||||
|
||||
configState.server = srv;
|
||||
// AHORA sí habilitamos el botón de Play
|
||||
ui.launchBtn.disabled = false;
|
||||
};
|
||||
|
||||
@@ -597,19 +557,16 @@ const RoomsApp = (function() {
|
||||
episode = configState.episode;
|
||||
category = configState.category;
|
||||
} else {
|
||||
// LÓGICA DEL HEADER (Corregida)
|
||||
ext = elements.roomExtSelect.value;
|
||||
server = elements.roomServerSelect.value;
|
||||
|
||||
// Intentar leer episodio del texto
|
||||
let epText = elements.npEpisode.textContent.replace('Ep ', '').trim();
|
||||
|
||||
// Fallback robusto: Si dice "--" o está vacío, usar los datos guardados o 1
|
||||
if (!epText || epText === '--' || isNaN(epText)) {
|
||||
if (selectedAnimeData.episode) {
|
||||
epText = selectedAnimeData.episode;
|
||||
} else {
|
||||
epText = "1"; // Default absoluto
|
||||
epText = "1";
|
||||
}
|
||||
}
|
||||
episode = epText;
|
||||
@@ -617,7 +574,6 @@ const RoomsApp = (function() {
|
||||
category = elements.roomSdToggle.getAttribute('data-state');
|
||||
}
|
||||
|
||||
// Validación
|
||||
if (!ext || !server || !episode) {
|
||||
console.error("Missing params:", { ext, server, episode });
|
||||
if(fromModal) {
|
||||
@@ -629,7 +585,6 @@ const RoomsApp = (function() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Feedback UI
|
||||
if(fromModal) {
|
||||
elements.btnLaunch.disabled = true;
|
||||
elements.btnLaunch.innerHTML = '<div class="spinner" style="width:20px;height:20px;"></div> Fetching...';
|
||||
@@ -645,7 +600,6 @@ const RoomsApp = (function() {
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Lógica de fuentes (igual que antes)
|
||||
const source = data.videoSources?.find(s => s.type === 'm3u8') || data.videoSources?.[0];
|
||||
if (!source) throw new Error('No video source found');
|
||||
|
||||
@@ -673,7 +627,7 @@ const RoomsApp = (function() {
|
||||
},
|
||||
metadata: {
|
||||
title: selectedAnimeData.title,
|
||||
episode: episode, // Usar el episodio corregido
|
||||
episode: episode,
|
||||
image: selectedAnimeData.image,
|
||||
id: selectedAnimeData.id
|
||||
}
|
||||
@@ -682,32 +636,21 @@ const RoomsApp = (function() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(videoPayload));
|
||||
|
||||
// Carga local
|
||||
loadVideo(videoPayload.video);
|
||||
updateHeaderInfo(videoPayload.metadata);
|
||||
|
||||
// Si venimos del Modal, sincronizamos los controles rápidos del header
|
||||
if(fromModal) {
|
||||
closeAnimeSearchModal();
|
||||
|
||||
// --- CORRECCIÓN INICIO ---
|
||||
|
||||
// 1. Actualizamos el source en la data global para que coincida con lo que acabas de elegir
|
||||
selectedAnimeData.source = ext;
|
||||
|
||||
// 2. Forzamos el repoblado del dropdown del header (ahora que tenemos anime y extensión)
|
||||
await populateQuickControls();
|
||||
|
||||
// --- CORRECCIÓN FIN ---
|
||||
|
||||
// Sincronizar UI del header
|
||||
if (extensionsStore.list.includes(ext)) {
|
||||
elements.roomExtSelect.value = ext;
|
||||
// Forzamos carga silenciosa para llenar los servidores en el select del header
|
||||
await onQuickExtensionChange(null, true);
|
||||
elements.roomServerSelect.value = server;
|
||||
|
||||
// Sincronizar toggle Dub/Sub
|
||||
elements.roomSdToggle.setAttribute('data-state', category);
|
||||
elements.roomSdToggle.querySelectorAll('.sd-option').forEach(o =>
|
||||
o.classList.toggle('active', o.dataset.val === category)
|
||||
@@ -737,48 +680,165 @@ const RoomsApp = (function() {
|
||||
|
||||
function connectToRoom(roomId, guestName, password) {
|
||||
const token = localStorage.getItem('token');
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/room/${roomId}`;
|
||||
|
||||
const isTunnel = window.location.hostname.includes('trycloudflare.com');
|
||||
|
||||
let wsUrl;
|
||||
|
||||
if (isTunnel) {
|
||||
wsUrl = `wss://${window.location.host}/ws/room/${roomId}`;
|
||||
console.log('[Tunnel Mode] Using secure WebSocket:', wsUrl);
|
||||
} else {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/ws/room/${roomId}`;
|
||||
console.log('[Local Mode] Using WebSocket:', wsUrl);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (token) params.append('token', token);
|
||||
if (guestName) params.append('guestName', guestName);
|
||||
if (password) params.append('password', password);
|
||||
|
||||
if (ws) ws.close();
|
||||
if (ws) {
|
||||
console.log('Closing existing WebSocket...');
|
||||
ws.close();
|
||||
}
|
||||
|
||||
console.log('Connecting to:', `${wsUrl}?${params.toString()}`);
|
||||
ws = new WebSocket(`${wsUrl}?${params.toString()}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket Connected');
|
||||
console.log('✓ WebSocket Connected');
|
||||
if (window.AnimePlayer && typeof window.AnimePlayer.setWebSocket === 'function') {
|
||||
window.AnimePlayer.setWebSocket(ws);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
console.log('Requesting sync from host...');
|
||||
ws.send(JSON.stringify({ type: 'request_sync' }));
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => handleWebSocketMessage(JSON.parse(e.data));
|
||||
ws.onerror = (e) => console.error('WebSocket error:', e);
|
||||
ws.onclose = () => {
|
||||
console.log('Disconnected');
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('✗ WebSocket error:', e);
|
||||
showConnectionError('WebSocket connection failed. Check your connection.');
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('WebSocket Disconnected:', event.code, event.reason);
|
||||
|
||||
if (window.AnimePlayer && typeof window.AnimePlayer.setWebSocket === 'function') {
|
||||
window.AnimePlayer.setWebSocket(null);
|
||||
}
|
||||
|
||||
if (event.code !== 1000 && event.code !== 1001) {
|
||||
console.log('Attempting reconnection in 3 seconds...');
|
||||
showReconnectingToast();
|
||||
setTimeout(() => {
|
||||
if (currentRoomId) {
|
||||
console.log('Reconnecting to room...');
|
||||
connectToRoom(currentRoomId, guestName, password);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function showReconnectingToast() {
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'reconnecting-toast';
|
||||
toast.className = 'connection-error-toast';
|
||||
toast.innerHTML = `
|
||||
<div style="display:flex; align-items:center; gap:10px;">
|
||||
<div class="spinner" style="width:16px; height:16px; border-width:2px;"></div>
|
||||
<span>Reconnecting...</span>
|
||||
</div>
|
||||
`;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(251, 191, 36, 0.95);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
animation: slideDown 0.3s ease-out;
|
||||
`;
|
||||
|
||||
const existing = document.getElementById('reconnecting-toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
function showConnectionError(message) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'connection-error-toast';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.cssText = `
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(239, 68, 68, 0.95);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
animation: slideDown 0.3s ease-out;
|
||||
`;
|
||||
|
||||
document.body.appendChild(errorDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
errorDiv.style.animation = 'fadeOut 0.3s ease-out';
|
||||
setTimeout(() => errorDiv.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function handleWebSocketMessage(data) {
|
||||
switch (data.type) {
|
||||
case 'error':
|
||||
handleConnectionError(data.message);
|
||||
break;
|
||||
|
||||
case 'init':
|
||||
const reconnectToast = document.getElementById('reconnecting-toast');
|
||||
if (reconnectToast) reconnectToast.remove();
|
||||
|
||||
elements.joinRoomModal.classList.remove('show');
|
||||
currentUserId = data.userId;
|
||||
currentUsername = data.username;
|
||||
isGuest = data.isGuest;
|
||||
updateRoomUI(data.room);
|
||||
if (data.room.currentVideo && data.room.metadata) {
|
||||
updateHeaderInfo(data.room.metadata);
|
||||
|
||||
if (data.room.currentVideo) {
|
||||
loadVideo(data.room.currentVideo);
|
||||
|
||||
if (data.room.metadata) {
|
||||
updateHeaderInfo(data.room.metadata);
|
||||
}
|
||||
|
||||
if (!isHost) {
|
||||
console.log('Video detected on join, requesting sync...');
|
||||
setTimeout(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'request_sync' }));
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -791,29 +851,27 @@ const RoomsApp = (function() {
|
||||
updateUsersList();
|
||||
|
||||
if (isHost) {
|
||||
sendSync();
|
||||
console.log('New user joined, sending sync...');
|
||||
setTimeout(() => sendSync(), 500);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user_left':
|
||||
addSystemMessage(`${data.user.username} left`);
|
||||
updateUsersList();
|
||||
break;
|
||||
|
||||
case 'chat':
|
||||
addChatMessage(data); // Siempre añadir al historial del chat lateral
|
||||
|
||||
// Comprobar si el chat está oculto
|
||||
addChatMessage(data);
|
||||
const isChatHidden = elements.roomLayout.classList.contains('chat-hidden');
|
||||
|
||||
if (isChatHidden) {
|
||||
// 1. Mostrar Toast sobre el video
|
||||
showChatToast(data);
|
||||
|
||||
// 2. Poner punto rojo en el botón
|
||||
if (elements.toggleChatBtn) {
|
||||
elements.toggleChatBtn.classList.add('has-unread');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'video_update':
|
||||
loadVideo(data.video);
|
||||
if (data.metadata) {
|
||||
@@ -823,25 +881,53 @@ const RoomsApp = (function() {
|
||||
};
|
||||
updateHeaderInfo(data.metadata);
|
||||
}
|
||||
|
||||
if (!isHost) {
|
||||
setTimeout(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
console.log('New video loaded, requesting sync...');
|
||||
ws.send(JSON.stringify({ type: 'request_sync' }));
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sync':
|
||||
console.log('Received sync:', data.currentTime, data.isPlaying ? 'playing' : 'paused');
|
||||
syncVideo(data.currentTime, data.isPlaying);
|
||||
updatePlayPauseButton(data.isPlaying);
|
||||
break;
|
||||
|
||||
case 'play':
|
||||
const vP = getVideoElement();
|
||||
if(vP) { vP.currentTime = data.currentTime; vP.play().catch(console.error); updatePlayPauseButton(true); }
|
||||
if(vP) {
|
||||
vP.currentTime = data.currentTime;
|
||||
vP.play().catch(console.error);
|
||||
updatePlayPauseButton(true);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
const vPa = getVideoElement();
|
||||
if(vPa) { vPa.currentTime = data.currentTime; vPa.pause(); updatePlayPauseButton(false); }
|
||||
if(vPa) {
|
||||
vPa.currentTime = data.currentTime;
|
||||
vPa.pause();
|
||||
updatePlayPauseButton(false);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'seek':
|
||||
const vS = getVideoElement();
|
||||
if(vS) { vS.currentTime = data.currentTime; }
|
||||
if(vS) {
|
||||
vS.currentTime = data.currentTime;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sync_requested':
|
||||
if (isHost) sendSync();
|
||||
if (isHost) {
|
||||
console.log('Sync requested, sending current state...');
|
||||
sendSync();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -853,7 +939,6 @@ const RoomsApp = (function() {
|
||||
const currentUser = room.users.find(u => u.id === currentUserId);
|
||||
isHost = currentUser?.isHost || false;
|
||||
|
||||
// Mostrar controles solo si es Host
|
||||
if (elements.selectAnimeBtn) elements.selectAnimeBtn.style.display = isHost ? 'flex' : 'none';
|
||||
if (elements.hostControls) elements.hostControls.style.display = isHost ? 'flex' : 'none';
|
||||
|
||||
@@ -861,7 +946,49 @@ const RoomsApp = (function() {
|
||||
window.AnimePlayer.setRoomHost(isHost);
|
||||
}
|
||||
|
||||
// Si somos host y tenemos metadatos, poblar los controles del header
|
||||
const copyInviteBtn = document.getElementById('copy-invite-btn');
|
||||
|
||||
if (copyInviteBtn) {
|
||||
let inviteUrl = null;
|
||||
|
||||
if (window.__roomExposed && window.__roomPublicUrl) {
|
||||
inviteUrl = window.__roomPublicUrl;
|
||||
} else {
|
||||
inviteUrl = `${window.location.origin}/room?id=${room.id}`;
|
||||
}
|
||||
|
||||
console.log('Copy button configured with URL:', inviteUrl);
|
||||
|
||||
copyInviteBtn.style.display = 'inline-flex';
|
||||
copyInviteBtn.title = window.__roomExposed
|
||||
? 'Copy public invite link (works outside your network)'
|
||||
: 'Copy local invite link (only works on your network)';
|
||||
|
||||
copyInviteBtn.onclick = async () => {
|
||||
try {
|
||||
console.log('Copying to clipboard:', inviteUrl);
|
||||
await navigator.clipboard.writeText(inviteUrl);
|
||||
|
||||
const originalHTML = copyInviteBtn.innerHTML;
|
||||
copyInviteBtn.innerHTML = `
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
`;
|
||||
copyInviteBtn.style.color = '#4ade80';
|
||||
|
||||
setTimeout(() => {
|
||||
copyInviteBtn.innerHTML = originalHTML;
|
||||
copyInviteBtn.style.color = '';
|
||||
}, 2000);
|
||||
|
||||
showCopyToast(window.__roomExposed ? 'Public link copied!' : 'Local link copied!');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (isHost && room.metadata) {
|
||||
if(!selectedAnimeData) selectedAnimeData = { ...room.metadata, source: 'anilist' };
|
||||
populateQuickControls();
|
||||
@@ -871,18 +998,44 @@ const RoomsApp = (function() {
|
||||
if (room.currentVideo) loadVideo(room.currentVideo);
|
||||
}
|
||||
|
||||
function showCopyToast(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'copy-toast';
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(74, 222, 128, 0.95);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
animation: slideUp 0.3s ease-out;
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'fadeOut 0.3s ease-out';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function updateHeaderInfo(meta) {
|
||||
if (!meta) return;
|
||||
if (elements.npTitle) elements.npTitle.textContent = meta.title;
|
||||
if (elements.npEpisode) elements.npEpisode.textContent = `Episode ${meta.episode}`;
|
||||
if (elements.npInfo) elements.npInfo.style.opacity = '1';
|
||||
|
||||
// Save data locally so we can use quick controls
|
||||
if(!selectedAnimeData) selectedAnimeData = { ...meta, source: 'anilist' };
|
||||
else {
|
||||
selectedAnimeData.id = meta.id;
|
||||
selectedAnimeData.title = meta.title;
|
||||
// Episode is tracked visually in header
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,7 +1054,6 @@ const RoomsApp = (function() {
|
||||
if(!token) document.getElementById('guest-name-input').focus();
|
||||
} else {
|
||||
alert(message);
|
||||
window.location.href = '/anime';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,13 +1063,11 @@ const RoomsApp = (function() {
|
||||
const password = document.getElementById('join-password-input').value.trim();
|
||||
const passwordGroup = document.getElementById('password-group');
|
||||
|
||||
// Validar Nombre para Guest
|
||||
if (!guestName && !localStorage.getItem('token')) {
|
||||
alert("Please enter a name");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar Password si es requerida y está visible
|
||||
if (passwordGroup.style.display !== 'none' && !password) {
|
||||
alert("This room requires a password");
|
||||
return;
|
||||
@@ -926,17 +1076,13 @@ const RoomsApp = (function() {
|
||||
connectToRoom(currentRoomId, guestName, password);
|
||||
}
|
||||
|
||||
// room.js - Reemplazar toggleChat
|
||||
|
||||
function toggleChat() {
|
||||
if (elements.roomLayout) {
|
||||
elements.roomLayout.classList.toggle('chat-hidden');
|
||||
|
||||
// Si acabamos de ABRIR el chat (ya no tiene la clase chat-hidden)
|
||||
if (!elements.roomLayout.classList.contains('chat-hidden')) {
|
||||
// Quitar notificación roja
|
||||
elements.toggleChatBtn.classList.remove('has-unread');
|
||||
// Opcional: Limpiar los toasts flotantes para que no estorben
|
||||
if(elements.toastContainer) elements.toastContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
@@ -947,12 +1093,10 @@ const RoomsApp = (function() {
|
||||
function showChatToast(data) {
|
||||
if (!elements.toastContainer) return;
|
||||
|
||||
// Crear elemento
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'video-toast';
|
||||
|
||||
// Avatar (usar el mismo fallback que el chat)
|
||||
const avatarSrc = data.avatar || '/public/assets/placeholder.png'; // Asegúrate de tener un placeholder o lógica de iniciales
|
||||
const avatarSrc = data.avatar || '/public/assets/placeholder.png';
|
||||
|
||||
toast.innerHTML = `
|
||||
<img src="${avatarSrc}" class="toast-avatar" onerror="this.style.display='none'">
|
||||
@@ -962,17 +1106,14 @@ const RoomsApp = (function() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Añadir al contenedor
|
||||
elements.toastContainer.appendChild(toast);
|
||||
|
||||
// Eliminar del DOM después de que termine la animación (5s total: 0.3s in + 4.2s wait + 0.5s out)
|
||||
setTimeout(() => {
|
||||
if (toast.parentElement) {
|
||||
toast.remove();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Limitar número de toasts (máximo 3 al mismo tiempo para no tapar todo el video)
|
||||
while (elements.toastContainer.children.length > 3) {
|
||||
elements.toastContainer.removeChild(elements.toastContainer.firstChild);
|
||||
}
|
||||
@@ -998,14 +1139,12 @@ const RoomsApp = (function() {
|
||||
}
|
||||
|
||||
function addSystemMessage(text) {
|
||||
// 1. Agregar al chat (siempre)
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-message system';
|
||||
div.innerHTML = `<div class="message-content">${escapeHtml(text)}</div>`;
|
||||
elements.chatMessages.appendChild(div);
|
||||
elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight;
|
||||
|
||||
// 2. Si el chat está oculto, mostrar notificación flotante
|
||||
if (elements.roomLayout && elements.roomLayout.classList.contains('chat-hidden')) {
|
||||
showSystemToast(text);
|
||||
}
|
||||
@@ -1015,12 +1154,11 @@ const RoomsApp = (function() {
|
||||
if (!elements.toastContainer) return;
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'video-toast system-toast'; // Clase especial para diferenciarlo
|
||||
toast.className = 'video-toast system-toast';
|
||||
toast.innerHTML = `<span class="toast-msg">${escapeHtml(text)}</span>`;
|
||||
|
||||
elements.toastContainer.appendChild(toast);
|
||||
|
||||
// Eliminar a los 4 segundos
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
}
|
||||
|
||||
@@ -1029,13 +1167,10 @@ const RoomsApp = (function() {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-message';
|
||||
|
||||
// LÓGICA DE AVATAR ACTUALIZADA
|
||||
let avatarHtml;
|
||||
if (data.avatar) {
|
||||
// Si tiene imagen, usamos img tag
|
||||
avatarHtml = `<img src="${data.avatar}" alt="${data.username}" style="width:100%; height:100%; object-fit:cover; border-radius:50%;">`;
|
||||
} else {
|
||||
// Fallback a inicial
|
||||
avatarHtml = data.username[0].toUpperCase();
|
||||
}
|
||||
|
||||
@@ -1093,7 +1228,6 @@ const RoomsApp = (function() {
|
||||
return document.getElementById('player');
|
||||
}
|
||||
|
||||
// Fallback simple video loader
|
||||
function loadVideoBasic(url, type, videoData) {
|
||||
elements.loading.style.display = 'none';
|
||||
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
|
||||
@@ -1113,13 +1247,39 @@ const RoomsApp = (function() {
|
||||
|
||||
function syncVideo(currentTime, isPlaying) {
|
||||
const video = getVideoElement();
|
||||
if (!video) return;
|
||||
if (!video) {
|
||||
console.warn('Cannot sync: video element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const timeDiff = Math.abs(video.currentTime - currentTime);
|
||||
if (timeDiff > 1) video.currentTime = currentTime;
|
||||
|
||||
console.log('Syncing video:', {
|
||||
targetTime: currentTime,
|
||||
currentTime: video.currentTime,
|
||||
diff: timeDiff.toFixed(2) + 's',
|
||||
targetState: isPlaying ? 'playing' : 'paused',
|
||||
currentState: video.paused ? 'paused' : 'playing'
|
||||
});
|
||||
|
||||
if (timeDiff > 0.5) {
|
||||
console.log('Time diff exceeds threshold, seeking to:', currentTime);
|
||||
video.currentTime = currentTime;
|
||||
}
|
||||
|
||||
if (isPlaying && video.paused) {
|
||||
video.play().then(() => updatePlayPauseButton(true)).catch(console.error);
|
||||
console.log('Starting playback...');
|
||||
video.play()
|
||||
.then(() => {
|
||||
console.log('✓ Playback started');
|
||||
updatePlayPauseButton(true);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('✗ Playback failed:', err);
|
||||
showPlaybackBlockedToast();
|
||||
});
|
||||
} else if (!isPlaying && !video.paused) {
|
||||
console.log('Pausing playback...');
|
||||
video.pause();
|
||||
updatePlayPauseButton(false);
|
||||
}
|
||||
@@ -1127,8 +1287,62 @@ const RoomsApp = (function() {
|
||||
|
||||
function sendSync() {
|
||||
const video = getVideoElement();
|
||||
if (!video || !ws) return;
|
||||
ws.send(JSON.stringify({ type: 'sync', currentTime: video.currentTime, isPlaying: !video.paused }));
|
||||
if (!video) {
|
||||
console.warn('Cannot send sync: video element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('Cannot send sync: WebSocket not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const syncData = {
|
||||
type: 'sync',
|
||||
currentTime: video.currentTime,
|
||||
isPlaying: !video.paused
|
||||
};
|
||||
|
||||
console.log('Sending sync:', syncData);
|
||||
ws.send(JSON.stringify(syncData));
|
||||
}
|
||||
|
||||
function showPlaybackBlockedToast() {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'playback-blocked-toast';
|
||||
toast.innerHTML = `
|
||||
<div style="display:flex; flex-direction:column; gap:8px; align-items:center;">
|
||||
<span>⚠️ Autoplay blocked by browser</span>
|
||||
<button onclick="this.parentElement.parentElement.remove(); getVideoElement()?.play();"
|
||||
style="background:white; color:#1a1a2e; border:none; padding:6px 12px; border-radius:6px; cursor:pointer; font-weight:600;">
|
||||
Click to Play
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(251, 191, 36, 0.95);
|
||||
color: white;
|
||||
padding: 20px 30px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
animation: scaleIn 0.3s ease-out;
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
if (toast.parentElement) {
|
||||
toast.style.animation = 'fadeOut 0.3s ease-out';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function updatePlayPauseButton(isPlaying) {
|
||||
@@ -1158,7 +1372,6 @@ const RoomsApp = (function() {
|
||||
function leaveRoom() {
|
||||
if (ws) ws.close();
|
||||
if (hlsInstance) hlsInstance.destroy();
|
||||
window.location.href = '/anime';
|
||||
}
|
||||
|
||||
function openAnimeSearchModal() {
|
||||
|
||||
@@ -14,7 +14,8 @@ const DEFAULT_CONFIG = {
|
||||
},
|
||||
paths: {
|
||||
mpv: null,
|
||||
ffmpeg: null
|
||||
ffmpeg: null,
|
||||
cloudflared: null,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,7 +27,8 @@ export const CONFIG_SCHEMA = {
|
||||
},
|
||||
paths: {
|
||||
mpv: { description: "Required to open anime episodes in mpv on desktop version." },
|
||||
ffmpeg: { description: "Required for downloading anime episodes." }
|
||||
ffmpeg: { description: "Required for downloading anime episodes." },
|
||||
cloudflared: { description: "Required for creating pubic rooms." }
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/* =========================================
|
||||
1. VARIABLES & UTILITIES
|
||||
========================================= */
|
||||
:root {
|
||||
--brand-color: #8b5cf6;
|
||||
--brand-gradient: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);
|
||||
@@ -11,7 +8,6 @@
|
||||
--text-muted: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Scrollbar Styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -38,11 +34,6 @@
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* =========================================
|
||||
2. UI COMPONENTS (Buttons, Inputs, Chips)
|
||||
========================================= */
|
||||
|
||||
/* Glass Buttons & Icons */
|
||||
.btn-icon-glass, .btn-icon-small, .modal-close {
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
@@ -91,7 +82,6 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Primary/Confirm Buttons */
|
||||
.btn-confirm, .btn-primary {
|
||||
background: var(--brand-color);
|
||||
border: none;
|
||||
@@ -126,7 +116,6 @@
|
||||
}
|
||||
.btn-cancel:hover { background: rgba(255, 255, 255, 0.1); color: white; }
|
||||
|
||||
/* Inputs & Selects */
|
||||
input[type="text"], input[type="password"], input[type="number"], .form-input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
@@ -144,16 +133,15 @@ input:focus, .form-input:focus {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Glass Select (Header Style - FIX: Better alignment) */
|
||||
.glass-select-sm {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #eee;
|
||||
padding: 0 32px 0 12px; /* Extra padding right for arrow */
|
||||
padding: 0 32px 0 12px;
|
||||
height: 32px;
|
||||
line-height: 30px; /* Vertically center text */
|
||||
line-height: 30px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
@@ -164,7 +152,6 @@ input:focus, .form-input:focus {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
/* SVG Arrow */
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.6)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
@@ -178,7 +165,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
.glass-select-sm option { background: #1a1a1a; color: #e0e0e0; }
|
||||
|
||||
/* Chips (Config Modal) */
|
||||
.chips-grid { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.chip {
|
||||
padding: 8px 16px;
|
||||
@@ -200,9 +186,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
.chip.disabled { opacity: 0.5; pointer-events: none; filter: grayscale(1); }
|
||||
|
||||
/* =========================================
|
||||
3. ROOM LAYOUT (The Watch Page)
|
||||
========================================= */
|
||||
.room-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
@@ -225,7 +208,6 @@ input:focus, .form-input:focus {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Responsive Layout */
|
||||
@media (max-width: 1200px) {
|
||||
.room-layout {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -241,9 +223,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
4. ROOM HEADER
|
||||
========================================= */
|
||||
.room-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -265,7 +244,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
.header-right { justify-content: flex-end; }
|
||||
|
||||
/* Info Section */
|
||||
.room-info { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; }
|
||||
#room-name {
|
||||
margin: 0;
|
||||
@@ -286,7 +264,6 @@ input:focus, .form-input:focus {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Host Controls (Center) - FIX: Better alignment container */
|
||||
.header-center { flex: 2; display: flex; justify-content: center; z-index: 50; }
|
||||
|
||||
.quick-controls-group {
|
||||
@@ -295,16 +272,15 @@ input:focus, .form-input:focus {
|
||||
gap: 8px;
|
||||
background: rgba(20, 20, 20, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 4px 8px; /* Slightly tighter padding */
|
||||
padding: 4px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
transition: all 0.2s ease;
|
||||
height: 42px; /* Explicit height to align children */
|
||||
height: 42px;
|
||||
}
|
||||
.quick-controls-group:hover { border-color: rgba(255,255,255,0.15); background: rgba(30, 30, 30, 0.7); }
|
||||
|
||||
/* Toggle Sub/Dub Mini */
|
||||
.sd-toggle.small {
|
||||
height: 32px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
@@ -340,7 +316,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
.sd-toggle.small .sd-option.active { color: #fff; }
|
||||
|
||||
/* Viewers Pill */
|
||||
.viewers-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -354,9 +329,6 @@ input:focus, .form-input:focus {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
5. VIDEO PLAYER AREA
|
||||
========================================= */
|
||||
.player-wrapper {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
@@ -394,7 +366,6 @@ input:focus, .form-input:focus {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* Custom Controls Layout Fixes */
|
||||
.custom-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -405,7 +376,6 @@ input:focus, .form-input:focus {
|
||||
padding: 20px 10px 10px;
|
||||
}
|
||||
|
||||
/* FIX: Ensure left controls stay in one line (time display fix) */
|
||||
.controls-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -413,14 +383,13 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
|
||||
.time-display {
|
||||
white-space: nowrap; /* Prevent line break */
|
||||
font-variant-numeric: tabular-nums; /* Monospaced numbers prevent jitter */
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9rem;
|
||||
color: #ddd;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
/* Subtitles Canvas */
|
||||
#subtitles-canvas {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
@@ -429,7 +398,6 @@ input:focus, .form-input:focus {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Hide unused player buttons in Room Mode */
|
||||
#download-btn,
|
||||
#manual-match-btn,
|
||||
#server-select,
|
||||
@@ -439,7 +407,6 @@ input:focus, .form-input:focus {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Settings Panel Position Fix */
|
||||
.settings-panel {
|
||||
position: absolute;
|
||||
bottom: 70px;
|
||||
@@ -452,15 +419,13 @@ input:focus, .form-input:focus {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
6. CHAT SIDEBAR
|
||||
========================================= */
|
||||
.chat-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(15, 15, 15, 0.95);
|
||||
border-left: 1px solid var(--glass-border);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.room-layout.chat-hidden .chat-sidebar {
|
||||
opacity: 0;
|
||||
@@ -477,7 +442,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
.chat-header h3 { margin: 0; font-size: 1.1rem; font-weight: 700; color: white; }
|
||||
|
||||
/* User List */
|
||||
.users-list {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
@@ -516,7 +480,6 @@ input:focus, .form-input:focus {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -524,6 +487,7 @@ input:focus, .form-input:focus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
.chat-message { display: flex; gap: 10px; }
|
||||
.chat-message.system { justify-content: center; margin: 8px 0; }
|
||||
@@ -558,7 +522,6 @@ input:focus, .form-input:focus {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Input Area */
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -585,9 +548,6 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
.chat-input button:hover { background: #7c3aed; }
|
||||
|
||||
/* =========================================
|
||||
7. MODALS & CONFIGURATION
|
||||
========================================= */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
@@ -617,12 +577,10 @@ input:focus, .form-input:focus {
|
||||
.modal-header-row { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
|
||||
.modal-header-row .modal-title { margin: 0; }
|
||||
|
||||
/* Forms inside modal */
|
||||
.form-group { margin-bottom: 20px; }
|
||||
.form-group label { display: block; margin-bottom: 8px; font-weight: 600; color: white; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
|
||||
|
||||
/* Search Step */
|
||||
.anime-search-content { max-width: 800px; max-height: 85vh; display: flex; flex-direction: column; }
|
||||
.search-bar { display: flex; gap: 12px; margin-bottom: 20px; }
|
||||
.search-bar button {
|
||||
@@ -653,7 +611,6 @@ input:focus, .form-input:focus {
|
||||
.search-title { font-weight: 700; color: white; margin-bottom: 4px; font-size: 1rem; }
|
||||
.search-meta { font-size: 0.85rem; color: var(--text-muted); }
|
||||
|
||||
/* Config Step (Unified UI) */
|
||||
.config-layout { display: flex; gap: 24px; margin-top: 20px; }
|
||||
.config-sidebar { width: 140px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
.config-cover {
|
||||
@@ -668,7 +625,6 @@ input:focus, .form-input:focus {
|
||||
margin-bottom: 8px; font-weight: 700;
|
||||
}
|
||||
|
||||
/* Episode Stepper */
|
||||
.ep-control {
|
||||
display: flex; align-items: center;
|
||||
background: rgba(255,255,255,0.05);
|
||||
@@ -689,7 +645,6 @@ input:focus, .form-input:focus {
|
||||
font-weight: 700; outline: none;
|
||||
}
|
||||
|
||||
/* Category Toggle */
|
||||
.cat-toggle {
|
||||
display: flex; background: rgba(0,0,0,0.3);
|
||||
padding: 4px; border-radius: 10px; width: fit-content;
|
||||
@@ -714,9 +669,6 @@ input:focus, .form-input:focus {
|
||||
.ep-control { width: auto; flex: 1; }
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
8. ROOM LIST / LOBBY (If used externally)
|
||||
========================================= */
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 80px 40px 40px; }
|
||||
.header h1 {
|
||||
font-size: 2.5rem; font-weight: 800;
|
||||
@@ -772,7 +724,7 @@ input:focus, .form-input:focus {
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #1a1a1a; /* Borde oscuro para separar del gradiente */
|
||||
border: 3px solid #1a1a1a;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
@@ -796,13 +748,13 @@ input:focus, .form-input:focus {
|
||||
|
||||
.video-toast-container {
|
||||
position: absolute;
|
||||
bottom: 100px; /* Encima de la barra de controles */
|
||||
bottom: 100px;
|
||||
left: 20px;
|
||||
z-index: 80; /* Por encima del video, debajo de los controles */
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none; /* Permitir clicks a través de ellos */
|
||||
pointer-events: none;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
@@ -819,9 +771,8 @@ input:focus, .form-input:focus {
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
|
||||
/* Animación de entrada y salida */
|
||||
animation: toastSlideIn 0.3s ease forwards, toastFadeOut 0.5s ease 4.5s forwards;
|
||||
pointer-events: auto; /* Permitir seleccionar texto si se quiere */
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -850,7 +801,6 @@ input:focus, .form-input:focus {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* --- BADGE DE NOTIFICACIÓN (Punto Rojo) --- */
|
||||
#toggle-chat-btn {
|
||||
position: relative;
|
||||
}
|
||||
@@ -862,7 +812,7 @@ input:focus, .form-input:focus {
|
||||
right: 2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: #ef4444; /* Rojo */
|
||||
background-color: #ef4444;
|
||||
border: 2px solid #1a1a1a;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
@@ -885,11 +835,11 @@ input:focus, .form-input:focus {
|
||||
}
|
||||
|
||||
.video-toast.system-toast {
|
||||
border-left-color: #9ca3af; /* Borde gris */
|
||||
background: rgba(20, 20, 20, 0.7); /* Un poco más transparente */
|
||||
border-left-color: #9ca3af;
|
||||
background: rgba(20, 20, 20, 0.7);
|
||||
justify-content: center;
|
||||
padding: 6px 12px;
|
||||
min-height: auto; /* Más compacto */
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.video-toast.system-toast .toast-msg {
|
||||
@@ -897,4 +847,78 @@ input:focus, .form-input:focus {
|
||||
font-style: italic;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#copy-invite-btn {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#copy-invite-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
#copy-invite-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="video-area">
|
||||
<div class="room-header">
|
||||
<div class="header-left">
|
||||
<button id="leave-room-btn" class="btn-icon-glass" title="Leave">
|
||||
<button id="leave-room-btn" class="btn-icon-glass" title="Leave" style="visibility: hidden;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<div class="room-info">
|
||||
@@ -49,6 +49,17 @@
|
||||
<select id="room-server-select" class="glass-select-sm" title="Server">
|
||||
<option value="" disabled selected>Server</option>
|
||||
</select>
|
||||
<button
|
||||
id="copy-invite-btn"
|
||||
class="btn-icon-glass"
|
||||
title="Copy invite link"
|
||||
style="display:none;"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.07 0l1.41-1.41a5 5 0 0 0-7.07-7.07L10 5"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.07 0L5.5 12.41a5 5 0 0 0 7.07 7.07L14 19"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user