diff --git a/desktop/src/api/rooms/rooms.service.ts b/desktop/src/api/rooms/rooms.service.ts index 2d06c1f..193095b 100644 --- a/desktop/src/api/rooms/rooms.service.ts +++ b/desktop/src/api/rooms/rooms.service.ts @@ -7,7 +7,14 @@ interface RoomUser { avatar?: string; isHost: boolean; isGuest: boolean; - userId?: number; // ID real del usuario si está logueado + userId?: number; +} + +export interface QueueItem { + uid: string; + metadata: RoomMetadata; + videoData: any; + addedBy: string; } interface RoomMetadata { @@ -36,6 +43,7 @@ interface RoomData { metadata?: RoomMetadata | null; exposed: boolean; publicUrl?: string; + queue: QueueItem[]; } const rooms = new Map(); @@ -57,7 +65,8 @@ export function createRoom(name: string, host: RoomUser, password?: string, expo password: password || undefined, metadata: null, exposed, - publicUrl + publicUrl, + queue: [] }; rooms.set(roomId, room); @@ -75,6 +84,55 @@ export function getAllRooms(): RoomData[] { })); } +export function addQueueItem(roomId: string, item: QueueItem): boolean { + const room = rooms.get(roomId); + if (!room) return false; + room.queue.push(item); + return true; +} + +export function removeQueueItem(roomId: string, itemUid: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + room.queue = room.queue.filter(i => i.uid !== itemUid); + return true; +} + +export function getNextQueueItem(roomId: string): QueueItem | undefined { + const room = rooms.get(roomId); + if (!room || room.queue.length === 0) return undefined; + return room.queue.shift(); // Saca el primero y lo retorna +} + +export function getAndRemoveQueueItem(roomId: string, itemUid: string): QueueItem | undefined { + const room = rooms.get(roomId); + if (!room) return undefined; + + const index = room.queue.findIndex(i => i.uid === itemUid); + if (index === -1) return undefined; + + const [item] = room.queue.splice(index, 1); + return item; +} + +export function moveQueueItem(roomId: string, itemUid: string, direction: 'up' | 'down'): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + const index = room.queue.findIndex(i => i.uid === itemUid); + if (index === -1) return false; + + const newIndex = direction === 'up' ? index - 1 : index + 1; + + if (newIndex < 0 || newIndex >= room.queue.length) return false; + + const temp = room.queue[newIndex]; + room.queue[newIndex] = room.queue[index]; + room.queue[index] = temp; + + return true; +} + export function addUserToRoom(roomId: string, user: RoomUser): boolean { const room = rooms.get(roomId); if (!room) return false; diff --git a/desktop/src/api/rooms/rooms.websocket.ts b/desktop/src/api/rooms/rooms.websocket.ts index 182fee6..379b16c 100644 --- a/desktop/src/api/rooms/rooms.websocket.ts +++ b/desktop/src/api/rooms/rooms.websocket.ts @@ -155,7 +155,6 @@ async function handleWebSocketConnection(connection: any, req: any) { isGuest }); - // Enviar estado inicial socket.send(JSON.stringify({ type: 'init', userId, @@ -171,7 +170,8 @@ async function handleWebSocketConnection(connection: any, req: any) { isHost: u.isHost, isGuest: u.isGuest })), - currentVideo: room.currentVideo + currentVideo: room.currentVideo, + queue: room.queue || [] } })); @@ -226,6 +226,46 @@ function handleMessage(roomId: string, userId: string, data: any) { }); break; + case 'queue_play_item': + if (room.host.id !== userId) return; + + const itemToPlay = roomService.getAndRemoveQueueItem(roomId, data.itemUid); + if (itemToPlay) { + const videoPayload = { + videoData: itemToPlay.videoData.videoData, + subtitles: itemToPlay.videoData.subtitles, + currentTime: 0, + isPlaying: true + }; + + roomService.updateRoomVideo(roomId, videoPayload); + roomService.updateRoomMetadata(roomId, itemToPlay.metadata); + + broadcastToRoom(roomId, { + type: 'video_update', + video: videoPayload, + metadata: itemToPlay.metadata + }); + + broadcastToRoom(roomId, { + type: 'queue_update', + queue: room.queue + }); + } + break; + + case 'queue_move': + if (room.host.id !== userId) return; + + const moved = roomService.moveQueueItem(roomId, data.itemUid, data.direction); + if (moved) { + broadcastToRoom(roomId, { + type: 'queue_update', + queue: room.queue + }); + } + break; + case 'request_sync': // Cualquier usuario puede pedir sync const host = clients.get(room.host.id); @@ -375,6 +415,63 @@ 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; + + 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 + 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 + }); + break; + + case 'queue_remove': + if (room.host.id !== userId) return; + roomService.removeQueueItem(roomId, data.itemUid); + broadcastToRoom(roomId, { + type: 'queue_update', + queue: room.queue + }); + break; + + case 'play_next': + if (room.host.id !== userId) return; + + const nextItem = roomService.getNextQueueItem(roomId); + if (nextItem) { + const videoPayload = { + videoData: nextItem.videoData.videoData, + subtitles: nextItem.videoData.subtitles, + currentTime: 0, + isPlaying: true + }; + + roomService.updateRoomVideo(roomId, videoPayload); + roomService.updateRoomMetadata(roomId, nextItem.metadata); + + broadcastToRoom(roomId, { + type: 'video_update', + video: videoPayload, + metadata: nextItem.metadata + }); + + broadcastToRoom(roomId, { + type: 'queue_update', + queue: room.queue + }); + } + break; + default: console.warn('Unknown message type:', data.type); break; diff --git a/desktop/src/scripts/room-modal.js b/desktop/src/scripts/room-modal.js index 82362ab..3a22f86 100644 --- a/desktop/src/scripts/room-modal.js +++ b/desktop/src/scripts/room-modal.js @@ -118,13 +118,7 @@ class CreateRoomModal { this.close(); - // REDIRECCIÓN: - // Si estamos en la página de rooms, recargamos o dejamos que el socket actualice. - // Si estamos en otra página, vamos a la sala creada. - // Asumo que tu ruta de sala es /room (o query params). - // Ajusta esta línea según tu router: - window.location.href = `/room?id=${data.room.id}`; - + window.open(`/room?id=${data.room.id}`, '_blank', 'noopener,noreferrer'); } catch (err) { alert(err.message); } finally { diff --git a/desktop/src/scripts/room.js b/desktop/src/scripts/room.js index eb69f58..3aa60a7 100644 --- a/desktop/src/scripts/room.js +++ b/desktop/src/scripts/room.js @@ -81,6 +81,14 @@ const RoomsApp = (function() { configError: document.getElementById('config-error'), toastContainer: document.getElementById('video-toast-container'), + + tabChatBtn: document.getElementById('tab-chat-btn'), + tabQueueBtn: document.getElementById('tab-queue-btn'), + tabContentChat: document.getElementById('tab-content-chat'), + tabContentQueue: document.getElementById('tab-content-queue'), + queueList: document.getElementById('queue-list'), + queueCount: document.getElementById('queue-count'), + btnAddQueue: document.getElementById('btn-add-queue'), }; const ui = { @@ -193,6 +201,11 @@ const RoomsApp = (function() { if (elements.roomExtSelect) elements.roomExtSelect.onchange = (e) => onQuickExtensionChange(e, false); if (elements.roomServerSelect) elements.roomServerSelect.onchange = onQuickServerChange; + elements.tabChatBtn.onclick = () => switchTab('chat'); + elements.tabQueueBtn.onclick = () => switchTab('queue'); + + if(elements.btnAddQueue) elements.btnAddQueue.onclick = () => launchStream(true, true); + if (elements.roomSdToggle) { elements.roomSdToggle.onclick = () => { if (!isHost) return; @@ -241,6 +254,16 @@ const RoomsApp = (function() { } } + function switchTab(tab) { + elements.tabChatBtn.classList.toggle('active', tab === 'chat'); + elements.tabQueueBtn.classList.toggle('active', tab === 'queue'); + elements.tabContentChat.style.display = tab === 'chat' ? 'flex' : 'none'; + elements.tabContentQueue.style.display = tab === 'queue' ? 'flex' : 'none'; + + if(tab === 'queue') elements.chatForm.style.display = 'none'; + else elements.chatForm.style.display = 'flex'; + } + // --- QUICK CONTROLS LOGIC (Header) --- async function populateQuickControls() { @@ -376,6 +399,7 @@ const RoomsApp = (function() { if(ui.epInput) ui.epInput.value = 1; if(ui.launchBtn) ui.launchBtn.disabled = true; + if(elements.btnAddQueue) elements.btnAddQueue.disabled = true; updateSDUI(); setupConfigListeners(); @@ -439,6 +463,7 @@ const RoomsApp = (function() { configState.extension = ext; configState.server = null; ui.launchBtn.disabled = true; + if(elements.btnAddQueue) elements.btnAddQueue.disabled = true; loadServersForExtension(ext); }; @@ -452,6 +477,7 @@ const RoomsApp = (function() { if (!extensionsReady) return; ui.serverContainer.innerHTML = '
Loading servers...
'; ui.launchBtn.disabled = true; + if(elements.btnAddQueue) elements.btnAddQueue.disabled = true; try { const settings = extensionsStore.settings[extName]; @@ -496,6 +522,7 @@ const RoomsApp = (function() { configState.server = srv; ui.launchBtn.disabled = false; + if(elements.btnAddQueue) elements.btnAddQueue.disabled = false; }; ui.serverContainer.appendChild(chip); @@ -543,7 +570,7 @@ const RoomsApp = (function() { // --- STREAM LAUNCHER (Unified) --- - async function launchStream(fromModal = false) { + async function launchStream(fromModal = false, isQueueAction = false) { if (!selectedAnimeData) { console.warn("No anime selected data found"); return; @@ -556,6 +583,10 @@ const RoomsApp = (function() { server = configState.server; episode = configState.episode; category = configState.category; + if(isQueueAction) { + elements.btnAddQueue.disabled = true; + elements.btnAddQueue.textContent = 'Adding...'; + } } else { ext = elements.roomExtSelect.value; server = elements.roomServerSelect.value; @@ -585,12 +616,6 @@ const RoomsApp = (function() { return; } - if(fromModal) { - elements.btnLaunch.disabled = true; - elements.btnLaunch.innerHTML = '
Fetching...'; - elements.configError.style.display = 'none'; - } - try { const apiUrl = `/api/watch/stream?animeId=${selectedAnimeData.id}&episode=${episode}&server=${encodeURIComponent(server)}&category=${category}&ext=${ext}&source=${selectedAnimeData.source}`; console.log('Fetching stream:', apiUrl); @@ -614,28 +639,36 @@ const RoomsApp = (function() { })); const videoPayload = { - type: 'video_update', - video: { - videoData: { - url: proxyUrl, - type: source.type || 'm3u8', - headers: headers - }, - subtitles: subtitles, - currentTime: 0, - isPlaying: true - }, - metadata: { - title: selectedAnimeData.title, - episode: episode, - image: selectedAnimeData.image, - id: selectedAnimeData.id - } + videoData: { url: proxyUrl, type: source.type || 'm3u8', headers: headers }, + subtitles: subtitles + }; + + const metaPayload = { + title: selectedAnimeData.title, + episode: episode, + image: selectedAnimeData.image, + id: selectedAnimeData.id, + source: ext }; if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(videoPayload)); + if (isQueueAction) { + ws.send(JSON.stringify({ + type: 'queue_add', + video: videoPayload, + metadata: metaPayload + })); + showSystemToast("Added to queue!"); + if(fromModal) closeAnimeSearchModal(); + + } else { + ws.send(JSON.stringify({ + type: 'video_update', + video: { ...videoPayload, currentTime: 0, isPlaying: true }, + metadata: metaPayload + })); + } loadVideo(videoPayload.video); updateHeaderInfo(videoPayload.metadata); @@ -669,9 +702,9 @@ const RoomsApp = (function() { alert(msg); } } finally { - if(fromModal) { - elements.btnLaunch.disabled = false; - elements.btnLaunch.innerHTML = 'Play in Room'; + if(fromModal && isQueueAction) { + elements.btnAddQueue.disabled = false; + elements.btnAddQueue.textContent = '+ Add to Queue'; } } } @@ -817,7 +850,7 @@ const RoomsApp = (function() { case 'init': const reconnectToast = document.getElementById('reconnecting-toast'); if (reconnectToast) reconnectToast.remove(); - + if (data.room.queue) renderQueue(data.room.queue); elements.joinRoomModal.classList.remove('show'); currentUserId = data.userId; currentUsername = data.username; @@ -842,6 +875,10 @@ const RoomsApp = (function() { } break; + case 'queue_update': + renderQueue(data.queue); + break; + case 'users_update': renderUsersList(data.users); break; @@ -932,6 +969,77 @@ const RoomsApp = (function() { } } + function renderQueue(queueItems) { + if (!elements.queueList) return; + + elements.queueCount.textContent = queueItems.length; + + if (queueItems.length === 0) { + elements.queueList.innerHTML = '
Queue is empty
'; + return; + } + + elements.queueList.innerHTML = queueItems.map((item, index) => { + let actionsHtml = ''; + + if (isHost) { + const isFirst = index === 0; + const isLast = index === queueItems.length - 1; + + actionsHtml = ` +
+ +
+ ${!isFirst ? ` + ` : ''} + ${!isLast ? ` + ` : ''} +
+ +
+ `; + } + + return ` +
+ +
+
${escapeHtml(item.metadata.title)}
+
Ep ${item.metadata.episode} • ${escapeHtml(item.addedBy)}
+
+ ${actionsHtml} +
+ `; + }).join(''); + } + + // Funciones globales (window) para los botones onclick + window.playQueueItem = function(uid) { + if(ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'queue_play_item', itemUid: uid })); + } + }; + + window.moveQueueItem = function(uid, direction) { + if(ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'queue_move', itemUid: uid, direction })); + } + }; + + window.removeQueueItem = function(uid) { + if(ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'queue_remove', itemUid: uid })); + } + }; + function updateRoomUI(room) { elements.roomName.textContent = room.name; elements.roomViewers.textContent = `${room.users.length}`; @@ -1361,6 +1469,16 @@ const RoomsApp = (function() { if(elements.timeDisplay) elements.timeDisplay.textContent = formatTime(video.currentTime) + " / " + formatTime(video.duration); if(elements.progressPlayed) elements.progressPlayed.style.width = (video.currentTime/video.duration*100) + "%"; }); + video.addEventListener('ended', () => { + if (isHost) { + console.log('Video ended. Checking queue...'); + setTimeout(() => { + if(ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'play_next' })); + } + }, 1000); + } + }); } function formatTime(s) { diff --git a/desktop/views/css/room.css b/desktop/views/css/room.css index f982a59..2a2c2fc 100644 --- a/desktop/views/css/room.css +++ b/desktop/views/css/room.css @@ -637,29 +637,27 @@ input[type=number]::-webkit-outer-spin-button { margin: 0; } input[type=number] { - -moz-appearance: textfield; /* Firefox */ + -moz-appearance: textfield; } -/* Enhanced Episode Control Container */ .ep-control { display: flex; align-items: center; justify-content: space-between; - background: rgba(0, 0, 0, 0.4); /* Darker background for visibility */ - border: 1px solid rgba(255, 255, 255, 0.2); /* brighter border */ + background: rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 12px; padding: 4px; width: 100%; - height: 48px; /* Fixed height */ + height: 48px; margin-top: 8px; box-shadow: inset 0 2px 4px rgba(0,0,0,0.2); } -/* Button Styling */ .ep-btn { - width: 40px; /* Fixed width */ - height: 38px; /* Fixed height */ - flex-shrink: 0; /* Prevent shrinking */ + width: 40px; + height: 38px; + flex-shrink: 0; display: flex; align-items: center; justify-content: center; @@ -682,10 +680,9 @@ input[type=number] { transform: translateY(1px); } -/* Input Styling */ .ep-input { flex: 1; - min-width: 0; /* Allows flex item to shrink below content size */ + min-width: 0; background: transparent; border: none; color: white; @@ -693,7 +690,7 @@ input[type=number] { font-size: 1.2rem; font-weight: 800; outline: none; - font-family: monospace; /* Better number alignment */ + font-family: monospace; } .ep-input:focus { @@ -977,4 +974,183 @@ input[type=number] { transform: translate(-50%, -50%) scale(1); opacity: 1; } +} + +.sidebar-tabs { + display: flex; + border-bottom: 1px solid var(--glass-border); + background: rgba(0,0,0,0.2); +} + +.tab-btn { + flex: 1; + background: transparent; + border: none; + color: var(--text-muted); + padding: 14px; + font-weight: 600; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; +} + +.tab-btn:hover { color: white; background: rgba(255,255,255,0.05); } +.tab-btn.active { color: white; border-bottom-color: var(--brand-color); background: rgba(255,255,255,0.02); } + +.tab-content { + display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; +} + +.queue-list { + padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; +} + +.queue-item { + display: flex; gap: 10px; padding: 10px; + background: rgba(255,255,255,0.05); border-radius: 8px; + border: 1px solid transparent; position: relative; +} + +.queue-item:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.1); } + +.q-img { width: 50px; height: 70px; object-fit: cover; border-radius: 4px; } +.q-info { flex: 1; display: flex; flex-direction: column; justify-content: center; overflow: hidden; } +.q-title { font-weight: 700; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.q-meta { font-size: 0.8rem; color: #aaa; } +.q-remove { + position: absolute; top: 5px; right: 5px; + background: rgba(0,0,0,0.5); border: none; color: #ff6b6b; + width: 24px; height: 24px; border-radius: 4px; cursor: pointer; + display: flex; align-items: center; justify-content: center; +} +.q-remove:hover { background: #ff6b6b; color: white; } + +.badge { background: var(--brand-color); padding: 1px 6px; border-radius: 10px; font-size: 0.7rem; margin-left: 6px; } +.queue-empty { text-align: center; color: #666; margin-top: 40px; font-style: italic; } + +.chat-sidebar-wrapper { + display: flex; + flex-direction: column; + height: 100vh; + background: rgba(15, 15, 15, 0.95); + border-left: 1px solid var(--glass-border); + overflow: hidden; +} + +@media (max-width: 1200px) { + .chat-sidebar-wrapper { + height: 400px; + border-left: none; + border-top: 1px solid var(--glass-border); + } +} + +.queue-item { + display: flex; + gap: 10px; + padding: 10px; + background: rgba(255,255,255,0.05); + border-radius: 8px; + border: 1px solid transparent; + position: relative; + align-items: center; + transition: transform 0.2s; +} + +.queue-item:hover { + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.1); +} + +.q-img { width: 50px; height: 70px; object-fit: cover; border-radius: 4px; flex-shrink: 0; } +.q-info { flex: 1; display: flex; flex-direction: column; justify-content: center; overflow: hidden; } + +.q-actions { + display: flex; + flex-direction: column; + gap: 4px; + opacity: 0.7; + transition: opacity 0.2s; +} +.queue-item:hover .q-actions { opacity: 1; } + +.q-btn { + background: rgba(255,255,255,0.1); + border: none; + color: white; + width: 28px; + height: 28px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.q-btn:hover { background: rgba(255,255,255,0.2); transform: scale(1.1); } +.q-btn.play:hover { background: var(--brand-color); } +.q-btn.remove:hover { background: #ff6b6b; } + +.chat-sidebar { + display: flex; + flex-direction: column; + height: 100%; + background: rgba(15, 15, 15, 0.95); + border-left: 1px solid var(--glass-border); + overflow: hidden; +} + +.sidebar-tabs { + display: flex; + width: 100%; + flex: 0 0 auto; + height: 50px; + border-bottom: 1px solid var(--glass-border); + background: rgba(0,0,0,0.2); +} + +.tab-btn { + flex: 1; + height: 100%; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--text-muted); + font-weight: 600; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; +} + +.tab-btn:hover { background: rgba(255,255,255,0.05); color: white; } +.tab-btn.active { border-bottom-color: var(--brand-color); color: white; background: rgba(255,255,255,0.02); } + +.tab-content { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.queue-list { + flex: 1; + overflow-y: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; } \ No newline at end of file diff --git a/desktop/views/room.html b/desktop/views/room.html index 1ba9a2f..d72de7b 100644 --- a/desktop/views/room.html +++ b/desktop/views/room.html @@ -33,9 +33,6 @@
-

Loading...

@@ -140,27 +137,36 @@
- -
-
-

Chat

- +
+ - -
-
- - -
+
+
+ +
+ +
+
+ + +
+
+
@@ -256,10 +262,14 @@ -
- + +
diff --git a/docker/src/api/rooms/rooms.service.ts b/docker/src/api/rooms/rooms.service.ts index 2d06c1f..193095b 100644 --- a/docker/src/api/rooms/rooms.service.ts +++ b/docker/src/api/rooms/rooms.service.ts @@ -7,7 +7,14 @@ interface RoomUser { avatar?: string; isHost: boolean; isGuest: boolean; - userId?: number; // ID real del usuario si está logueado + userId?: number; +} + +export interface QueueItem { + uid: string; + metadata: RoomMetadata; + videoData: any; + addedBy: string; } interface RoomMetadata { @@ -36,6 +43,7 @@ interface RoomData { metadata?: RoomMetadata | null; exposed: boolean; publicUrl?: string; + queue: QueueItem[]; } const rooms = new Map(); @@ -57,7 +65,8 @@ export function createRoom(name: string, host: RoomUser, password?: string, expo password: password || undefined, metadata: null, exposed, - publicUrl + publicUrl, + queue: [] }; rooms.set(roomId, room); @@ -75,6 +84,55 @@ export function getAllRooms(): RoomData[] { })); } +export function addQueueItem(roomId: string, item: QueueItem): boolean { + const room = rooms.get(roomId); + if (!room) return false; + room.queue.push(item); + return true; +} + +export function removeQueueItem(roomId: string, itemUid: string): boolean { + const room = rooms.get(roomId); + if (!room) return false; + room.queue = room.queue.filter(i => i.uid !== itemUid); + return true; +} + +export function getNextQueueItem(roomId: string): QueueItem | undefined { + const room = rooms.get(roomId); + if (!room || room.queue.length === 0) return undefined; + return room.queue.shift(); // Saca el primero y lo retorna +} + +export function getAndRemoveQueueItem(roomId: string, itemUid: string): QueueItem | undefined { + const room = rooms.get(roomId); + if (!room) return undefined; + + const index = room.queue.findIndex(i => i.uid === itemUid); + if (index === -1) return undefined; + + const [item] = room.queue.splice(index, 1); + return item; +} + +export function moveQueueItem(roomId: string, itemUid: string, direction: 'up' | 'down'): boolean { + const room = rooms.get(roomId); + if (!room) return false; + + const index = room.queue.findIndex(i => i.uid === itemUid); + if (index === -1) return false; + + const newIndex = direction === 'up' ? index - 1 : index + 1; + + if (newIndex < 0 || newIndex >= room.queue.length) return false; + + const temp = room.queue[newIndex]; + room.queue[newIndex] = room.queue[index]; + room.queue[index] = temp; + + return true; +} + export function addUserToRoom(roomId: string, user: RoomUser): boolean { const room = rooms.get(roomId); if (!room) return false; diff --git a/docker/src/api/rooms/rooms.websocket.ts b/docker/src/api/rooms/rooms.websocket.ts index 182fee6..379b16c 100644 --- a/docker/src/api/rooms/rooms.websocket.ts +++ b/docker/src/api/rooms/rooms.websocket.ts @@ -155,7 +155,6 @@ async function handleWebSocketConnection(connection: any, req: any) { isGuest }); - // Enviar estado inicial socket.send(JSON.stringify({ type: 'init', userId, @@ -171,7 +170,8 @@ async function handleWebSocketConnection(connection: any, req: any) { isHost: u.isHost, isGuest: u.isGuest })), - currentVideo: room.currentVideo + currentVideo: room.currentVideo, + queue: room.queue || [] } })); @@ -226,6 +226,46 @@ function handleMessage(roomId: string, userId: string, data: any) { }); break; + case 'queue_play_item': + if (room.host.id !== userId) return; + + const itemToPlay = roomService.getAndRemoveQueueItem(roomId, data.itemUid); + if (itemToPlay) { + const videoPayload = { + videoData: itemToPlay.videoData.videoData, + subtitles: itemToPlay.videoData.subtitles, + currentTime: 0, + isPlaying: true + }; + + roomService.updateRoomVideo(roomId, videoPayload); + roomService.updateRoomMetadata(roomId, itemToPlay.metadata); + + broadcastToRoom(roomId, { + type: 'video_update', + video: videoPayload, + metadata: itemToPlay.metadata + }); + + broadcastToRoom(roomId, { + type: 'queue_update', + queue: room.queue + }); + } + break; + + case 'queue_move': + if (room.host.id !== userId) return; + + const moved = roomService.moveQueueItem(roomId, data.itemUid, data.direction); + if (moved) { + broadcastToRoom(roomId, { + type: 'queue_update', + queue: room.queue + }); + } + break; + case 'request_sync': // Cualquier usuario puede pedir sync const host = clients.get(room.host.id); @@ -375,6 +415,63 @@ 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; + + 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 + 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 + }); + break; + + case 'queue_remove': + if (room.host.id !== userId) return; + roomService.removeQueueItem(roomId, data.itemUid); + broadcastToRoom(roomId, { + type: 'queue_update', + queue: room.queue + }); + break; + + case 'play_next': + if (room.host.id !== userId) return; + + const nextItem = roomService.getNextQueueItem(roomId); + if (nextItem) { + const videoPayload = { + videoData: nextItem.videoData.videoData, + subtitles: nextItem.videoData.subtitles, + currentTime: 0, + isPlaying: true + }; + + roomService.updateRoomVideo(roomId, videoPayload); + roomService.updateRoomMetadata(roomId, nextItem.metadata); + + broadcastToRoom(roomId, { + type: 'video_update', + video: videoPayload, + metadata: nextItem.metadata + }); + + broadcastToRoom(roomId, { + type: 'queue_update', + queue: room.queue + }); + } + break; + default: console.warn('Unknown message type:', data.type); break; diff --git a/docker/src/scripts/room-modal.js b/docker/src/scripts/room-modal.js index 82362ab..3a22f86 100644 --- a/docker/src/scripts/room-modal.js +++ b/docker/src/scripts/room-modal.js @@ -118,13 +118,7 @@ class CreateRoomModal { this.close(); - // REDIRECCIÓN: - // Si estamos en la página de rooms, recargamos o dejamos que el socket actualice. - // Si estamos en otra página, vamos a la sala creada. - // Asumo que tu ruta de sala es /room (o query params). - // Ajusta esta línea según tu router: - window.location.href = `/room?id=${data.room.id}`; - + window.open(`/room?id=${data.room.id}`, '_blank', 'noopener,noreferrer'); } catch (err) { alert(err.message); } finally { diff --git a/docker/src/scripts/room.js b/docker/src/scripts/room.js index eb69f58..3aa60a7 100644 --- a/docker/src/scripts/room.js +++ b/docker/src/scripts/room.js @@ -81,6 +81,14 @@ const RoomsApp = (function() { configError: document.getElementById('config-error'), toastContainer: document.getElementById('video-toast-container'), + + tabChatBtn: document.getElementById('tab-chat-btn'), + tabQueueBtn: document.getElementById('tab-queue-btn'), + tabContentChat: document.getElementById('tab-content-chat'), + tabContentQueue: document.getElementById('tab-content-queue'), + queueList: document.getElementById('queue-list'), + queueCount: document.getElementById('queue-count'), + btnAddQueue: document.getElementById('btn-add-queue'), }; const ui = { @@ -193,6 +201,11 @@ const RoomsApp = (function() { if (elements.roomExtSelect) elements.roomExtSelect.onchange = (e) => onQuickExtensionChange(e, false); if (elements.roomServerSelect) elements.roomServerSelect.onchange = onQuickServerChange; + elements.tabChatBtn.onclick = () => switchTab('chat'); + elements.tabQueueBtn.onclick = () => switchTab('queue'); + + if(elements.btnAddQueue) elements.btnAddQueue.onclick = () => launchStream(true, true); + if (elements.roomSdToggle) { elements.roomSdToggle.onclick = () => { if (!isHost) return; @@ -241,6 +254,16 @@ const RoomsApp = (function() { } } + function switchTab(tab) { + elements.tabChatBtn.classList.toggle('active', tab === 'chat'); + elements.tabQueueBtn.classList.toggle('active', tab === 'queue'); + elements.tabContentChat.style.display = tab === 'chat' ? 'flex' : 'none'; + elements.tabContentQueue.style.display = tab === 'queue' ? 'flex' : 'none'; + + if(tab === 'queue') elements.chatForm.style.display = 'none'; + else elements.chatForm.style.display = 'flex'; + } + // --- QUICK CONTROLS LOGIC (Header) --- async function populateQuickControls() { @@ -376,6 +399,7 @@ const RoomsApp = (function() { if(ui.epInput) ui.epInput.value = 1; if(ui.launchBtn) ui.launchBtn.disabled = true; + if(elements.btnAddQueue) elements.btnAddQueue.disabled = true; updateSDUI(); setupConfigListeners(); @@ -439,6 +463,7 @@ const RoomsApp = (function() { configState.extension = ext; configState.server = null; ui.launchBtn.disabled = true; + if(elements.btnAddQueue) elements.btnAddQueue.disabled = true; loadServersForExtension(ext); }; @@ -452,6 +477,7 @@ const RoomsApp = (function() { if (!extensionsReady) return; ui.serverContainer.innerHTML = '
Loading servers...
'; ui.launchBtn.disabled = true; + if(elements.btnAddQueue) elements.btnAddQueue.disabled = true; try { const settings = extensionsStore.settings[extName]; @@ -496,6 +522,7 @@ const RoomsApp = (function() { configState.server = srv; ui.launchBtn.disabled = false; + if(elements.btnAddQueue) elements.btnAddQueue.disabled = false; }; ui.serverContainer.appendChild(chip); @@ -543,7 +570,7 @@ const RoomsApp = (function() { // --- STREAM LAUNCHER (Unified) --- - async function launchStream(fromModal = false) { + async function launchStream(fromModal = false, isQueueAction = false) { if (!selectedAnimeData) { console.warn("No anime selected data found"); return; @@ -556,6 +583,10 @@ const RoomsApp = (function() { server = configState.server; episode = configState.episode; category = configState.category; + if(isQueueAction) { + elements.btnAddQueue.disabled = true; + elements.btnAddQueue.textContent = 'Adding...'; + } } else { ext = elements.roomExtSelect.value; server = elements.roomServerSelect.value; @@ -585,12 +616,6 @@ const RoomsApp = (function() { return; } - if(fromModal) { - elements.btnLaunch.disabled = true; - elements.btnLaunch.innerHTML = '
Fetching...'; - elements.configError.style.display = 'none'; - } - try { const apiUrl = `/api/watch/stream?animeId=${selectedAnimeData.id}&episode=${episode}&server=${encodeURIComponent(server)}&category=${category}&ext=${ext}&source=${selectedAnimeData.source}`; console.log('Fetching stream:', apiUrl); @@ -614,28 +639,36 @@ const RoomsApp = (function() { })); const videoPayload = { - type: 'video_update', - video: { - videoData: { - url: proxyUrl, - type: source.type || 'm3u8', - headers: headers - }, - subtitles: subtitles, - currentTime: 0, - isPlaying: true - }, - metadata: { - title: selectedAnimeData.title, - episode: episode, - image: selectedAnimeData.image, - id: selectedAnimeData.id - } + videoData: { url: proxyUrl, type: source.type || 'm3u8', headers: headers }, + subtitles: subtitles + }; + + const metaPayload = { + title: selectedAnimeData.title, + episode: episode, + image: selectedAnimeData.image, + id: selectedAnimeData.id, + source: ext }; if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(videoPayload)); + if (isQueueAction) { + ws.send(JSON.stringify({ + type: 'queue_add', + video: videoPayload, + metadata: metaPayload + })); + showSystemToast("Added to queue!"); + if(fromModal) closeAnimeSearchModal(); + + } else { + ws.send(JSON.stringify({ + type: 'video_update', + video: { ...videoPayload, currentTime: 0, isPlaying: true }, + metadata: metaPayload + })); + } loadVideo(videoPayload.video); updateHeaderInfo(videoPayload.metadata); @@ -669,9 +702,9 @@ const RoomsApp = (function() { alert(msg); } } finally { - if(fromModal) { - elements.btnLaunch.disabled = false; - elements.btnLaunch.innerHTML = 'Play in Room'; + if(fromModal && isQueueAction) { + elements.btnAddQueue.disabled = false; + elements.btnAddQueue.textContent = '+ Add to Queue'; } } } @@ -817,7 +850,7 @@ const RoomsApp = (function() { case 'init': const reconnectToast = document.getElementById('reconnecting-toast'); if (reconnectToast) reconnectToast.remove(); - + if (data.room.queue) renderQueue(data.room.queue); elements.joinRoomModal.classList.remove('show'); currentUserId = data.userId; currentUsername = data.username; @@ -842,6 +875,10 @@ const RoomsApp = (function() { } break; + case 'queue_update': + renderQueue(data.queue); + break; + case 'users_update': renderUsersList(data.users); break; @@ -932,6 +969,77 @@ const RoomsApp = (function() { } } + function renderQueue(queueItems) { + if (!elements.queueList) return; + + elements.queueCount.textContent = queueItems.length; + + if (queueItems.length === 0) { + elements.queueList.innerHTML = '
Queue is empty
'; + return; + } + + elements.queueList.innerHTML = queueItems.map((item, index) => { + let actionsHtml = ''; + + if (isHost) { + const isFirst = index === 0; + const isLast = index === queueItems.length - 1; + + actionsHtml = ` +
+ +
+ ${!isFirst ? ` + ` : ''} + ${!isLast ? ` + ` : ''} +
+ +
+ `; + } + + return ` +
+ +
+
${escapeHtml(item.metadata.title)}
+
Ep ${item.metadata.episode} • ${escapeHtml(item.addedBy)}
+
+ ${actionsHtml} +
+ `; + }).join(''); + } + + // Funciones globales (window) para los botones onclick + window.playQueueItem = function(uid) { + if(ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'queue_play_item', itemUid: uid })); + } + }; + + window.moveQueueItem = function(uid, direction) { + if(ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'queue_move', itemUid: uid, direction })); + } + }; + + window.removeQueueItem = function(uid) { + if(ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'queue_remove', itemUid: uid })); + } + }; + function updateRoomUI(room) { elements.roomName.textContent = room.name; elements.roomViewers.textContent = `${room.users.length}`; @@ -1361,6 +1469,16 @@ const RoomsApp = (function() { if(elements.timeDisplay) elements.timeDisplay.textContent = formatTime(video.currentTime) + " / " + formatTime(video.duration); if(elements.progressPlayed) elements.progressPlayed.style.width = (video.currentTime/video.duration*100) + "%"; }); + video.addEventListener('ended', () => { + if (isHost) { + console.log('Video ended. Checking queue...'); + setTimeout(() => { + if(ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'play_next' })); + } + }, 1000); + } + }); } function formatTime(s) { diff --git a/docker/views/css/room.css b/docker/views/css/room.css index f982a59..2a2c2fc 100644 --- a/docker/views/css/room.css +++ b/docker/views/css/room.css @@ -637,29 +637,27 @@ input[type=number]::-webkit-outer-spin-button { margin: 0; } input[type=number] { - -moz-appearance: textfield; /* Firefox */ + -moz-appearance: textfield; } -/* Enhanced Episode Control Container */ .ep-control { display: flex; align-items: center; justify-content: space-between; - background: rgba(0, 0, 0, 0.4); /* Darker background for visibility */ - border: 1px solid rgba(255, 255, 255, 0.2); /* brighter border */ + background: rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 12px; padding: 4px; width: 100%; - height: 48px; /* Fixed height */ + height: 48px; margin-top: 8px; box-shadow: inset 0 2px 4px rgba(0,0,0,0.2); } -/* Button Styling */ .ep-btn { - width: 40px; /* Fixed width */ - height: 38px; /* Fixed height */ - flex-shrink: 0; /* Prevent shrinking */ + width: 40px; + height: 38px; + flex-shrink: 0; display: flex; align-items: center; justify-content: center; @@ -682,10 +680,9 @@ input[type=number] { transform: translateY(1px); } -/* Input Styling */ .ep-input { flex: 1; - min-width: 0; /* Allows flex item to shrink below content size */ + min-width: 0; background: transparent; border: none; color: white; @@ -693,7 +690,7 @@ input[type=number] { font-size: 1.2rem; font-weight: 800; outline: none; - font-family: monospace; /* Better number alignment */ + font-family: monospace; } .ep-input:focus { @@ -977,4 +974,183 @@ input[type=number] { transform: translate(-50%, -50%) scale(1); opacity: 1; } +} + +.sidebar-tabs { + display: flex; + border-bottom: 1px solid var(--glass-border); + background: rgba(0,0,0,0.2); +} + +.tab-btn { + flex: 1; + background: transparent; + border: none; + color: var(--text-muted); + padding: 14px; + font-weight: 600; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; +} + +.tab-btn:hover { color: white; background: rgba(255,255,255,0.05); } +.tab-btn.active { color: white; border-bottom-color: var(--brand-color); background: rgba(255,255,255,0.02); } + +.tab-content { + display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; +} + +.queue-list { + padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; +} + +.queue-item { + display: flex; gap: 10px; padding: 10px; + background: rgba(255,255,255,0.05); border-radius: 8px; + border: 1px solid transparent; position: relative; +} + +.queue-item:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.1); } + +.q-img { width: 50px; height: 70px; object-fit: cover; border-radius: 4px; } +.q-info { flex: 1; display: flex; flex-direction: column; justify-content: center; overflow: hidden; } +.q-title { font-weight: 700; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.q-meta { font-size: 0.8rem; color: #aaa; } +.q-remove { + position: absolute; top: 5px; right: 5px; + background: rgba(0,0,0,0.5); border: none; color: #ff6b6b; + width: 24px; height: 24px; border-radius: 4px; cursor: pointer; + display: flex; align-items: center; justify-content: center; +} +.q-remove:hover { background: #ff6b6b; color: white; } + +.badge { background: var(--brand-color); padding: 1px 6px; border-radius: 10px; font-size: 0.7rem; margin-left: 6px; } +.queue-empty { text-align: center; color: #666; margin-top: 40px; font-style: italic; } + +.chat-sidebar-wrapper { + display: flex; + flex-direction: column; + height: 100vh; + background: rgba(15, 15, 15, 0.95); + border-left: 1px solid var(--glass-border); + overflow: hidden; +} + +@media (max-width: 1200px) { + .chat-sidebar-wrapper { + height: 400px; + border-left: none; + border-top: 1px solid var(--glass-border); + } +} + +.queue-item { + display: flex; + gap: 10px; + padding: 10px; + background: rgba(255,255,255,0.05); + border-radius: 8px; + border: 1px solid transparent; + position: relative; + align-items: center; + transition: transform 0.2s; +} + +.queue-item:hover { + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.1); +} + +.q-img { width: 50px; height: 70px; object-fit: cover; border-radius: 4px; flex-shrink: 0; } +.q-info { flex: 1; display: flex; flex-direction: column; justify-content: center; overflow: hidden; } + +.q-actions { + display: flex; + flex-direction: column; + gap: 4px; + opacity: 0.7; + transition: opacity 0.2s; +} +.queue-item:hover .q-actions { opacity: 1; } + +.q-btn { + background: rgba(255,255,255,0.1); + border: none; + color: white; + width: 28px; + height: 28px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.q-btn:hover { background: rgba(255,255,255,0.2); transform: scale(1.1); } +.q-btn.play:hover { background: var(--brand-color); } +.q-btn.remove:hover { background: #ff6b6b; } + +.chat-sidebar { + display: flex; + flex-direction: column; + height: 100%; + background: rgba(15, 15, 15, 0.95); + border-left: 1px solid var(--glass-border); + overflow: hidden; +} + +.sidebar-tabs { + display: flex; + width: 100%; + flex: 0 0 auto; + height: 50px; + border-bottom: 1px solid var(--glass-border); + background: rgba(0,0,0,0.2); +} + +.tab-btn { + flex: 1; + height: 100%; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--text-muted); + font-weight: 600; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; +} + +.tab-btn:hover { background: rgba(255,255,255,0.05); color: white; } +.tab-btn.active { border-bottom-color: var(--brand-color); color: white; background: rgba(255,255,255,0.02); } + +.tab-content { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.queue-list { + flex: 1; + overflow-y: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; } \ No newline at end of file diff --git a/docker/views/room.html b/docker/views/room.html index a4d683c..ed4f349 100644 --- a/docker/views/room.html +++ b/docker/views/room.html @@ -21,9 +21,6 @@
-

Loading...

@@ -128,27 +125,36 @@
- -
-
-

Chat

- +
+ - -
-
- - -
+
+
+ +
+ +
+
+ + +
+
+
@@ -244,10 +250,14 @@ -
- + +