added public watch parties with cloudflared

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

View File

@@ -47,6 +47,76 @@ fastify.addHook("preHandler", async (request) => {
}
});
const roomService = require('./electron/api/rooms/rooms.service');
fastify.addHook('onRequest', async (req, reply) => {
const isTunnel =
!!req.headers['cf-connecting-ip'] ||
!!req.headers['cf-ray'];
if (!isTunnel) return;
if (req.url.startsWith('/public/') ||
req.url.startsWith('/views/') ||
req.url.startsWith('/src/')) {
return;
}
if (req.url.startsWith('/room')) {
const urlParams = new URLSearchParams(req.url.split('?')[1]);
const roomId = urlParams.get('id');
if (!roomId) {
return reply.code(404).send({ error: 'Room ID required' });
}
const room = roomService.getRoom(roomId);
if (!room || room.exposed !== true) {
return reply.code(404).send({ error: 'Room not found' });
}
return;
}
const wsMatch = req.url.match(/^\/ws\/room\/([a-f0-9]+)/);
if (wsMatch) {
const roomId = wsMatch[1];
const room = roomService.getRoom(roomId);
if (!room || room.exposed !== true) {
return reply.code(404).send({ error: 'Room not found' });
}
return;
}
const apiMatch = req.url.match(/^\/api\/rooms\/([a-f0-9]+)/);
if (apiMatch) {
const roomId = apiMatch[1];
const room = roomService.getRoom(roomId);
if (!room || room.exposed !== true) {
return reply.code(404).send({ error: 'Room not found' });
}
return;
}
const allowedEndpoints = [
'/api/watch/stream',
'/api/proxy',
'/api/extensions',
'/api/search'
];
for (const endpoint of allowedEndpoints) {
if (req.url.startsWith(endpoint)) {
console.log('[Tunnel] ✓ Allowing utility endpoint:', endpoint);
return;
}
}
return reply.code(404).send({ error: 'Not found' });
});
fastify.register(require("@fastify/static"), {
root: path.join(__dirname, "public"),
prefix: "/public/",

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -0,0 +1,111 @@
import { spawn, ChildProcess } from "child_process";
import { getConfig as loadConfig } from '../../shared/config';
const { values } = loadConfig();
const CLOUDFLARED_PATH = values.paths?.cloudflared || 'cloudflared';
let tunnelProcess: ChildProcess | null = null;
let exposedRooms = 0;
let publicUrl: string | null = null;
let tunnelPromise: Promise<string> | null = null;
export function openTunnel(): Promise<string> {
if (tunnelProcess && publicUrl) {
exposedRooms++;
return Promise.resolve(publicUrl);
}
if (tunnelPromise) {
return tunnelPromise;
}
tunnelPromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("Timeout esperando URL del túnel (30s)"));
}, 30000);
tunnelProcess = spawn(CLOUDFLARED_PATH, [
"tunnel",
"--url",
"http://localhost:54322",
"--no-autoupdate"
]);
const cleanup = () => {
clearTimeout(timeout);
tunnelPromise = null;
};
let outputBuffer = "";
const processOutput = (data: Buffer) => {
const text = data.toString();
outputBuffer += text;
const match = outputBuffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
if (match && !publicUrl) {
publicUrl = match[0];
exposedRooms = 1;
cleanup();
resolve(publicUrl);
}
};
tunnelProcess.stdout?.on("data", (data) => {
processOutput(data);
});
tunnelProcess.stderr?.on("data", (data) => {
processOutput(data);
});
tunnelProcess.on("error", (error) => {
console.error("[Cloudflared Process Error]", error);
cleanup();
tunnelProcess = null;
reject(error);
});
tunnelProcess.on("exit", (code, signal) => {
tunnelProcess = null;
publicUrl = null;
exposedRooms = 0;
if (!publicUrl) {
cleanup();
reject(new Error(`Proceso cloudflared terminó antes de obtener URL (código: ${code})`));
}
});
});
return tunnelPromise;
}
export function closeTunnelIfUnused() {
exposedRooms--;
console.log(`[Tunnel Manager] Rooms expuestas: ${exposedRooms}`);
if (exposedRooms <= 0 && tunnelProcess) {
console.log("[Tunnel Manager] Cerrando túnel...");
tunnelProcess.kill();
tunnelProcess = null;
publicUrl = null;
exposedRooms = 0;
tunnelPromise = null;
}
}
export function getTunnelUrl(): string | null {
return publicUrl;
}
export function forceTunnelClose() {
if (tunnelProcess) {
console.log("[Tunnel Manager] Forzando cierre del túnel...");
tunnelProcess.kill();
tunnelProcess = null;
publicUrl = null;
exposedRooms = 0;
tunnelPromise = null;
}
}

View File

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

View File

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

View File

@@ -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." }
}
};

View File

@@ -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;
}
}

View File

@@ -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>