added user permissions to rooms

This commit is contained in:
2026-01-05 01:52:28 +01:00
parent 5cf034200e
commit e2345aa20a
10 changed files with 1819 additions and 165 deletions

View File

@@ -1,6 +1,11 @@
import crypto from 'crypto';
import { closeTunnelIfUnused } from "./tunnel.manager";
interface RoomPermissions {
canControl: boolean;
canManageQueue: boolean;
}
interface RoomUser {
id: string;
username: string;
@@ -8,6 +13,8 @@ interface RoomUser {
isHost: boolean;
isGuest: boolean;
userId?: number;
permissions?: RoomPermissions;
ipAddress?: string;
}
interface SourceContext {
@@ -55,8 +62,14 @@ interface RoomData {
exposed: boolean;
publicUrl?: string;
queue: QueueItem[];
bannedIPs: Set<string>;
}
export const DEFAULT_GUEST_PERMISSIONS: RoomPermissions = {
canControl: false,
canManageQueue: false
};
const rooms = new Map<string, RoomData>();
export function generateRoomId(): string {
@@ -77,13 +90,81 @@ export function createRoom(name: string, host: RoomUser, password?: string, expo
metadata: null,
exposed,
publicUrl,
queue: []
queue: [],
bannedIPs: new Set() // NUEVO
};
rooms.set(roomId, room);
return room;
}
export function updateUserPermissions(roomId: string, userId: string, permissions: Partial<RoomPermissions>): boolean {
const room = rooms.get(roomId);
if (!room) return false;
const user: any = room.users.get(userId);
if (!user) return false;
if (user.isHost) return false;
user.permissions = {
...user.permissions,
...permissions
};
return true;
}
export function banUserIP(roomId: string, ipAddress: string): boolean {
const room = rooms.get(roomId);
if (!room) return false;
room.bannedIPs.add(ipAddress);
// Remover a todos los usuarios con esa IP
Array.from(room.users.values()).forEach(user => {
if (user.ipAddress === ipAddress) {
removeUserFromRoom(roomId, user.id);
}
});
return true;
}
export function unbanUserIP(roomId: string, ipAddress: string): boolean {
const room = rooms.get(roomId);
if (!room) return false;
return room.bannedIPs.delete(ipAddress);
}
export function isIPBanned(roomId: string, ipAddress: string): boolean {
const room = rooms.get(roomId);
if (!room) return false;
return room.bannedIPs.has(ipAddress);
}
export function getBannedIPs(roomId: string): string[] {
const room = rooms.get(roomId);
if (!room) return [];
return Array.from(room.bannedIPs);
}
export function hasPermission(roomId: string, userId: string, permission: keyof RoomPermissions): boolean {
const room = rooms.get(roomId);
if (!room) return false;
const user = room.users.get(userId);
if (!user) return false;
// El host siempre tiene todos los permisos
if (user.isHost) return true;
// Si no tiene permisos definidos, usar defaults
const userPerms = user.permissions || DEFAULT_GUEST_PERMISSIONS;
return userPerms[permission] || false;
}
export function getRoom(roomId: string): RoomData | null {
return rooms.get(roomId) || null;
}

View File

@@ -13,14 +13,13 @@ interface WSClient {
const clients = new Map<string, WSClient>();
interface WSParams {
roomId: string;
}
interface WSQuery {
token?: string;
guestName?: string;
password?: string;
function getClientIP(req: any): string {
return req.headers['x-forwarded-for']?.split(',')[0].trim() ||
req.headers['x-real-ip'] ||
req.connection?.remoteAddress ||
req.socket?.remoteAddress ||
'unknown';
}
export function setupRoomWebSocket(fastify: FastifyInstance) {
@@ -44,13 +43,14 @@ async function handleWebSocketConnection(connection: any, req: any) {
const guestName = req.query.guestName;
const password = req.query.password;
const clientIP = getClientIP(req); // NUEVO
let userId: string;
let username: string;
let avatar: string | undefined;
let isGuest = false;
let realUserId: any;
// Verificar si la sala existe
const room = roomService.getRoom(roomId);
if (!room) {
socket.send(JSON.stringify({
@@ -61,6 +61,24 @@ async function handleWebSocketConnection(connection: any, req: any) {
return;
}
if (roomService.isIPBanned(roomId, clientIP)) {
socket.send(JSON.stringify({
type: 'error',
message: 'You have been banned from this room'
}));
socket.close();
return;
}
if (!room) {
socket.send(JSON.stringify({
type: 'error',
message: 'Room not found'
}));
socket.close();
return;
}
// Verificar contraseña si existe
if (room.password) {
if (!password || !roomService.verifyRoomPassword(roomId, password)) {
@@ -134,14 +152,15 @@ async function handleWebSocketConnection(connection: any, req: any) {
isHost
});
// Agregar usuario a la sala
const userInRoom = {
id: userId,
username,
avatar,
isHost: isHost, // ← CORREGIDO: Usar la verificación correcta
isHost: isHost,
isGuest,
userId: realUserId
userId: realUserId,
ipAddress: clientIP, // NUEVO
permissions: isHost ? undefined : { ...roomService.DEFAULT_GUEST_PERMISSIONS }
};
roomService.addUserToRoom(roomId, userInRoom);
@@ -160,6 +179,7 @@ async function handleWebSocketConnection(connection: any, req: any) {
userId,
username,
isGuest,
isHost, // NUEVO: Enviar explícitamente
room: {
id: room.id,
name: room.name,
@@ -168,7 +188,8 @@ async function handleWebSocketConnection(connection: any, req: any) {
username: u.username,
avatar: u.avatar,
isHost: u.isHost,
isGuest: u.isGuest
isGuest: u.isGuest,
permissions: u.permissions // NUEVO
})),
currentVideo: room.currentVideo,
queue: room.queue || []
@@ -212,6 +233,9 @@ function handleMessage(roomId: string, userId: string, data: any) {
const room = roomService.getRoom(roomId);
if (!room) return;
const user = room.users.get(userId);
if (!user) return;
console.log('Handling message:', data.type, 'from user:', userId, 'isHost:', room.host.id === userId);
switch (data.type) {
@@ -226,8 +250,65 @@ function handleMessage(roomId: string, userId: string, data: any) {
});
break;
case 'update_permissions':
if (!user.isHost) {
console.warn('Non-host attempted to update permissions');
return;
}
const success = roomService.updateUserPermissions(
roomId,
data.targetUserId,
data.permissions
);
if (success) {
const updatedRoom = roomService.getRoom(roomId);
if (updatedRoom) {
broadcastToRoom(roomId, {
type: 'permissions_updated',
users: Array.from(updatedRoom.users.values()).map(u => ({
id: u.id,
username: u.username,
avatar: u.avatar,
isHost: u.isHost,
isGuest: u.isGuest,
permissions: u.permissions
}))
});
}
}
break;
// NUEVO: Baneo de usuarios (solo host)
case 'ban_user':
if (!user.isHost) {
console.warn('Non-host attempted to ban user');
return;
}
const targetUser = room.users.get(data.targetUserId);
if (targetUser && targetUser.ipAddress) {
roomService.banUserIP(roomId, targetUser.ipAddress);
// Cerrar conexión del usuario baneado
const targetClient = clients.get(data.targetUserId);
if (targetClient && targetClient.socket) {
targetClient.socket.send(JSON.stringify({
type: 'banned',
message: 'You have been banned from this room'
}));
targetClient.socket.close();
}
}
break;
case 'queue_play_item':
if (room.host.id !== userId) return;
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
console.warn('User lacks permission for queue management');
return;
}
const itemToPlay = roomService.getAndRemoveQueueItem(roomId, data.itemUid);
if (itemToPlay) {
@@ -255,7 +336,9 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'queue_move':
if (room.host.id !== userId) return;
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
return;
}
const moved = roomService.moveQueueItem(roomId, data.itemUid, data.direction);
if (moved) {
@@ -295,7 +378,14 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'video_update':
if (room.host.id !== userId) return;
const canUpdateVideo = user.isHost ||
roomService.hasPermission(roomId, userId, 'canControl') ||
roomService.hasPermission(roomId, userId, 'canManageQueue');
if (!canUpdateVideo) {
console.warn('User lacks permissions to update video');
return;
}
roomService.updateRoomVideo(roomId, data.video);
roomService.updateRoomMetadata(roomId, data.metadata);
@@ -308,13 +398,13 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'queue_add_batch':
if (room.host.id !== userId) return;
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
return;
}
if (Array.isArray(data.items)) {
// Añadimos el índice (i) al forEach
data.items.forEach((item: any, i: number) => {
const newItem = {
// Añadimos el índice '_${i}' al UID para garantizar unicidad en milisegundos
uid: `q_${Date.now()}_${i}_${Math.random().toString(36).substr(2, 5)}`,
metadata: item.metadata,
videoData: item.video,
@@ -365,13 +455,11 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'play':
// Solo el host puede controlar la reproducción
if (room.host.id !== userId) {
console.warn('Non-host attempted play:', userId);
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) {
console.warn('User lacks control permissions');
return;
}
console.log('Broadcasting play event to room:', roomId);
broadcastToRoom(roomId, {
type: 'play',
currentTime: data.currentTime,
@@ -380,8 +468,8 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'pause':
if (room.host.id !== userId) {
console.warn('Non-host attempted pause:', userId);
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) {
console.warn('User lacks control permissions for pause');
return;
}
@@ -394,8 +482,8 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'seek':
if (room.host.id !== userId) {
console.warn('Non-host attempted seek:', userId);
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) {
console.warn('User lacks control permissions for seek');
return;
}
@@ -436,19 +524,19 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'queue_add':
// Solo el host (o todos si quisieras) pueden añadir
if (room.host.id !== userId) return;
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
return;
}
const newItem = {
uid: `q_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
metadata: data.metadata,
videoData: data.video, // El objeto que contiene url, type, subtitles
videoData: data.video,
addedBy: room.users.get(userId)?.username || 'Unknown'
};
roomService.addQueueItem(roomId, newItem);
// Avisar a todos que la cola cambió
broadcastToRoom(roomId, {
type: 'queue_update',
queue: room.queue
@@ -456,7 +544,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'queue_remove':
if (room.host.id !== userId) return;
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
return;
}
roomService.removeQueueItem(roomId, data.itemUid);
broadcastToRoom(roomId, {
type: 'queue_update',

View File

@@ -402,8 +402,8 @@ const AnimePlayer = (function() {
// Control functions
function togglePlayPause() {
if (_roomMode && !_isRoomHost) {
console.log('Guests cannot control playback');
if (_roomMode && !_isRoomHost && !hasControlPermission()) {
showPermissionToast('You need playback control permission');
return;
}
@@ -411,12 +411,14 @@ const AnimePlayer = (function() {
if (els.video.paused) {
els.video.play().catch(() => {});
if (_roomMode && _isRoomHost) {
if (_roomMode && (_isRoomHost || hasControlPermission())) {
sendRoomEvent('play', { currentTime: els.video.currentTime });
}
} else {
els.video.pause();
if (_roomMode && _isRoomHost) {
if (_roomMode && (_isRoomHost || hasControlPermission())) {
sendRoomEvent('pause', { currentTime: els.video.currentTime });
}
}
@@ -460,7 +462,7 @@ const AnimePlayer = (function() {
function seekToPosition(e) {
if (!els.video || !els.progressContainer) return;
if (_roomMode && !_isRoomHost) return;
if (_roomMode && !_isRoomHost && !hasControlPermission()) return;
const rect = els.progressContainer.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
@@ -468,8 +470,7 @@ const AnimePlayer = (function() {
els.video.currentTime = newTime;
// En room mode, enviar evento de seek
if (_roomMode && _isRoomHost) {
if (_roomMode && (_isRoomHost || hasControlPermission())) {
sendRoomEvent('seek', { currentTime: newTime });
}
}
@@ -484,29 +485,63 @@ const AnimePlayer = (function() {
function seekRelative(seconds) {
if (!els.video) return;
if (_roomMode && !_isRoomHost) return;
if (_roomMode && !_isRoomHost && !hasControlPermission()) {
showPermissionToast('You need playback control permission');
return;
}
const newTime = Math.max(0, Math.min(els.video.duration, els.video.currentTime + seconds));
els.video.currentTime = newTime;
// En room mode, enviar evento de seek
if (_roomMode && _isRoomHost) {
if (_roomMode && (_isRoomHost || hasControlPermission())) {
sendRoomEvent('seek', { currentTime: newTime });
}
}
function seekToPercent(percent) {
if (!els.video) return;
if (_roomMode && !_isRoomHost) return;
if (_roomMode && !_isRoomHost && !hasControlPermission()) {
showPermissionToast('You need playback control permission');
return;
}
const newTime = els.video.duration * percent;
els.video.currentTime = newTime;
// En room mode, enviar evento de seek
if (_roomMode && _isRoomHost) {
if (_roomMode && (_isRoomHost || hasControlPermission())) {
sendRoomEvent('seek', { currentTime: newTime });
}
}
function hasControlPermission() {
return window.__userPermissions?.canControl || false;
}
function showPermissionToast(message) {
const toast = document.createElement('div');
toast.className = 'permission-toast';
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(239, 68, 68, 0.95);
color: white;
padding: 16px 24px;
border-radius: 10px;
font-weight: 600;
z-index: 10000;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
animation: fadeIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'fadeOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 2500);
}
// Video event handlers
function onPlay() {
if (els.playPauseBtn) {
@@ -589,7 +624,9 @@ const AnimePlayer = (function() {
}
function sendRoomEvent(eventType, data = {}) {
if (!_roomMode || !_isRoomHost || !_roomWebSocket) return;
if (!_roomMode || !_roomWebSocket) return;
if (!_isRoomHost && !hasControlPermission()) return;
if (_roomWebSocket.readyState !== WebSocket.OPEN) return;
console.log('Sending room event:', eventType, data);

View File

@@ -232,10 +232,15 @@ const RoomsApp = (function() {
if (elements.btnAddQueue) {
elements.btnAddQueue.onclick = () => {
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
if (!canManageQueue) {
showSystemToast('You need queue management permission');
return;
}
if (selectedEpisodes.size === 0) return;
const sortedEps = Array.from(selectedEpisodes).sort((a, b) => a - b);
const currentProvider = elements.roomExtSelect ? elements.roomExtSelect.value : 'gogoanime';
const originalText = elements.btnAddQueue.innerHTML;
@@ -279,7 +284,9 @@ const RoomsApp = (function() {
}
if (elements.roomSdToggle) {
elements.roomSdToggle.onclick = () => {
if (!isHost) return;
const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false);
if (!hasAccess) return;
const currentState = elements.roomSdToggle.getAttribute('data-state');
const newState = currentState === 'sub' ? 'dub' : 'sub';
@@ -338,7 +345,9 @@ const RoomsApp = (function() {
// --- QUICK CONTROLS LOGIC (Header) ---
async function populateQuickControls() {
if (!isHost || !selectedAnimeData) return;
const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false);
if (!hasAccess || !selectedAnimeData) return;
if (!extensionsReady) return;
elements.roomExtSelect.innerHTML = '';
@@ -356,7 +365,8 @@ const RoomsApp = (function() {
}
async function onQuickExtensionChange(e, silent = false) {
if (!isHost) return;
const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false);
if (!hasAccess) return;
const ext = elements.roomExtSelect.value;
const settings = extensionsStore.settings[ext];
@@ -433,7 +443,9 @@ const RoomsApp = (function() {
}
function onQuickServerChange() {
if (!isHost) return;
const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false);
if (!hasAccess) return;
launchStream(false);
}
@@ -488,6 +500,28 @@ const RoomsApp = (function() {
if(elements.btnLaunch) elements.btnLaunch.disabled = false;
if(elements.btnAddQueue) elements.btnAddQueue.disabled = false;
if (extensionsReady && elements.selExtension) {
if (elements.selExtension.options.length <= 1) {
elements.selExtension.innerHTML = '';
extensionsStore.list.forEach(ext => {
const opt = document.createElement('option');
opt.value = ext;
opt.textContent = ext[0].toUpperCase() + ext.slice(1);
elements.selExtension.appendChild(opt);
});
let defaultExt = selectedAnimeData.source || 'anilist';
if (!extensionsStore.list.includes(defaultExt) && extensionsStore.list.length > 0) {
defaultExt = extensionsStore.list[0];
}
elements.selExtension.value = defaultExt;
handleModalExtensionChange();
}
}
setupConfigListeners();
elements.stepSearch.style.display = 'none';
@@ -495,7 +529,7 @@ const RoomsApp = (function() {
}
function setupConfigListeners() {
elements.epInc.onclick = () => {
elements.epInc.onclick = () => {
elements.inpEpisode.value = parseInt(elements.inpEpisode.value || 0) + 1;
};
elements.epDec.onclick = () => {
@@ -625,12 +659,24 @@ const RoomsApp = (function() {
let episodeToPlay = activeContext.episode;
if (fromModal && elements.inpEpisode) episodeToPlay = elements.inpEpisode.value;
const ext = overrides.forceExtension || elements.roomExtSelect.value || activeContext.extension;
const server = overrides.forceServer || elements.roomServerSelect.value || activeContext.server;
const category = elements.roomSdToggle.getAttribute('data-state') || activeContext.category;
const ext = overrides.forceExtension ||
(fromModal ? (configState.extension || elements.selExtension?.value) : null) ||
activeContext.extension ||
(elements.roomExtSelect ? elements.roomExtSelect.value : null);
const server = overrides.forceServer ||
(fromModal ? (configState.server || elements.selServer?.value) : null) ||
activeContext.server ||
(elements.roomServerSelect ? elements.roomServerSelect.value : null);
const category = elements.roomSdToggle?.getAttribute('data-state') || activeContext.category || 'sub';
if (!ext || !server) {
console.warn("Faltan datos (ext o server) para lanzar el stream");
console.warn("Faltan datos (ext o server).", {ext, server});
if (fromModal && elements.configError) {
elements.configError.textContent = "Please select an extension and server.";
elements.configError.style.display = 'block';
}
return;
}
@@ -850,7 +896,6 @@ const RoomsApp = (function() {
if (context.extension && elements.roomExtSelect.value !== context.extension) {
elements.roomExtSelect.value = context.extension;
// Importante: Cargar los servidores de esa extensión sin disparar otro play
await onQuickExtensionChange(null, true);
}
@@ -918,7 +963,7 @@ const RoomsApp = (function() {
episode: ep,
title: currentAnimeDetails.title.userPreferred,
server: document.getElementById('room-server-select').value || 'gogoanime',
category: 'sub' // o lo que esté seleccionado
category: 'sub'
}
}));
@@ -1081,6 +1126,18 @@ const RoomsApp = (function() {
currentUserId = data.userId;
currentUsername = data.username;
isGuest = data.isGuest;
if (data.room && data.room.users) {
const currentUser = data.room.users.find(u => u.id === data.userId);
if (currentUser) {
window.__userPermissions = currentUser.permissions || {
canControl: false,
canManageQueue: false
};
}
}
if (data.room.queue) renderQueue(data.room.queue);
updateRoomUI(data.room);
if (data.room.currentVideo) {
@@ -1109,6 +1166,7 @@ const RoomsApp = (function() {
break;
case 'users_update':
saveRoomData({ users: data.users });
renderUsersList(data.users);
break;
@@ -1127,6 +1185,32 @@ const RoomsApp = (function() {
updateUsersList();
break;
case 'permissions_updated':
saveRoomData({ users: data.users });
const updatedUser = data.users.find(u => u.id === currentUserId);
if (updatedUser) {
window.__userPermissions = updatedUser.permissions || {
canControl: false,
canManageQueue: false
};
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
if (elements.selectAnimeBtn) {
elements.selectAnimeBtn.style.display = canManageQueue ? 'flex' : 'none';
}
if (currentQueue.length > 0) {
renderQueue(currentQueue);
}
}
if (permissionsModal && permissionsModal.classList.contains('show')) {
updatePermissionsList();
}
renderUsersList(data.users);
break;
case 'chat':
addChatMessage(data);
const isChatHidden = elements.roomLayout.classList.contains('chat-hidden');
@@ -1210,33 +1294,34 @@ const RoomsApp = (function() {
return;
}
const canManageQueue = isHost || (window.__userPermissions && window.__userPermissions.canManageQueue);
elements.queueList.innerHTML = queueItems.map((item, index) => {
let actionsHtml = '';
if (isHost) {
if (canManageQueue) {
const isFirst = index === 0;
const isLast = index === queueItems.length - 1;
actionsHtml = `
<div class="q-actions">
<button class="q-btn play" onclick="playQueueItem('${item.uid}')" title="Play Now">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<div style="display:flex; gap:2px;">
${!isFirst ? `
<button class="q-btn" onclick="moveQueueItem('${item.uid}', 'up')" title="Move Up">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 15l-6-6-6 6"/></svg>
</button>` : ''}
${!isLast ? `
<button class="q-btn" onclick="moveQueueItem('${item.uid}', 'down')" title="Move Down">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>` : ''}
</div>
<button class="q-btn remove" onclick="removeQueueItem('${item.uid}')" title="Remove">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<div class="q-actions">
<button class="q-btn play" onclick="playQueueItem('${item.uid}')" title="Play Now">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<div style="display:flex; gap:2px;">
${!isFirst ? `
<button class="q-btn" onclick="moveQueueItem('${item.uid}', 'up')" title="Move Up">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 15l-6-6-6 6"/></svg>
</button>` : ''}
${!isLast ? `
<button class="q-btn" onclick="moveQueueItem('${item.uid}', 'down')" title="Move Down">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>` : ''}
</div>
`;
<button class="q-btn remove" onclick="removeQueueItem('${item.uid}')" title="Remove">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
`;
}
return `
@@ -1248,11 +1333,17 @@ const RoomsApp = (function() {
</div>
${actionsHtml}
</div>
`;
`;
}).join('');
}
window.playQueueItem = async function(uid) {
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
if (!canManageQueue) {
showSystemToast('You need queue management permission');
return;
}
const item = currentQueue.find(i => i.uid === uid);
if (!item) return;
@@ -1305,12 +1396,25 @@ const RoomsApp = (function() {
};
window.moveQueueItem = function(uid, direction) {
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
if (!canManageQueue) {
showSystemToast('You need queue management permission');
return;
}
if(ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'queue_move', itemUid: uid, direction }));
}
};
window.removeQueueItem = function(uid) {
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
if (!canManageQueue) {
showSystemToast('You need queue management permission');
return;
}
if(ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'queue_remove', itemUid: uid }));
}
@@ -1321,17 +1425,39 @@ const RoomsApp = (function() {
elements.roomViewers.textContent = `${room.users.length}`;
const currentUser = room.users.find(u => u.id === currentUserId);
if (currentUser) {
window.__userPermissions = currentUser.permissions || { canControl: false, canManageQueue: false };
}
isHost = currentUser?.isHost || false;
if (elements.selectAnimeBtn) elements.selectAnimeBtn.style.display = isHost ? 'flex' : 'none';
if (elements.hostControls) elements.hostControls.style.display = isHost ? 'flex' : 'none';
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
const canControlPlayer = isHost || (window.__userPermissions?.canControl || false);
if (elements.selectAnimeBtn) {
elements.selectAnimeBtn.style.display = canManageQueue ? 'flex' : 'none';
}
if (elements.hostControls) {
elements.hostControls.style.display = canManageQueue ? 'flex' : 'none';
}
if (window.AnimePlayer && typeof window.AnimePlayer.setRoomHost === 'function') {
window.AnimePlayer.setRoomHost(isHost);
window.AnimePlayer.setRoomHost(canControlPlayer);
}
if (isHost) {
initGlobalControls();
initPermissionsUI();
}
else if (canManageQueue) {
initGlobalControls();
}
if (canManageQueue && room.metadata) {
if(!selectedAnimeData) selectedAnimeData = { ...room.metadata, source: 'anilist' };
populateQuickControls();
}
const copyInviteBtn = document.getElementById('copy-invite-btn');
@@ -1387,7 +1513,8 @@ const RoomsApp = (function() {
}
async function initGlobalControls() {
if (!isHost || !extensionsReady) return;
const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false);
if (!hasAccess || !extensionsReady) return;
if (elements.roomExtSelect.options.length > 1) return;
@@ -1820,6 +1947,274 @@ const RoomsApp = (function() {
return div.innerHTML;
}
let permissionsModal = null;
function initPermissionsUI() {
if (!isHost) return;
if (!permissionsModal) {
permissionsModal = document.createElement('div');
permissionsModal.className = 'modal-overlay';
permissionsModal.id = 'permissions-modal';
permissionsModal.innerHTML = `
<div class="modal-content permissions-content">
<button class="modal-close" onclick="closePermissionsModal()">✕</button>
<h2 class="modal-title">Manage Permissions</h2>
<div class="permissions-list" id="permissions-list">
<!-- Se llenará dinámicamente -->
</div>
<div class="banned-ips-section">
<h3 class="section-title">Banned IPs</h3>
<div id="banned-ips-list" class="banned-list">
<p class="empty-state">No banned users</p>
</div>
</div>
</div>
`;
document.body.appendChild(permissionsModal);
}
const headerRight = document.querySelector('.header-right');
if (headerRight && !document.getElementById('permissions-btn')) {
const permBtn = document.createElement('button');
permBtn.id = 'permissions-btn';
permBtn.className = 'btn-icon-glass';
permBtn.title = 'Manage Permissions';
permBtn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
`;
permBtn.onclick = openPermissionsModal;
const searchBtn = document.getElementById('select-anime-btn');
if (searchBtn) {
headerRight.insertBefore(permBtn, searchBtn);
} else {
headerRight.appendChild(permBtn);
}
}
}
function openPermissionsModal() {
if (!permissionsModal || !isHost) return;
updatePermissionsList();
permissionsModal.classList.add('show');
}
window.closePermissionsModal = function() {
if (permissionsModal) {
permissionsModal.classList.remove('show');
}
};
function updatePermissionsList() {
const list = document.getElementById('permissions-list');
if (!list) return;
const room = getCurrentRoomData();
if (!room || !room.users) {
list.innerHTML = '<p class="empty-state">No active users</p>';
return;
}
list.innerHTML = room.users
.filter(u => !u.isHost)
.map(user => createPermissionCard(user))
.join('');
}
function createPermissionCard(user) {
const perms = user.permissions || { canControl: false, canManageQueue: false };
return `
<div class="permission-card">
<div class="user-info-section">
<div class="user-avatar-small">
${user.avatar ? `<img src="${user.avatar}">` : user.username[0].toUpperCase()}
</div>
<div class="user-details">
<span class="user-name-text">${escapeHtml(user.username)}</span>
${user.isGuest ? '<span class="guest-badge">Guest</span>' : ''}
</div>
</div>
<div class="permissions-toggles">
<label class="perm-toggle" for="perm-ctrl-${user.id}">
<input type="checkbox"
id="perm-ctrl-${user.id}"
data-user-id="${user.id}"
${perms.canControl ? 'checked' : ''}
onchange="togglePermission('${user.id}', 'canControl', this.checked)">
<span class="perm-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
Playback Control
</span>
</label>
<label class="perm-toggle" for="perm-queue-${user.id}">
<input type="checkbox"
id="perm-queue-${user.id}"
data-user-id="${user.id}"
${perms.canManageQueue ? 'checked' : ''}
onchange="togglePermission('${user.id}', 'canManageQueue', this.checked)">
<span class="perm-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
Queue Management
</span>
</label>
</div>
<button class="ban-btn" onclick="banUser('${user.id}')" title="Ban User">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
</svg>
Ban
</button>
</div>
`;
}
window.togglePermission = function(userId, permission, value) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
console.log(`Toggling permission ${permission} to ${value} for user ${userId}`);
const permissions = {};
permissions[permission] = value;
ws.send(JSON.stringify({
type: 'update_permissions',
targetUserId: userId,
permissions
}));
showSystemToast(`Permission ${value ? 'granted' : 'revoked'}`);
};
window.banUser = function(userId) {
const room = getCurrentRoomData();
const user = room?.users?.find(u => u.id === userId);
if (!user) return;
const confirmed = confirm(`Ban ${user.username} from this room? They won't be able to rejoin.`);
if (!confirmed) return;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'ban_user',
targetUserId: userId
}));
}
showSystemToast(`${user.username} has been banned`);
setTimeout(() => updatePermissionsList(), 500);
};
function getCurrentRoomData() {
return window.__currentRoomData || null;
}
function saveRoomData(roomData) {
window.__currentRoomData = roomData;
if (roomData.users && currentUserId) {
const currentUser = roomData.users.find(u => u.id === currentUserId);
if (currentUser && currentUser.permissions) {
window.__userPermissions = currentUser.permissions;
console.log('Permissions updated for current user:', window.__userPermissions);
}
}
}
const originalHandleMessage = handleWebSocketMessage;
handleWebSocketMessage = function(data) {
switch(data.type) {
case 'permissions_updated':
saveRoomData({ users: data.users });
const updatedUser = data.users.find(u => u.id === currentUserId);
if (updatedUser) {
window.__userPermissions = updatedUser.permissions || {
canControl: false,
canManageQueue: false
};
console.log('My permissions updated:', window.__userPermissions);
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
if (elements.selectAnimeBtn) {
elements.selectAnimeBtn.style.display = canManageQueue ? 'flex' : 'none';
}
if (elements.hostControls) {
elements.hostControls.style.display = canManageQueue ? 'flex' : 'none';
}
if (canManageQueue) {
initGlobalControls();
populateQuickControls();
}
if (!canManageQueue && elements.animeSearchModal && elements.animeSearchModal.classList.contains('show')) {
closeAnimeSearchModal();
showSystemToast("Permissions updated: Access revoked");
}
if (currentQueue && currentQueue.length > 0) {
console.log('Forcing queue re-render due to permission change');
renderQueue(currentQueue);
}
}
if (permissionsModal && permissionsModal.classList.contains('show')) {
updatePermissionsList();
}
renderUsersList(data.users);
break;
case 'banned':
alert(data.message || 'You have been banned from this room');
window.location.href = '/anime';
break;
case 'users_update':
saveRoomData({ users: data.users });
break;
default:
originalHandleMessage(data);
}
};
const originalUpdateRoomUI = updateRoomUI;
updateRoomUI = function(room) {
originalUpdateRoomUI(room);
if (isHost) {
initPermissionsUI();
}
};
return { init };
})();

View File

@@ -627,4 +627,227 @@ input[type=number] { -moz-appearance: textfield; }
.config-sidebar { width: 100%; flex-direction: row; }
.config-cover { width: 80px; }
.ep-control { width: auto; flex: 1; }
}
/* =========================================
PERMISSIONS & MODERATION
========================================= */
.permissions-content {
max-width: 700px;
width: 90%;
max-height: 85vh;
overflow-y: auto;
}
.permissions-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 30px;
}
.permission-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
transition: all 0.2s;
}
.permission-card:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.2);
}
.user-info-section {
display: flex;
align-items: center;
gap: 12px;
min-width: 180px;
}
.user-avatar-small {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--brand-gradient);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: white;
font-size: 0.9rem;
flex-shrink: 0;
}
.user-avatar-small img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.user-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-name-text {
font-weight: 600;
color: white;
font-size: 0.95rem;
}
.guest-badge {
font-size: 0.7rem;
background: rgba(255, 255, 255, 0.1);
color: #aaa;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
width: fit-content;
}
.permissions-toggles {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.perm-toggle {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.perm-toggle input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--brand-color);
}
.perm-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
transition: color 0.2s;
}
.perm-toggle:hover .perm-label {
color: white;
}
.perm-label svg {
opacity: 0.6;
}
.ban-btn {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: 8px 16px;
border-radius: 8px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
white-space: nowrap;
}
.ban-btn:hover {
background: rgba(239, 68, 68, 0.2);
border-color: #ef4444;
transform: translateY(-1px);
}
.banned-ips-section {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.section-title {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 12px;
font-weight: 700;
}
.banned-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.banned-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: rgba(239, 68, 68, 0.05);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
}
.banned-ip {
font-family: monospace;
color: #ef4444;
font-size: 0.9rem;
}
.unban-btn {
background: transparent;
border: 1px solid rgba(74, 222, 128, 0.3);
color: #4ade80;
padding: 4px 12px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.unban-btn:hover {
background: rgba(74, 222, 128, 0.1);
border-color: #4ade80;
}
.empty-state {
text-align: center;
color: #666;
font-style: italic;
padding: 20px;
}
/* Responsive */
@media (max-width: 768px) {
.permission-card {
flex-direction: column;
align-items: stretch;
}
.user-info-section {
min-width: auto;
}
.ban-btn {
width: 100%;
justify-content: center;
}
}

View File

@@ -1,6 +1,11 @@
import crypto from 'crypto';
import { closeTunnelIfUnused } from "./tunnel.manager";
interface RoomPermissions {
canControl: boolean;
canManageQueue: boolean;
}
interface RoomUser {
id: string;
username: string;
@@ -8,6 +13,8 @@ interface RoomUser {
isHost: boolean;
isGuest: boolean;
userId?: number;
permissions?: RoomPermissions;
ipAddress?: string;
}
interface SourceContext {
@@ -55,8 +62,14 @@ interface RoomData {
exposed: boolean;
publicUrl?: string;
queue: QueueItem[];
bannedIPs: Set<string>;
}
export const DEFAULT_GUEST_PERMISSIONS: RoomPermissions = {
canControl: false,
canManageQueue: false
};
const rooms = new Map<string, RoomData>();
export function generateRoomId(): string {
@@ -77,13 +90,81 @@ export function createRoom(name: string, host: RoomUser, password?: string, expo
metadata: null,
exposed,
publicUrl,
queue: []
queue: [],
bannedIPs: new Set() // NUEVO
};
rooms.set(roomId, room);
return room;
}
export function updateUserPermissions(roomId: string, userId: string, permissions: Partial<RoomPermissions>): boolean {
const room = rooms.get(roomId);
if (!room) return false;
const user: any = room.users.get(userId);
if (!user) return false;
if (user.isHost) return false;
user.permissions = {
...user.permissions,
...permissions
};
return true;
}
export function banUserIP(roomId: string, ipAddress: string): boolean {
const room = rooms.get(roomId);
if (!room) return false;
room.bannedIPs.add(ipAddress);
// Remover a todos los usuarios con esa IP
Array.from(room.users.values()).forEach(user => {
if (user.ipAddress === ipAddress) {
removeUserFromRoom(roomId, user.id);
}
});
return true;
}
export function unbanUserIP(roomId: string, ipAddress: string): boolean {
const room = rooms.get(roomId);
if (!room) return false;
return room.bannedIPs.delete(ipAddress);
}
export function isIPBanned(roomId: string, ipAddress: string): boolean {
const room = rooms.get(roomId);
if (!room) return false;
return room.bannedIPs.has(ipAddress);
}
export function getBannedIPs(roomId: string): string[] {
const room = rooms.get(roomId);
if (!room) return [];
return Array.from(room.bannedIPs);
}
export function hasPermission(roomId: string, userId: string, permission: keyof RoomPermissions): boolean {
const room = rooms.get(roomId);
if (!room) return false;
const user = room.users.get(userId);
if (!user) return false;
// El host siempre tiene todos los permisos
if (user.isHost) return true;
// Si no tiene permisos definidos, usar defaults
const userPerms = user.permissions || DEFAULT_GUEST_PERMISSIONS;
return userPerms[permission] || false;
}
export function getRoom(roomId: string): RoomData | null {
return rooms.get(roomId) || null;
}

View File

@@ -13,14 +13,13 @@ interface WSClient {
const clients = new Map<string, WSClient>();
interface WSParams {
roomId: string;
}
interface WSQuery {
token?: string;
guestName?: string;
password?: string;
function getClientIP(req: any): string {
return req.headers['x-forwarded-for']?.split(',')[0].trim() ||
req.headers['x-real-ip'] ||
req.connection?.remoteAddress ||
req.socket?.remoteAddress ||
'unknown';
}
export function setupRoomWebSocket(fastify: FastifyInstance) {
@@ -44,13 +43,14 @@ async function handleWebSocketConnection(connection: any, req: any) {
const guestName = req.query.guestName;
const password = req.query.password;
const clientIP = getClientIP(req); // NUEVO
let userId: string;
let username: string;
let avatar: string | undefined;
let isGuest = false;
let realUserId: any;
// Verificar si la sala existe
const room = roomService.getRoom(roomId);
if (!room) {
socket.send(JSON.stringify({
@@ -61,6 +61,24 @@ async function handleWebSocketConnection(connection: any, req: any) {
return;
}
if (roomService.isIPBanned(roomId, clientIP)) {
socket.send(JSON.stringify({
type: 'error',
message: 'You have been banned from this room'
}));
socket.close();
return;
}
if (!room) {
socket.send(JSON.stringify({
type: 'error',
message: 'Room not found'
}));
socket.close();
return;
}
// Verificar contraseña si existe
if (room.password) {
if (!password || !roomService.verifyRoomPassword(roomId, password)) {
@@ -134,14 +152,15 @@ async function handleWebSocketConnection(connection: any, req: any) {
isHost
});
// Agregar usuario a la sala
const userInRoom = {
id: userId,
username,
avatar,
isHost: isHost, // ← CORREGIDO: Usar la verificación correcta
isHost: isHost,
isGuest,
userId: realUserId
userId: realUserId,
ipAddress: clientIP, // NUEVO
permissions: isHost ? undefined : { ...roomService.DEFAULT_GUEST_PERMISSIONS }
};
roomService.addUserToRoom(roomId, userInRoom);
@@ -160,6 +179,7 @@ async function handleWebSocketConnection(connection: any, req: any) {
userId,
username,
isGuest,
isHost, // NUEVO: Enviar explícitamente
room: {
id: room.id,
name: room.name,
@@ -168,7 +188,8 @@ async function handleWebSocketConnection(connection: any, req: any) {
username: u.username,
avatar: u.avatar,
isHost: u.isHost,
isGuest: u.isGuest
isGuest: u.isGuest,
permissions: u.permissions // NUEVO
})),
currentVideo: room.currentVideo,
queue: room.queue || []
@@ -212,6 +233,9 @@ function handleMessage(roomId: string, userId: string, data: any) {
const room = roomService.getRoom(roomId);
if (!room) return;
const user = room.users.get(userId);
if (!user) return;
console.log('Handling message:', data.type, 'from user:', userId, 'isHost:', room.host.id === userId);
switch (data.type) {
@@ -226,8 +250,65 @@ function handleMessage(roomId: string, userId: string, data: any) {
});
break;
case 'update_permissions':
if (!user.isHost) {
console.warn('Non-host attempted to update permissions');
return;
}
const success = roomService.updateUserPermissions(
roomId,
data.targetUserId,
data.permissions
);
if (success) {
const updatedRoom = roomService.getRoom(roomId);
if (updatedRoom) {
broadcastToRoom(roomId, {
type: 'permissions_updated',
users: Array.from(updatedRoom.users.values()).map(u => ({
id: u.id,
username: u.username,
avatar: u.avatar,
isHost: u.isHost,
isGuest: u.isGuest,
permissions: u.permissions
}))
});
}
}
break;
// NUEVO: Baneo de usuarios (solo host)
case 'ban_user':
if (!user.isHost) {
console.warn('Non-host attempted to ban user');
return;
}
const targetUser = room.users.get(data.targetUserId);
if (targetUser && targetUser.ipAddress) {
roomService.banUserIP(roomId, targetUser.ipAddress);
// Cerrar conexión del usuario baneado
const targetClient = clients.get(data.targetUserId);
if (targetClient && targetClient.socket) {
targetClient.socket.send(JSON.stringify({
type: 'banned',
message: 'You have been banned from this room'
}));
targetClient.socket.close();
}
}
break;
case 'queue_play_item':
if (room.host.id !== userId) return;
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
console.warn('User lacks permission for queue management');
return;
}
const itemToPlay = roomService.getAndRemoveQueueItem(roomId, data.itemUid);
if (itemToPlay) {
@@ -255,7 +336,9 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'queue_move':
if (room.host.id !== userId) return;
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
return;
}
const moved = roomService.moveQueueItem(roomId, data.itemUid, data.direction);
if (moved) {
@@ -295,7 +378,14 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'video_update':
if (room.host.id !== userId) return;
const canUpdateVideo = user.isHost ||
roomService.hasPermission(roomId, userId, 'canControl') ||
roomService.hasPermission(roomId, userId, 'canManageQueue');
if (!canUpdateVideo) {
console.warn('User lacks permissions to update video');
return;
}
roomService.updateRoomVideo(roomId, data.video);
roomService.updateRoomMetadata(roomId, data.metadata);
@@ -308,13 +398,13 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'queue_add_batch':
if (room.host.id !== userId) return;
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
return;
}
if (Array.isArray(data.items)) {
// Añadimos el índice (i) al forEach
data.items.forEach((item: any, i: number) => {
const newItem = {
// Añadimos el índice '_${i}' al UID para garantizar unicidad en milisegundos
uid: `q_${Date.now()}_${i}_${Math.random().toString(36).substr(2, 5)}`,
metadata: item.metadata,
videoData: item.video,
@@ -365,13 +455,11 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'play':
// Solo el host puede controlar la reproducción
if (room.host.id !== userId) {
console.warn('Non-host attempted play:', userId);
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) {
console.warn('User lacks control permissions');
return;
}
console.log('Broadcasting play event to room:', roomId);
broadcastToRoom(roomId, {
type: 'play',
currentTime: data.currentTime,
@@ -380,8 +468,8 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'pause':
if (room.host.id !== userId) {
console.warn('Non-host attempted pause:', userId);
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) {
console.warn('User lacks control permissions for pause');
return;
}
@@ -394,8 +482,8 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'seek':
if (room.host.id !== userId) {
console.warn('Non-host attempted seek:', userId);
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canControl')) {
console.warn('User lacks control permissions for seek');
return;
}
@@ -436,19 +524,19 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'queue_add':
// Solo el host (o todos si quisieras) pueden añadir
if (room.host.id !== userId) return;
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
return;
}
const newItem = {
uid: `q_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
metadata: data.metadata,
videoData: data.video, // El objeto que contiene url, type, subtitles
videoData: data.video,
addedBy: room.users.get(userId)?.username || 'Unknown'
};
roomService.addQueueItem(roomId, newItem);
// Avisar a todos que la cola cambió
broadcastToRoom(roomId, {
type: 'queue_update',
queue: room.queue
@@ -456,7 +544,10 @@ function handleMessage(roomId: string, userId: string, data: any) {
break;
case 'queue_remove':
if (room.host.id !== userId) return;
if (!user.isHost && !roomService.hasPermission(roomId, userId, 'canManageQueue')) {
return;
}
roomService.removeQueueItem(roomId, data.itemUid);
broadcastToRoom(roomId, {
type: 'queue_update',

View File

@@ -402,8 +402,8 @@ const AnimePlayer = (function() {
// Control functions
function togglePlayPause() {
if (_roomMode && !_isRoomHost) {
console.log('Guests cannot control playback');
if (_roomMode && !_isRoomHost && !hasControlPermission()) {
showPermissionToast('You need playback control permission');
return;
}
@@ -411,12 +411,14 @@ const AnimePlayer = (function() {
if (els.video.paused) {
els.video.play().catch(() => {});
if (_roomMode && _isRoomHost) {
if (_roomMode && (_isRoomHost || hasControlPermission())) {
sendRoomEvent('play', { currentTime: els.video.currentTime });
}
} else {
els.video.pause();
if (_roomMode && _isRoomHost) {
if (_roomMode && (_isRoomHost || hasControlPermission())) {
sendRoomEvent('pause', { currentTime: els.video.currentTime });
}
}
@@ -460,7 +462,7 @@ const AnimePlayer = (function() {
function seekToPosition(e) {
if (!els.video || !els.progressContainer) return;
if (_roomMode && !_isRoomHost) return;
if (_roomMode && !_isRoomHost && !hasControlPermission()) return;
const rect = els.progressContainer.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
@@ -468,8 +470,7 @@ const AnimePlayer = (function() {
els.video.currentTime = newTime;
// En room mode, enviar evento de seek
if (_roomMode && _isRoomHost) {
if (_roomMode && (_isRoomHost || hasControlPermission())) {
sendRoomEvent('seek', { currentTime: newTime });
}
}
@@ -484,29 +485,63 @@ const AnimePlayer = (function() {
function seekRelative(seconds) {
if (!els.video) return;
if (_roomMode && !_isRoomHost) return;
if (_roomMode && !_isRoomHost && !hasControlPermission()) {
showPermissionToast('You need playback control permission');
return;
}
const newTime = Math.max(0, Math.min(els.video.duration, els.video.currentTime + seconds));
els.video.currentTime = newTime;
// En room mode, enviar evento de seek
if (_roomMode && _isRoomHost) {
if (_roomMode && (_isRoomHost || hasControlPermission())) {
sendRoomEvent('seek', { currentTime: newTime });
}
}
function seekToPercent(percent) {
if (!els.video) return;
if (_roomMode && !_isRoomHost) return;
if (_roomMode && !_isRoomHost && !hasControlPermission()) {
showPermissionToast('You need playback control permission');
return;
}
const newTime = els.video.duration * percent;
els.video.currentTime = newTime;
// En room mode, enviar evento de seek
if (_roomMode && _isRoomHost) {
if (_roomMode && (_isRoomHost || hasControlPermission())) {
sendRoomEvent('seek', { currentTime: newTime });
}
}
function hasControlPermission() {
return window.__userPermissions?.canControl || false;
}
function showPermissionToast(message) {
const toast = document.createElement('div');
toast.className = 'permission-toast';
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(239, 68, 68, 0.95);
color: white;
padding: 16px 24px;
border-radius: 10px;
font-weight: 600;
z-index: 10000;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
animation: fadeIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'fadeOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 2500);
}
// Video event handlers
function onPlay() {
if (els.playPauseBtn) {
@@ -589,7 +624,9 @@ const AnimePlayer = (function() {
}
function sendRoomEvent(eventType, data = {}) {
if (!_roomMode || !_isRoomHost || !_roomWebSocket) return;
if (!_roomMode || !_roomWebSocket) return;
if (!_isRoomHost && !hasControlPermission()) return;
if (_roomWebSocket.readyState !== WebSocket.OPEN) return;
console.log('Sending room event:', eventType, data);

View File

@@ -232,10 +232,15 @@ const RoomsApp = (function() {
if (elements.btnAddQueue) {
elements.btnAddQueue.onclick = () => {
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
if (!canManageQueue) {
showSystemToast('You need queue management permission');
return;
}
if (selectedEpisodes.size === 0) return;
const sortedEps = Array.from(selectedEpisodes).sort((a, b) => a - b);
const currentProvider = elements.roomExtSelect ? elements.roomExtSelect.value : 'gogoanime';
const originalText = elements.btnAddQueue.innerHTML;
@@ -279,7 +284,9 @@ const RoomsApp = (function() {
}
if (elements.roomSdToggle) {
elements.roomSdToggle.onclick = () => {
if (!isHost) return;
const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false);
if (!hasAccess) return;
const currentState = elements.roomSdToggle.getAttribute('data-state');
const newState = currentState === 'sub' ? 'dub' : 'sub';
@@ -338,7 +345,9 @@ const RoomsApp = (function() {
// --- QUICK CONTROLS LOGIC (Header) ---
async function populateQuickControls() {
if (!isHost || !selectedAnimeData) return;
const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false);
if (!hasAccess || !selectedAnimeData) return;
if (!extensionsReady) return;
elements.roomExtSelect.innerHTML = '';
@@ -356,7 +365,8 @@ const RoomsApp = (function() {
}
async function onQuickExtensionChange(e, silent = false) {
if (!isHost) return;
const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false);
if (!hasAccess) return;
const ext = elements.roomExtSelect.value;
const settings = extensionsStore.settings[ext];
@@ -433,7 +443,9 @@ const RoomsApp = (function() {
}
function onQuickServerChange() {
if (!isHost) return;
const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false);
if (!hasAccess) return;
launchStream(false);
}
@@ -488,6 +500,28 @@ const RoomsApp = (function() {
if(elements.btnLaunch) elements.btnLaunch.disabled = false;
if(elements.btnAddQueue) elements.btnAddQueue.disabled = false;
if (extensionsReady && elements.selExtension) {
if (elements.selExtension.options.length <= 1) {
elements.selExtension.innerHTML = '';
extensionsStore.list.forEach(ext => {
const opt = document.createElement('option');
opt.value = ext;
opt.textContent = ext[0].toUpperCase() + ext.slice(1);
elements.selExtension.appendChild(opt);
});
let defaultExt = selectedAnimeData.source || 'anilist';
if (!extensionsStore.list.includes(defaultExt) && extensionsStore.list.length > 0) {
defaultExt = extensionsStore.list[0];
}
elements.selExtension.value = defaultExt;
handleModalExtensionChange();
}
}
setupConfigListeners();
elements.stepSearch.style.display = 'none';
@@ -625,12 +659,24 @@ const RoomsApp = (function() {
let episodeToPlay = activeContext.episode;
if (fromModal && elements.inpEpisode) episodeToPlay = elements.inpEpisode.value;
const ext = overrides.forceExtension || elements.roomExtSelect.value || activeContext.extension;
const server = overrides.forceServer || elements.roomServerSelect.value || activeContext.server;
const category = elements.roomSdToggle.getAttribute('data-state') || activeContext.category;
const ext = overrides.forceExtension ||
(fromModal ? (configState.extension || elements.selExtension?.value) : null) ||
activeContext.extension ||
(elements.roomExtSelect ? elements.roomExtSelect.value : null);
const server = overrides.forceServer ||
(fromModal ? (configState.server || elements.selServer?.value) : null) ||
activeContext.server ||
(elements.roomServerSelect ? elements.roomServerSelect.value : null);
const category = elements.roomSdToggle?.getAttribute('data-state') || activeContext.category || 'sub';
if (!ext || !server) {
console.warn("Faltan datos (ext o server) para lanzar el stream");
console.warn("Faltan datos (ext o server).", {ext, server});
if (fromModal && elements.configError) {
elements.configError.textContent = "Please select an extension and server.";
elements.configError.style.display = 'block';
}
return;
}
@@ -850,7 +896,6 @@ const RoomsApp = (function() {
if (context.extension && elements.roomExtSelect.value !== context.extension) {
elements.roomExtSelect.value = context.extension;
// Importante: Cargar los servidores de esa extensión sin disparar otro play
await onQuickExtensionChange(null, true);
}
@@ -918,7 +963,7 @@ const RoomsApp = (function() {
episode: ep,
title: currentAnimeDetails.title.userPreferred,
server: document.getElementById('room-server-select').value || 'gogoanime',
category: 'sub' // o lo que esté seleccionado
category: 'sub'
}
}));
@@ -1081,6 +1126,18 @@ const RoomsApp = (function() {
currentUserId = data.userId;
currentUsername = data.username;
isGuest = data.isGuest;
if (data.room && data.room.users) {
const currentUser = data.room.users.find(u => u.id === data.userId);
if (currentUser) {
window.__userPermissions = currentUser.permissions || {
canControl: false,
canManageQueue: false
};
}
}
if (data.room.queue) renderQueue(data.room.queue);
updateRoomUI(data.room);
if (data.room.currentVideo) {
@@ -1109,6 +1166,7 @@ const RoomsApp = (function() {
break;
case 'users_update':
saveRoomData({ users: data.users });
renderUsersList(data.users);
break;
@@ -1127,6 +1185,32 @@ const RoomsApp = (function() {
updateUsersList();
break;
case 'permissions_updated':
saveRoomData({ users: data.users });
const updatedUser = data.users.find(u => u.id === currentUserId);
if (updatedUser) {
window.__userPermissions = updatedUser.permissions || {
canControl: false,
canManageQueue: false
};
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
if (elements.selectAnimeBtn) {
elements.selectAnimeBtn.style.display = canManageQueue ? 'flex' : 'none';
}
if (currentQueue.length > 0) {
renderQueue(currentQueue);
}
}
if (permissionsModal && permissionsModal.classList.contains('show')) {
updatePermissionsList();
}
renderUsersList(data.users);
break;
case 'chat':
addChatMessage(data);
const isChatHidden = elements.roomLayout.classList.contains('chat-hidden');
@@ -1210,33 +1294,34 @@ const RoomsApp = (function() {
return;
}
const canManageQueue = isHost || (window.__userPermissions && window.__userPermissions.canManageQueue);
elements.queueList.innerHTML = queueItems.map((item, index) => {
let actionsHtml = '';
if (isHost) {
if (canManageQueue) {
const isFirst = index === 0;
const isLast = index === queueItems.length - 1;
actionsHtml = `
<div class="q-actions">
<button class="q-btn play" onclick="playQueueItem('${item.uid}')" title="Play Now">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<div style="display:flex; gap:2px;">
${!isFirst ? `
<button class="q-btn" onclick="moveQueueItem('${item.uid}', 'up')" title="Move Up">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 15l-6-6-6 6"/></svg>
</button>` : ''}
${!isLast ? `
<button class="q-btn" onclick="moveQueueItem('${item.uid}', 'down')" title="Move Down">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>` : ''}
</div>
<button class="q-btn remove" onclick="removeQueueItem('${item.uid}')" title="Remove">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<div class="q-actions">
<button class="q-btn play" onclick="playQueueItem('${item.uid}')" title="Play Now">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<div style="display:flex; gap:2px;">
${!isFirst ? `
<button class="q-btn" onclick="moveQueueItem('${item.uid}', 'up')" title="Move Up">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 15l-6-6-6 6"/></svg>
</button>` : ''}
${!isLast ? `
<button class="q-btn" onclick="moveQueueItem('${item.uid}', 'down')" title="Move Down">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>` : ''}
</div>
`;
<button class="q-btn remove" onclick="removeQueueItem('${item.uid}')" title="Remove">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
`;
}
return `
@@ -1248,11 +1333,17 @@ const RoomsApp = (function() {
</div>
${actionsHtml}
</div>
`;
`;
}).join('');
}
window.playQueueItem = async function(uid) {
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
if (!canManageQueue) {
showSystemToast('You need queue management permission');
return;
}
const item = currentQueue.find(i => i.uid === uid);
if (!item) return;
@@ -1305,12 +1396,25 @@ const RoomsApp = (function() {
};
window.moveQueueItem = function(uid, direction) {
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
if (!canManageQueue) {
showSystemToast('You need queue management permission');
return;
}
if(ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'queue_move', itemUid: uid, direction }));
}
};
window.removeQueueItem = function(uid) {
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
if (!canManageQueue) {
showSystemToast('You need queue management permission');
return;
}
if(ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'queue_remove', itemUid: uid }));
}
@@ -1321,17 +1425,39 @@ const RoomsApp = (function() {
elements.roomViewers.textContent = `${room.users.length}`;
const currentUser = room.users.find(u => u.id === currentUserId);
if (currentUser) {
window.__userPermissions = currentUser.permissions || { canControl: false, canManageQueue: false };
}
isHost = currentUser?.isHost || false;
if (elements.selectAnimeBtn) elements.selectAnimeBtn.style.display = isHost ? 'flex' : 'none';
if (elements.hostControls) elements.hostControls.style.display = isHost ? 'flex' : 'none';
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
const canControlPlayer = isHost || (window.__userPermissions?.canControl || false);
if (elements.selectAnimeBtn) {
elements.selectAnimeBtn.style.display = canManageQueue ? 'flex' : 'none';
}
if (elements.hostControls) {
elements.hostControls.style.display = canManageQueue ? 'flex' : 'none';
}
if (window.AnimePlayer && typeof window.AnimePlayer.setRoomHost === 'function') {
window.AnimePlayer.setRoomHost(isHost);
window.AnimePlayer.setRoomHost(canControlPlayer);
}
if (isHost) {
initGlobalControls();
initPermissionsUI();
}
else if (canManageQueue) {
initGlobalControls();
}
if (canManageQueue && room.metadata) {
if(!selectedAnimeData) selectedAnimeData = { ...room.metadata, source: 'anilist' };
populateQuickControls();
}
const copyInviteBtn = document.getElementById('copy-invite-btn');
@@ -1387,7 +1513,8 @@ const RoomsApp = (function() {
}
async function initGlobalControls() {
if (!isHost || !extensionsReady) return;
const hasAccess = isHost || (window.__userPermissions?.canManageQueue || false);
if (!hasAccess || !extensionsReady) return;
if (elements.roomExtSelect.options.length > 1) return;
@@ -1820,6 +1947,274 @@ const RoomsApp = (function() {
return div.innerHTML;
}
let permissionsModal = null;
function initPermissionsUI() {
if (!isHost) return;
if (!permissionsModal) {
permissionsModal = document.createElement('div');
permissionsModal.className = 'modal-overlay';
permissionsModal.id = 'permissions-modal';
permissionsModal.innerHTML = `
<div class="modal-content permissions-content">
<button class="modal-close" onclick="closePermissionsModal()">✕</button>
<h2 class="modal-title">Manage Permissions</h2>
<div class="permissions-list" id="permissions-list">
<!-- Se llenará dinámicamente -->
</div>
<div class="banned-ips-section">
<h3 class="section-title">Banned IPs</h3>
<div id="banned-ips-list" class="banned-list">
<p class="empty-state">No banned users</p>
</div>
</div>
</div>
`;
document.body.appendChild(permissionsModal);
}
const headerRight = document.querySelector('.header-right');
if (headerRight && !document.getElementById('permissions-btn')) {
const permBtn = document.createElement('button');
permBtn.id = 'permissions-btn';
permBtn.className = 'btn-icon-glass';
permBtn.title = 'Manage Permissions';
permBtn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
`;
permBtn.onclick = openPermissionsModal;
const searchBtn = document.getElementById('select-anime-btn');
if (searchBtn) {
headerRight.insertBefore(permBtn, searchBtn);
} else {
headerRight.appendChild(permBtn);
}
}
}
function openPermissionsModal() {
if (!permissionsModal || !isHost) return;
updatePermissionsList();
permissionsModal.classList.add('show');
}
window.closePermissionsModal = function() {
if (permissionsModal) {
permissionsModal.classList.remove('show');
}
};
function updatePermissionsList() {
const list = document.getElementById('permissions-list');
if (!list) return;
const room = getCurrentRoomData();
if (!room || !room.users) {
list.innerHTML = '<p class="empty-state">No active users</p>';
return;
}
list.innerHTML = room.users
.filter(u => !u.isHost)
.map(user => createPermissionCard(user))
.join('');
}
function createPermissionCard(user) {
const perms = user.permissions || { canControl: false, canManageQueue: false };
return `
<div class="permission-card">
<div class="user-info-section">
<div class="user-avatar-small">
${user.avatar ? `<img src="${user.avatar}">` : user.username[0].toUpperCase()}
</div>
<div class="user-details">
<span class="user-name-text">${escapeHtml(user.username)}</span>
${user.isGuest ? '<span class="guest-badge">Guest</span>' : ''}
</div>
</div>
<div class="permissions-toggles">
<label class="perm-toggle" for="perm-ctrl-${user.id}">
<input type="checkbox"
id="perm-ctrl-${user.id}"
data-user-id="${user.id}"
${perms.canControl ? 'checked' : ''}
onchange="togglePermission('${user.id}', 'canControl', this.checked)">
<span class="perm-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
Playback Control
</span>
</label>
<label class="perm-toggle" for="perm-queue-${user.id}">
<input type="checkbox"
id="perm-queue-${user.id}"
data-user-id="${user.id}"
${perms.canManageQueue ? 'checked' : ''}
onchange="togglePermission('${user.id}', 'canManageQueue', this.checked)">
<span class="perm-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
Queue Management
</span>
</label>
</div>
<button class="ban-btn" onclick="banUser('${user.id}')" title="Ban User">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
</svg>
Ban
</button>
</div>
`;
}
window.togglePermission = function(userId, permission, value) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
console.log(`Toggling permission ${permission} to ${value} for user ${userId}`);
const permissions = {};
permissions[permission] = value;
ws.send(JSON.stringify({
type: 'update_permissions',
targetUserId: userId,
permissions
}));
showSystemToast(`Permission ${value ? 'granted' : 'revoked'}`);
};
window.banUser = function(userId) {
const room = getCurrentRoomData();
const user = room?.users?.find(u => u.id === userId);
if (!user) return;
const confirmed = confirm(`Ban ${user.username} from this room? They won't be able to rejoin.`);
if (!confirmed) return;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'ban_user',
targetUserId: userId
}));
}
showSystemToast(`${user.username} has been banned`);
setTimeout(() => updatePermissionsList(), 500);
};
function getCurrentRoomData() {
return window.__currentRoomData || null;
}
function saveRoomData(roomData) {
window.__currentRoomData = roomData;
if (roomData.users && currentUserId) {
const currentUser = roomData.users.find(u => u.id === currentUserId);
if (currentUser && currentUser.permissions) {
window.__userPermissions = currentUser.permissions;
console.log('Permissions updated for current user:', window.__userPermissions);
}
}
}
const originalHandleMessage = handleWebSocketMessage;
handleWebSocketMessage = function(data) {
switch(data.type) {
case 'permissions_updated':
saveRoomData({ users: data.users });
const updatedUser = data.users.find(u => u.id === currentUserId);
if (updatedUser) {
window.__userPermissions = updatedUser.permissions || {
canControl: false,
canManageQueue: false
};
console.log('My permissions updated:', window.__userPermissions);
const canManageQueue = isHost || (window.__userPermissions?.canManageQueue || false);
if (elements.selectAnimeBtn) {
elements.selectAnimeBtn.style.display = canManageQueue ? 'flex' : 'none';
}
if (elements.hostControls) {
elements.hostControls.style.display = canManageQueue ? 'flex' : 'none';
}
if (canManageQueue) {
initGlobalControls();
populateQuickControls();
}
if (!canManageQueue && elements.animeSearchModal && elements.animeSearchModal.classList.contains('show')) {
closeAnimeSearchModal();
showSystemToast("Permissions updated: Access revoked");
}
if (currentQueue && currentQueue.length > 0) {
console.log('Forcing queue re-render due to permission change');
renderQueue(currentQueue);
}
}
if (permissionsModal && permissionsModal.classList.contains('show')) {
updatePermissionsList();
}
renderUsersList(data.users);
break;
case 'banned':
alert(data.message || 'You have been banned from this room');
window.location.href = '/anime';
break;
case 'users_update':
saveRoomData({ users: data.users });
break;
default:
originalHandleMessage(data);
}
};
const originalUpdateRoomUI = updateRoomUI;
updateRoomUI = function(room) {
originalUpdateRoomUI(room);
if (isHost) {
initPermissionsUI();
}
};
return { init };
})();

View File

@@ -627,4 +627,227 @@ input[type=number] { -moz-appearance: textfield; }
.config-sidebar { width: 100%; flex-direction: row; }
.config-cover { width: 80px; }
.ep-control { width: auto; flex: 1; }
}
/* =========================================
PERMISSIONS & MODERATION
========================================= */
.permissions-content {
max-width: 700px;
width: 90%;
max-height: 85vh;
overflow-y: auto;
}
.permissions-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 30px;
}
.permission-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
transition: all 0.2s;
}
.permission-card:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.2);
}
.user-info-section {
display: flex;
align-items: center;
gap: 12px;
min-width: 180px;
}
.user-avatar-small {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--brand-gradient);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: white;
font-size: 0.9rem;
flex-shrink: 0;
}
.user-avatar-small img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.user-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-name-text {
font-weight: 600;
color: white;
font-size: 0.95rem;
}
.guest-badge {
font-size: 0.7rem;
background: rgba(255, 255, 255, 0.1);
color: #aaa;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
width: fit-content;
}
.permissions-toggles {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.perm-toggle {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.perm-toggle input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--brand-color);
}
.perm-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
transition: color 0.2s;
}
.perm-toggle:hover .perm-label {
color: white;
}
.perm-label svg {
opacity: 0.6;
}
.ban-btn {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: 8px 16px;
border-radius: 8px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
white-space: nowrap;
}
.ban-btn:hover {
background: rgba(239, 68, 68, 0.2);
border-color: #ef4444;
transform: translateY(-1px);
}
.banned-ips-section {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.section-title {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 12px;
font-weight: 700;
}
.banned-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.banned-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: rgba(239, 68, 68, 0.05);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
}
.banned-ip {
font-family: monospace;
color: #ef4444;
font-size: 0.9rem;
}
.unban-btn {
background: transparent;
border: 1px solid rgba(74, 222, 128, 0.3);
color: #4ade80;
padding: 4px 12px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.unban-btn:hover {
background: rgba(74, 222, 128, 0.1);
border-color: #4ade80;
}
.empty-state {
text-align: center;
color: #666;
font-style: italic;
padding: 20px;
}
/* Responsive */
@media (max-width: 768px) {
.permission-card {
flex-direction: column;
align-items: stretch;
}
.user-info-section {
min-width: auto;
}
.ban-btn {
width: 100%;
justify-content: center;
}
}