+
+
+
+
-
-
Audio
-
-
Subtitles
-
Dubbed
+
+
+
+
+
-
-
-
Select Server
-
-
Select a source first
+
+
+
-
-
-
-
diff --git a/docker/src/api/rooms/rooms.service.ts b/docker/src/api/rooms/rooms.service.ts
index 193095b..4d6cf95 100644
--- a/docker/src/api/rooms/rooms.service.ts
+++ b/docker/src/api/rooms/rooms.service.ts
@@ -10,6 +10,15 @@ interface RoomUser {
userId?: number;
}
+interface SourceContext {
+ animeId: string;
+ episode: string | number;
+ source: string;
+ extension: string;
+ server: string;
+ category: string;
+}
+
export interface QueueItem {
uid: string;
metadata: RoomMetadata;
@@ -23,6 +32,7 @@ interface RoomMetadata {
episode: number;
image?: string;
source?: string;
+ malId?: number;
}
interface RoomData {
@@ -38,6 +48,7 @@ interface RoomData {
videoData?: any;
currentTime: number;
isPlaying: boolean;
+ context?: SourceContext;
} | null;
password?: string;
metadata?: RoomMetadata | null;
@@ -168,7 +179,10 @@ export function updateRoomVideo(roomId: string, videoData: any): boolean {
const room = rooms.get(roomId);
if (!room) return false;
- room.currentVideo = videoData;
+ room.currentVideo = {
+ ...room.currentVideo,
+ ...videoData
+ };
return true;
}
diff --git a/docker/src/api/rooms/rooms.websocket.ts b/docker/src/api/rooms/rooms.websocket.ts
index 379b16c..f50bb63 100644
--- a/docker/src/api/rooms/rooms.websocket.ts
+++ b/docker/src/api/rooms/rooms.websocket.ts
@@ -303,14 +303,34 @@ function handleMessage(roomId: string, userId: string, data: any) {
broadcastToRoom(roomId, {
type: 'video_update',
video: data.video,
- metadata: data.metadata // ✅ CLAVE
+ metadata: data.metadata
});
-
break;
+ case 'queue_add_batch':
+ if (room.host.id !== userId) 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,
+ addedBy: room.users.get(userId)?.username || 'Unknown'
+ };
+ roomService.addQueueItem(roomId, newItem);
+ });
+
+ broadcastToRoom(roomId, {
+ type: 'queue_update',
+ queue: room.queue
+ });
+ }
+ break;
case 'sync':
- // Solo el host puede hacer sync
if (room.host.id !== userId) return;
if (room.currentVideo) {
diff --git a/docker/src/scripts/anime/player.js b/docker/src/scripts/anime/player.js
index c672fb4..a694675 100644
--- a/docker/src/scripts/anime/player.js
+++ b/docker/src/scripts/anime/player.js
@@ -78,13 +78,11 @@ const AnimePlayer = (function() {
initElements();
setupEventListeners();
- // In Room Mode, we show the player immediately and hide extra controls
if (_roomMode) {
if(els.playerWrapper) {
els.playerWrapper.style.display = 'block';
els.playerWrapper.classList.add('room-mode');
}
- // Hide extension list loading in room mode
} else {
loadExtensionsList();
}
@@ -128,10 +126,8 @@ const AnimePlayer = (function() {
els.progressBuffer = document.querySelector('.progress-buffer');
els.progressHandle = document.querySelector('.progress-handle');
- // Subtitles canvas
els.subtitlesCanvas = document.getElementById('subtitles-canvas');
- // Create skip button if not exists
if (!document.getElementById('skip-overlay-btn')) {
const btn = document.createElement('button');
btn.id = 'skip-overlay-btn';
@@ -143,13 +139,11 @@ const AnimePlayer = (function() {
}
function setupEventListeners() {
- // Close player
if(!_roomMode) {
const closeBtn = document.getElementById('close-player-btn');
if(closeBtn) closeBtn.addEventListener('click', closePlayer);
}
- // Episode navigation
if(els.prevBtn) els.prevBtn.onclick = () => playEpisode(_currentEpisode - 1);
if(els.nextBtn) els.nextBtn.onclick = () => playEpisode(_currentEpisode + 1);
@@ -203,6 +197,15 @@ const AnimePlayer = (function() {
return;
}
+ if (videoData.malId) _malId = videoData.malId;
+ if (videoData.episode) _currentEpisode = parseInt(videoData.episode);
+
+ _skipIntervals = [];
+ if (els.progressContainer) {
+ els.progressContainer.querySelectorAll('.skip-range, .skip-cut').forEach(e => e.remove());
+ }
+ if (_skipBtn) _skipBtn.classList.remove('visible');
+
_currentSubtitles = videoData.subtitles || [];
if (els.loader) els.loader.style.display = 'none';
diff --git a/docker/src/scripts/room.js b/docker/src/scripts/room.js
index 3aa60a7..062441b 100644
--- a/docker/src/scripts/room.js
+++ b/docker/src/scripts/room.js
@@ -5,10 +5,16 @@ const RoomsApp = (function() {
let currentUsername = null;
let isHost = false;
let isGuest = false;
- let hlsInstance = null;
let selectedAnimeData = null;
let currentLoadedUrl = null;
let extensionsReady = false;
+ let currentAnimeDetails = null;
+ let selectedEpisodes = new Set([1]);
+ let lastSelectedEp = 1;
+ let modalCurrentPage = 1;
+ let modalTotalEpisodes = 0;
+ const MODAL_EPS_PER_PAGE = 50;
+ let currentQueue = [];
let configState = {
extension: null,
@@ -17,6 +23,13 @@ const RoomsApp = (function() {
episode: 1
};
+ let activeContext = {
+ extension: null,
+ server: null,
+ category: 'sub',
+ episode: 1
+ };
+
let extensionsStore = {
list: [],
settings: {}
@@ -29,6 +42,7 @@ const RoomsApp = (function() {
roomViewers: document.getElementById('room-viewers'),
selectAnimeBtn: document.getElementById('select-anime-btn'),
toggleChatBtn: document.getElementById('toggle-chat-btn'),
+ copyInviteBtn: document.getElementById('copy-invite-btn'), // NUEVO
// Host Quick Controls (Header)
hostControls: document.getElementById('host-controls'),
@@ -60,46 +74,59 @@ const RoomsApp = (function() {
chatInput: document.getElementById('chat-input'),
roomLayout: document.getElementById('room-layout'),
- // Modals
+ // Join Room Modal Elements
joinRoomModal: document.getElementById('join-room-modal'),
joinRoomForm: document.getElementById('join-room-form'),
+ guestNameInput: document.getElementById('guest-name-input'),
+ joinPasswordInput: document.getElementById('join-password-input'),
+ passwordGroup: document.getElementById('password-group'),
+ cancelJoinBtn: document.getElementById('cancel-join-btn'),
+ joinHostInfo: document.getElementById('join-host-info'),
+ joinHostAvatar: document.getElementById('join-host-avatar'),
+ joinHostText: document.getElementById('join-host-text'),
+
+ // Anime Search Modal
animeSearchModal: document.getElementById('anime-search-modal'),
animeSearchInput: document.getElementById('anime-search-input'),
animeResults: document.getElementById('anime-results'),
+ closeSearchBtn: document.getElementById('close-search-modal'),
+ animeSearchBtn: document.getElementById('anime-search-btn'),
- // Config Elements (Modal)
+ // Config Elements (Stream Configuration Modal)
stepSearch: document.getElementById('step-search'),
stepConfig: document.getElementById('step-config'),
backToSearchBtn: document.getElementById('back-to-search'),
configTitle: document.getElementById('selected-anime-title'),
configCover: document.getElementById('config-cover'),
+
+ // Selects del Modal (Legacy/Manual)
selExtension: document.getElementById('sel-extension'),
selServer: document.getElementById('sel-server'),
selCategory: document.getElementById('sel-category'),
+
+ // Controles de Configuración
+ extContainer: document.getElementById('ext-chips-container'),
+ serverContainer: document.getElementById('server-chips-container'),
+ modalSdToggle: document.getElementById('modal-sd-toggle'),
+
+ // Episode Controls
inpEpisode: document.getElementById('inp-episode'),
+ epInc: document.getElementById('ep-inc'),
+ epDec: document.getElementById('ep-dec'),
+
+ // Launch Actions
btnLaunch: document.getElementById('btn-launch-stream'),
configError: document.getElementById('config-error'),
+ btnAddQueue: document.getElementById('btn-add-queue'),
+ // Toasts & Tabs
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 = {
- extContainer: document.getElementById('ext-chips-container'),
- serverContainer: document.getElementById('server-chips-container'),
- sdToggle: document.getElementById('modal-sd-toggle'),
- epInput: document.getElementById('inp-episode'),
- epInc: document.getElementById('ep-inc'),
- epDec: document.getElementById('ep-dec'),
- launchBtn: document.getElementById('btn-launch-stream'),
- configError: document.getElementById('config-error')
};
async function init() {
@@ -113,7 +140,6 @@ const RoomsApp = (function() {
currentRoomId = urlRoomId;
if (window.AnimePlayer) {
- console.log('Initializing AnimePlayer in room mode');
window.AnimePlayer.init(null, 'anilist', false, {}, true);
}
@@ -196,16 +222,61 @@ const RoomsApp = (function() {
if (elements.selectAnimeBtn) elements.selectAnimeBtn.onclick = openAnimeSearchModal;
if (elements.toggleChatBtn) elements.toggleChatBtn.onclick = toggleChat;
- if (elements.leaveRoomBtn) elements.leaveRoomBtn.onclick = leaveRoom;
- if (elements.roomExtSelect) elements.roomExtSelect.onchange = (e) => onQuickExtensionChange(e, false);
- if (elements.roomServerSelect) elements.roomServerSelect.onchange = onQuickServerChange;
+ if (elements.roomExtSelect) elements.roomExtSelect.onchange = (e) => {
+ onQuickExtensionChange(e, false).then(() => {});
+ }; 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.btnAddQueue) {
+ elements.btnAddQueue.onclick = () => {
+ 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;
+ elements.btnAddQueue.disabled = true;
+ elements.btnAddQueue.textContent = "Adding...";
+
+ const itemsBatch = sortedEps.map(ep => ({
+ metadata: {
+ id: currentAnimeDetails.id,
+ title: currentAnimeDetails.title.userPreferred || currentAnimeDetails.title.english,
+ episode: ep,
+ image: currentAnimeDetails.coverImage?.large,
+ source: 'anilist',
+ malId: currentAnimeDetails.idMal
+ },
+ video: {
+ animeId: currentAnimeDetails.id,
+ episode: ep,
+ title: currentAnimeDetails.title.userPreferred,
+ extension: currentProvider,
+ server: elements.roomServerSelect?.value || 'gogoanime',
+ category: elements.roomSdToggle?.getAttribute('data-state') || 'sub'
+ }
+ }));
+
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({
+ type: 'queue_add_batch',
+ items: itemsBatch
+ }));
+ closeAnimeSearchModal();
+ } else {
+ console.error("WebSocket not connected");
+ }
+
+ setTimeout(() => {
+ elements.btnAddQueue.disabled = false;
+ elements.btnAddQueue.innerHTML = originalText;
+ }, 500);
+ };
+ }
if (elements.roomSdToggle) {
elements.roomSdToggle.onclick = () => {
if (!isHost) return;
@@ -243,7 +314,7 @@ const RoomsApp = (function() {
};
}
if (elements.selExtension) elements.selExtension.onchange = handleModalExtensionChange;
- if (elements.btnLaunch) elements.btnLaunch.onclick = () => launchStream(true); // true = from modal
+ if (elements.btnLaunch) elements.btnLaunch.onclick = () => launchStream(true);
if (elements.toggleUsersBtn) elements.toggleUsersBtn.onclick = toggleUsersList;
@@ -284,15 +355,18 @@ const RoomsApp = (function() {
await onQuickExtensionChange(null, true);
}
- function onQuickExtensionChange(e, silent = false) {
+ async function onQuickExtensionChange(e, silent = false) {
if (!isHost) return;
const ext = elements.roomExtSelect.value;
const settings = extensionsStore.settings[ext];
- if (!settings) return;
+
+ if (!settings) {
+ console.error("Settings not loaded for extension:", ext);
+ return;
+ }
elements.roomServerSelect.innerHTML = '';
-
const servers = settings.episodeServers?.length
? settings.episodeServers
: ['Default'];
@@ -304,11 +378,24 @@ const RoomsApp = (function() {
elements.roomServerSelect.appendChild(opt);
});
- elements.roomServerSelect.value = servers[0];
+ const defaultServer = servers[0];
+ elements.roomServerSelect.value = defaultServer;
- handleDubUI(settings, 'header');
+ if (typeof handleDubUI === 'function') {
+ handleDubUI(settings, 'header');
+ }
- if (!silent) onQuickServerChange();
+ if (typeof activeContext !== 'undefined') {
+ activeContext.extension = ext;
+ activeContext.server = defaultServer;
+ }
+
+ if (!silent) {
+ await launchStream(false, false, {
+ forceExtension: ext,
+ forceServer: defaultServer
+ });
+ }
}
function handleDubUI(settings, context = 'header') {
@@ -333,7 +420,7 @@ const RoomsApp = (function() {
}
if (context === 'modal') {
- const dubBtn = ui.sdToggle?.querySelector('[data-val="dub"]');
+ const dubBtn = elements.modalSdToggle?.querySelector('[data-val="dub"]');
if (!dubBtn) return;
dubBtn.classList.toggle('disabled', !supportsDub);
@@ -347,8 +434,6 @@ const RoomsApp = (function() {
function onQuickServerChange() {
if (!isHost) return;
- if (!elements.roomExtSelect.value) return;
- if (!elements.roomServerSelect.value) return;
launchStream(false);
}
@@ -379,50 +464,47 @@ const RoomsApp = (function() {
image: img,
source: 'anilist'
};
+ const animeResultObj = {
+ id: id,
+ title: title,
+ cover: img
+ };
showConfigStep();
+ onAnimeSelected(animeResultObj);
}
}
function showConfigStep() {
if (!selectedAnimeData) return;
- if (!extensionsReady) return;
elements.configTitle.textContent = selectedAnimeData.title;
elements.configCover.src = selectedAnimeData.image;
- if(ui.configError) ui.configError.style.display = 'none';
+ if(elements.configError) elements.configError.style.display = 'none';
configState.episode = 1;
- configState.server = null;
- configState.category = 'sub';
- configState.extension = null;
+ if(elements.inpEpisode) elements.inpEpisode.value = 1;
- if(ui.epInput) ui.epInput.value = 1;
- if(ui.launchBtn) ui.launchBtn.disabled = true;
- if(elements.btnAddQueue) elements.btnAddQueue.disabled = true;
- updateSDUI();
+ if(elements.btnLaunch) elements.btnLaunch.disabled = false;
+ if(elements.btnAddQueue) elements.btnAddQueue.disabled = false;
setupConfigListeners();
- renderExtensionChips();
-
elements.stepSearch.style.display = 'none';
elements.stepConfig.style.display = 'block';
}
function setupConfigListeners() {
- if(ui.epInc) ui.epInc.onclick = () => {
- ui.epInput.value = parseInt(ui.epInput.value || 0) + 1;
- configState.episode = ui.epInput.value;
+ elements.epInc.onclick = () => {
+ elements.inpEpisode.value = parseInt(elements.inpEpisode.value || 0) + 1;
};
- if(ui.epDec) ui.epDec.onclick = () => {
- if(ui.epInput.value > 1) ui.epInput.value = parseInt(ui.epInput.value) - 1;
- configState.episode = ui.epInput.value;
+ elements.epDec.onclick = () => {
+ if(elements.inpEpisode.value > 1) elements.inpEpisode.value = parseInt(elements.inpEpisode.value) - 1;
};
- if(ui.epInput) ui.epInput.onchange = (e) => configState.episode = e.target.value;
+ if(elements.inpEpisode) elements.inpEpisode.onchange = (e) => configState.episode = e.target.value;
- if(ui.sdToggle) {
- ui.sdToggle.querySelectorAll('.cat-opt').forEach(opt => {
+ if(elements.modalSdToggle) {
+ elements.modalSdToggle.querySelectorAll('.cat-opt').forEach(opt => {
opt.onclick = () => {
if(opt.classList.contains('disabled')) return;
configState.category = opt.dataset.val;
@@ -434,49 +516,16 @@ const RoomsApp = (function() {
}
function updateSDUI() {
- if(!ui.sdToggle) return;
- ui.sdToggle.querySelectorAll('.cat-opt').forEach(opt => {
+ if(!elements.modalSdToggle) return;
+ elements.modalSdToggle.querySelectorAll('.cat-opt').forEach(opt => {
opt.classList.toggle('active', opt.dataset.val === configState.category);
});
}
- function renderExtensionChips() {
- ui.extContainer.innerHTML = '';
-
- if (!extensionsStore.list || extensionsStore.list.length === 0) {
- ui.extContainer.innerHTML = '
No extensions';
- return;
- }
-
- extensionsStore.list.forEach(ext => {
- const chip = document.createElement('div');
- chip.className = 'chip';
- chip.textContent = ext.charAt(0).toUpperCase() + ext.slice(1);
-
- if (!configState.extension && ext === 'anilist') configState.extension = 'anilist';
- if (ext === configState.extension) chip.classList.add('active');
-
- chip.onclick = () => {
- document.querySelectorAll('#ext-chips-container .chip').forEach(c => c.classList.remove('active'));
- chip.classList.add('active');
-
- configState.extension = ext;
- configState.server = null;
- ui.launchBtn.disabled = true;
- if(elements.btnAddQueue) elements.btnAddQueue.disabled = true;
-
- loadServersForExtension(ext);
- };
- ui.extContainer.appendChild(chip);
- });
-
- if(configState.extension) loadServersForExtension(configState.extension);
- }
-
async function loadServersForExtension(extName) {
if (!extensionsReady) return;
- ui.serverContainer.innerHTML = '
';
- ui.launchBtn.disabled = true;
+ elements.serverContainer.innerHTML = '
';
+ elements.btnLaunch.disabled = true;
if(elements.btnAddQueue) elements.btnAddQueue.disabled = true;
try {
@@ -489,12 +538,12 @@ const RoomsApp = (function() {
renderServerChips(servers);
- const dubBtn = ui.sdToggle.querySelector('[data-val="dub"]');
+ const dubBtn = elements.modalSdToggle.querySelector('[data-val="dub"]');
if (dubBtn) {
if (!settings.supportsDub) {
dubBtn.classList.add('disabled');
if(configState.category === 'dub') {
- configState.category = 'sub'; // Forzar sub si no hay dub
+ configState.category = 'sub';
updateSDUI();
}
} else {
@@ -504,12 +553,12 @@ const RoomsApp = (function() {
} catch (e) {
console.error("Error loading settings", e);
- ui.serverContainer.innerHTML = '
Error loading servers
';
+ elements.serverContainer.innerHTML = '
Error loading servers
';
}
}
function renderServerChips(servers) {
- ui.serverContainer.innerHTML = '';
+ elements.serverContainer.innerHTML = '';
servers.forEach(srv => {
const chip = document.createElement('div');
@@ -521,11 +570,11 @@ const RoomsApp = (function() {
chip.classList.add('active');
configState.server = srv;
- ui.launchBtn.disabled = false;
+ elements.btnLaunch.disabled = false;
if(elements.btnAddQueue) elements.btnAddQueue.disabled = false;
};
- ui.serverContainer.appendChild(chip);
+ elements.serverContainer.appendChild(chip);
});
}
@@ -570,67 +619,51 @@ const RoomsApp = (function() {
// --- STREAM LAUNCHER (Unified) ---
- async function launchStream(fromModal = false, isQueueAction = false) {
- if (!selectedAnimeData) {
- console.warn("No anime selected data found");
+ async function launchStream(fromModal = false, isQueueAction = false, overrides = {}) {
+ if (!selectedAnimeData) return;
+
+ 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;
+
+ if (!ext || !server) {
+ console.warn("Faltan datos (ext o server) para lanzar el stream");
return;
}
- let ext, server, episode, category;
+ activeContext = { extension: ext, server, category, episode: episodeToPlay };
+ selectedAnimeData.episode = parseInt(episodeToPlay);
- if (fromModal) {
- ext = configState.extension;
- 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;
-
- let epText = elements.npEpisode.textContent.replace('Ep ', '').trim();
-
- if (!epText || epText === '--' || isNaN(epText)) {
- if (selectedAnimeData.episode) {
- epText = selectedAnimeData.episode;
- } else {
- epText = "1";
- }
- }
- episode = epText;
-
- category = elements.roomSdToggle.getAttribute('data-state');
- }
-
- if (!ext || !server || !episode) {
- console.error("Missing params:", { ext, server, episode });
- if(fromModal) {
- elements.configError.textContent = "Please select an extension, server, and episode.";
- elements.configError.style.display = 'block';
- } else {
- alert("Please select a valid server/extension.");
- }
- return;
+ if (fromModal && isQueueAction) {
+ elements.btnAddQueue.disabled = true;
+ elements.btnAddQueue.textContent = 'Adding...';
+ } else if (!isQueueAction) {
+ if(typeof showSystemToast === 'function') showSystemToast(`Loading ${ext}...`);
}
try {
- const apiUrl = `/api/watch/stream?animeId=${selectedAnimeData.id}&episode=${episode}&server=${encodeURIComponent(server)}&category=${category}&ext=${ext}&source=${selectedAnimeData.source}`;
+ const apiUrl = `/api/watch/stream?animeId=${selectedAnimeData.id}&episode=${episodeToPlay}&server=${encodeURIComponent(server)}&category=${category}&ext=${ext}&source=anilist`;
console.log('Fetching stream:', apiUrl);
const res = await fetch(apiUrl);
- if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
+ if (!res.ok) throw new Error(`Error ${res.status}: Failed to fetch stream`);
const data = await res.json();
+ if (data.error) throw new Error(data.error);
+
const source = data.videoSources?.find(s => s.type === 'm3u8') || data.videoSources?.[0];
- if (!source) throw new Error('No video source found');
+
+ if (!source) {
+ throw new Error(`No sources found on ${ext} for this episode.`);
+ }
let proxyUrl = `/api/proxy?url=${encodeURIComponent(source.url)}`;
const headers = data.headers || {};
- if (headers['Referer'] && headers['Referer'] !== "null") proxyUrl += `&referer=${encodeURIComponent(headers['Referer'])}`;
+ if (headers['Referer']) proxyUrl += `&referer=${encodeURIComponent(headers['Referer'])}`;
const subtitles = (source.subtitles || []).map(sub => ({
label: sub.language,
@@ -638,17 +671,30 @@ const RoomsApp = (function() {
src: `/api/proxy?url=${encodeURIComponent(sub.url)}`
}));
+ const contextPayload = {
+ animeId: selectedAnimeData.id,
+ episode: episodeToPlay,
+ source: selectedAnimeData.source,
+ extension: ext,
+ server: server,
+ category: category
+ };
+
const videoPayload = {
- videoData: { url: proxyUrl, type: source.type || 'm3u8', headers: headers },
- subtitles: subtitles
+ videoData: { url: proxyUrl, type: source.type || 'm3u8', headers },
+ subtitles: subtitles,
+ context: contextPayload,
+ malId: selectedAnimeData.malId,
+ episode: episodeToPlay
};
const metaPayload = {
title: selectedAnimeData.title,
- episode: episode,
+ episode: episodeToPlay,
image: selectedAnimeData.image,
id: selectedAnimeData.id,
- source: ext
+ source: ext,
+ malId: selectedAnimeData.malId
};
if (ws && ws.readyState === WebSocket.OPEN) {
@@ -658,59 +704,239 @@ const RoomsApp = (function() {
video: videoPayload,
metadata: metaPayload
}));
-
- showSystemToast("Added to queue!");
- if(fromModal) closeAnimeSearchModal();
-
+ if(typeof showSystemToast === 'function') showSystemToast("Added to queue!");
} else {
ws.send(JSON.stringify({
type: 'video_update',
video: { ...videoPayload, currentTime: 0, isPlaying: true },
metadata: metaPayload
}));
- }
- loadVideo(videoPayload.video);
- updateHeaderInfo(videoPayload.metadata);
- if(fromModal) {
- closeAnimeSearchModal();
+ loadVideo({ ...videoPayload, currentTime: 0, isPlaying: true });
+ updateHeaderInfo(metaPayload);
- selectedAnimeData.source = ext;
-
- await populateQuickControls();
-
- if (extensionsStore.list.includes(ext)) {
- elements.roomExtSelect.value = ext;
- await onQuickExtensionChange(null, true);
- elements.roomServerSelect.value = server;
-
- elements.roomSdToggle.setAttribute('data-state', category);
- elements.roomSdToggle.querySelectorAll('.sd-option').forEach(o =>
- o.classList.toggle('active', o.dataset.val === category)
- );
- }
+ if(typeof syncControlsUI === 'function') syncControlsUI(contextPayload);
}
}
+ if (fromModal) closeAnimeSearchModal();
+
} catch (err) {
- console.error('Stream launch error:', err);
- const msg = "Error loading stream. Try another server.";
- if(fromModal) {
- elements.configError.textContent = msg;
- elements.configError.style.display = 'block';
+ console.error("Stream Launch Error:", err);
+
+ const errorMsg = err.message || "Error loading stream.";
+
+ if (fromModal) {
+ if(elements.configError) {
+ elements.configError.textContent = errorMsg;
+ elements.configError.style.display = 'block';
+ }
} else {
- alert(msg);
+ if(typeof showSystemToast === 'function') {
+ showSystemToast(`Error: ${errorMsg}`);
+ } else {
+ alert(errorMsg);
+ }
}
} finally {
- if(fromModal && isQueueAction) {
+ if (fromModal && isQueueAction) {
elements.btnAddQueue.disabled = false;
elements.btnAddQueue.textContent = '+ Add to Queue';
}
}
}
- // --- CONNECTION & WS ---
+ async function onAnimeSelected(animeResult) {
+ const gridContainer = document.getElementById('episodes-grid');
+ gridContainer.innerHTML = '
';
+ document.getElementById('step-search').style.display = 'none';
+ document.getElementById('step-config').style.display = 'block';
+
+ document.getElementById('config-cover').src = animeResult.cover;
+ document.getElementById('selected-anime-title').textContent = animeResult.title;
+
+ selectedEpisodes.clear();
+ selectedEpisodes.add(1);
+ updateCountBtn();
+
+ try {
+ const response = await fetch(`/api/anime/${animeResult.id}?source=anilist`);
+ if (!response.ok) throw new Error("Failed to load anime details");
+
+ const data = await response.json();
+ currentAnimeDetails = data;
+
+ if (selectedAnimeData) {
+ selectedAnimeData.malId = data.idMal;
+ }
+
+ modalTotalEpisodes = data.episodes || 12;
+ modalCurrentPage = 1;
+
+ renderModalEpisodes();
+ setupModalPaginationControls();
+
+ } catch (error) {
+ console.error("Error fetching details", error);
+ gridContainer.innerHTML = '';
+ document.querySelector('.manual-ep-input').style.display = 'block';
+ document.getElementById('modal-pagination').style.display = 'none';
+ }
+ }
+
+ function renderModalEpisodes() {
+ const grid = document.getElementById('episodes-grid');
+ grid.innerHTML = '';
+
+ const start = (modalCurrentPage - 1) * MODAL_EPS_PER_PAGE + 1;
+ const end = Math.min(start + MODAL_EPS_PER_PAGE - 1, modalTotalEpisodes);
+
+ const infoText = document.getElementById('modal-page-info');
+ if (infoText) infoText.textContent = `${start} - ${end} of ${modalTotalEpisodes}`;
+
+ for (let i = start; i <= end; i++) {
+ const btn = document.createElement('div');
+ const isSelected = selectedEpisodes.has(i);
+ btn.className = `ep-grid-btn ${isSelected ? 'selected' : ''}`;
+ btn.textContent = i;
+ btn.dataset.ep = i;
+
+ btn.onclick = (e) => toggleEpisode(i, btn, e.shiftKey);
+
+ grid.appendChild(btn);
+ }
+
+ document.getElementById('modal-prev-page').disabled = modalCurrentPage === 1;
+ document.getElementById('modal-next-page').disabled = end >= modalTotalEpisodes;
+
+ document.getElementById('modal-pagination').style.display = modalTotalEpisodes > MODAL_EPS_PER_PAGE ? 'flex' : 'none';
+ document.querySelector('.manual-ep-input').style.display = 'none';
+ }
+
+ function setupModalPaginationControls() {
+ document.getElementById('modal-prev-page').onclick = () => {
+ if (modalCurrentPage > 1) {
+ modalCurrentPage--;
+ renderModalEpisodes();
+ }
+ };
+
+ document.getElementById('modal-next-page').onclick = () => {
+ const maxPage = Math.ceil(modalTotalEpisodes / MODAL_EPS_PER_PAGE);
+ if (modalCurrentPage < maxPage) {
+ modalCurrentPage++;
+ renderModalEpisodes();
+ }
+ };
+
+ document.getElementById('select-all-page').onclick = () => {
+ const start = (modalCurrentPage - 1) * MODAL_EPS_PER_PAGE + 1;
+ const end = Math.min(start + MODAL_EPS_PER_PAGE - 1, modalTotalEpisodes);
+ for(let i=start; i<=end; i++) selectedEpisodes.add(i);
+ renderModalEpisodes();
+ updateCountBtn();
+ };
+
+ document.getElementById('select-none-eps').onclick = () => {
+ selectedEpisodes.clear();
+ renderModalEpisodes();
+ updateCountBtn();
+ };
+ }
+
+ async function syncControlsUI(context) {
+ if (!context || !isHost) return;
+
+ 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);
+ }
+
+ if (context.server) {
+ const options = Array.from(elements.roomServerSelect.options).map(o => o.value);
+ if (options.includes(context.server)) {
+ elements.roomServerSelect.value = context.server;
+ }
+ }
+
+ if (context.category) {
+ elements.roomSdToggle.setAttribute('data-state', context.category);
+ elements.roomSdToggle.querySelectorAll('.sd-option').forEach(opt => {
+ opt.classList.toggle('active', opt.dataset.val === context.category);
+ });
+ }
+
+ activeContext = { ...activeContext, ...context };
+ }
+
+ function toggleEpisode(epNum, btnElement, isShift) {
+ if (isShift && lastSelectedEp) {
+ const start = Math.min(lastSelectedEp, epNum);
+ const end = Math.max(lastSelectedEp, epNum);
+
+ for (let i = start; i <= end; i++) {
+ selectedEpisodes.add(i);
+ const el = document.querySelector(`.ep-grid-btn[data-ep="${i}"]`);
+ if (el) el.classList.add('selected');
+ }
+ } else {
+ if (selectedEpisodes.has(epNum)) {
+ selectedEpisodes.delete(epNum);
+ btnElement.classList.remove('selected');
+ } else {
+ selectedEpisodes.add(epNum);
+ btnElement.classList.add('selected');
+ }
+ }
+
+ lastSelectedEp = epNum;
+ updateCountBtn();
+ }
+
+ function updateCountBtn() {
+ document.getElementById('sel-count').textContent = selectedEpisodes.size;
+ }
+
+ document.getElementById('btn-add-queue').addEventListener('click', () => {
+ if (selectedEpisodes.size === 0) return;
+
+ const sortedEps = Array.from(selectedEpisodes).sort((a, b) => a - b);
+
+ const itemsBatch = sortedEps.map(ep => ({
+ metadata: {
+ id: currentAnimeDetails.id,
+ title: currentAnimeDetails.title.userPreferred || currentAnimeDetails.title.english,
+ episode: ep,
+ image: currentAnimeDetails.coverImage?.large,
+ source: 'anilist',
+ malId: currentAnimeDetails.idMal
+ },
+ video: {
+ animeId: currentAnimeDetails.id,
+ episode: ep,
+ title: currentAnimeDetails.title.userPreferred,
+ server: document.getElementById('room-server-select').value || 'gogoanime',
+ category: 'sub' // o lo que esté seleccionado
+ }
+ }));
+
+ if (itemsBatch.length === 1) {
+ socket.send(JSON.stringify({
+ type: 'queue_add',
+ metadata: itemsBatch[0].metadata,
+ video: itemsBatch[0].video
+ }));
+ } else {
+ socket.send(JSON.stringify({
+ type: 'queue_add_batch',
+ items: itemsBatch
+ }));
+ }
+
+ closeModal();
+ });
function connectToRoom(roomId, guestName, password) {
const token = localStorage.getItem('token');
@@ -863,6 +1089,9 @@ const RoomsApp = (function() {
if (data.room.metadata) {
updateHeaderInfo(data.room.metadata);
}
+ if (data.room.currentVideo.context) {
+ syncControlsUI(data.room.currentVideo.context);
+ }
if (!isHost) {
console.log('Video detected on join, requesting sync...');
@@ -911,22 +1140,23 @@ const RoomsApp = (function() {
case 'video_update':
loadVideo(data.video);
+
if (data.metadata) {
- selectedAnimeData = {
- ...selectedAnimeData,
- ...data.metadata
- };
+ selectedAnimeData = { ...selectedAnimeData, ...data.metadata };
updateHeaderInfo(data.metadata);
}
+ if (data.video.context) {
+ syncControlsUI(data.video.context);
+ }
+
if (!isHost) {
setTimeout(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
console.log('New video loaded, requesting sync...');
ws.send(JSON.stringify({ type: 'request_sync' }));
}
- }, 1500);
- }
+ }, 1500); }
break;
case 'sync':
@@ -970,6 +1200,7 @@ const RoomsApp = (function() {
}
function renderQueue(queueItems) {
+ currentQueue = queueItems;
if (!elements.queueList) return;
elements.queueCount.textContent = queueItems.length;
@@ -1021,10 +1252,55 @@ const RoomsApp = (function() {
}).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.playQueueItem = async function(uid) {
+ const item = currentQueue.find(i => i.uid === uid);
+ if (!item) return;
+
+ if(typeof showSystemToast === 'function') showSystemToast("Loading from queue...");
+
+ if (item.videoData && item.videoData.animeId) {
+ const v = item.videoData;
+
+ selectedAnimeData = {
+ id: v.animeId,
+ title: v.title,
+ image: item.metadata.image,
+ source: 'anilist',
+ episode: parseInt(v.episode),
+ malId: item.metadata.malId
+ };
+
+ activeContext.episode = parseInt(v.episode);
+ activeContext.category = v.category || 'sub';
+
+ if (elements.roomSdToggle) {
+ elements.roomSdToggle.setAttribute('data-state', activeContext.category);
+ elements.roomSdToggle.querySelectorAll('.sd-option').forEach(opt => {
+ opt.classList.toggle('active', opt.dataset.val === activeContext.category);
+ });
+ }
+
+ try {
+ const providerExtension = v.extension || elements.roomExtSelect?.value || 'gogoanime';
+
+ console.log(`Playing from queue with provider: ${providerExtension}`);
+
+ await launchStream(false, false, {
+ forceExtension: providerExtension,
+ forceServer: v.server
+ });
+
+ removeQueueItem(uid);
+
+ } catch(e) {
+ console.error("Error playing queue item:", e);
+ if(typeof showSystemToast === 'function') showSystemToast("Error: " + e.message);
+ }
+
+ } else {
+ if(ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'queue_play_item', itemUid: uid }));
+ }
}
};
@@ -1054,6 +1330,10 @@ const RoomsApp = (function() {
window.AnimePlayer.setRoomHost(isHost);
}
+ if (isHost) {
+ initGlobalControls();
+ }
+
const copyInviteBtn = document.getElementById('copy-invite-btn');
if (copyInviteBtn) {
@@ -1106,6 +1386,30 @@ const RoomsApp = (function() {
if (room.currentVideo) loadVideo(room.currentVideo);
}
+ async function initGlobalControls() {
+ if (!isHost || !extensionsReady) return;
+
+ if (elements.roomExtSelect.options.length > 1) return;
+
+ elements.roomExtSelect.innerHTML = '';
+
+ extensionsStore.list.forEach(ext => {
+ const opt = document.createElement('option');
+ opt.value = ext;
+ opt.textContent = ext[0].toUpperCase() + ext.slice(1);
+ elements.roomExtSelect.appendChild(opt);
+ });
+
+ const defaultExt = 'anilist';
+ if (extensionsStore.list.includes(defaultExt)) {
+ elements.roomExtSelect.value = defaultExt;
+ } else {
+ elements.roomExtSelect.value = extensionsStore.list[0];
+ }
+
+ await onQuickExtensionChange(null, true);
+ }
+
function showCopyToast(message) {
const toast = document.createElement('div');
toast.className = 'copy-toast';
@@ -1140,10 +1444,13 @@ const RoomsApp = (function() {
if (elements.npEpisode) elements.npEpisode.textContent = `Episode ${meta.episode}`;
if (elements.npInfo) elements.npInfo.style.opacity = '1';
- if(!selectedAnimeData) selectedAnimeData = { ...meta, source: 'anilist' };
- else {
+ if(!selectedAnimeData) {
+ selectedAnimeData = { ...meta, source: 'anilist' };
+ } else {
selectedAnimeData.id = meta.id;
selectedAnimeData.title = meta.title;
+ selectedAnimeData.image = meta.image || selectedAnimeData.image;
+ selectedAnimeData.episode = parseInt(meta.episode);
}
}
@@ -1316,8 +1623,13 @@ const RoomsApp = (function() {
if (window.AnimePlayer && typeof window.AnimePlayer.loadVideoFromRoom === 'function') {
if(elements.loading) elements.loading.style.display = 'none';
- window.AnimePlayer.loadVideoFromRoom({ url: url, type: type, subtitles: videoData.subtitles });
-
+ window.AnimePlayer.loadVideoFromRoom({
+ url: url,
+ type: type,
+ subtitles: videoData.subtitles,
+ malId: videoData.malId,
+ episode: videoData.episode
+ });
setTimeout(() => {
const newVideoElement = document.getElementById('player');
if (newVideoElement) {
@@ -1326,8 +1638,6 @@ const RoomsApp = (function() {
updatePlayPauseButton(!newVideoElement.paused);
}
}, 500);
- } else {
- loadVideoBasic(url, type, videoData);
}
}
@@ -1336,23 +1646,6 @@ const RoomsApp = (function() {
return document.getElementById('player');
}
- function loadVideoBasic(url, type, videoData) {
- elements.loading.style.display = 'none';
- if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
-
- if (Hls.isSupported() && type === 'm3u8') {
- hlsInstance = new Hls();
- hlsInstance.loadSource(url);
- hlsInstance.attachMedia(elements.video);
- hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
- if (videoData.isPlaying) elements.video.play().catch(e=>console.error(e));
- });
- } else {
- elements.video.src = url;
- if (videoData.isPlaying) elements.video.play();
- }
- }
-
function syncVideo(currentTime, isPlaying) {
const video = getVideoElement();
if (!video) {
@@ -1473,14 +1766,20 @@ const RoomsApp = (function() {
if (isHost) {
console.log('Video ended. Checking queue...');
setTimeout(() => {
- if(ws && ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: 'play_next' }));
- }
+ playNextInQueue();
}, 1000);
}
});
}
+ async function playNextInQueue() {
+ if (currentQueue.length === 0) return;
+ const nextItem = currentQueue[0];
+ if (nextItem) {
+ await window.playQueueItem(nextItem.uid);
+ }
+ }
+
function formatTime(s) {
if(isNaN(s)) return "0:00";
const m = Math.floor(s/60), sec = Math.floor(s%60);
diff --git a/docker/views/css/room.css b/docker/views/css/room.css
index 2a2c2fc..457a0f6 100644
--- a/docker/views/css/room.css
+++ b/docker/views/css/room.css
@@ -1,3 +1,6 @@
+/* =========================================
+ 1. VARIABLES & RESET
+ ========================================= */
:root {
--brand-color: #8b5cf6;
--brand-gradient: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);
@@ -23,6 +26,9 @@
background: rgba(255, 255, 255, 0.4);
}
+/* =========================================
+ 2. UTILITIES & ANIMATIONS
+ ========================================= */
.spinner {
width: 24px;
height: 24px;
@@ -33,7 +39,22 @@
}
@keyframes spin { to { transform: rotate(360deg); } }
+@keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
+@keyframes slideUp { from { transform: translateX(-50%) translateY(20px); opacity: 0; } to { transform: translateX(-50%) translateY(0); opacity: 1; } }
+@keyframes slideDown { from { transform: translateX(-50%) translateY(-20px); opacity: 0; } to { transform: translateX(-50%) translateY(0); opacity: 1; } }
+@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
+@keyframes scaleIn { from { transform: translate(-50%, -50%) scale(0.8); opacity: 0; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
+@keyframes toastSlideIn { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } }
+@keyframes toastFadeOut { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-10px); visibility: hidden; } }
+@keyframes pulse {
+ 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
+ 70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
+ 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
+}
+/* =========================================
+ 3. BUTTONS & INPUTS
+ ========================================= */
.btn-icon-glass, .btn-icon-small, .modal-close {
appearance: none;
background: rgba(255, 255, 255, 0.05);
@@ -46,7 +67,6 @@
align-items: center;
justify-content: center;
}
-
.btn-icon-glass { width: 36px; height: 36px; backdrop-filter: blur(8px); }
.btn-icon-small { padding: 6px; }
.modal-close { width: 32px; height: 32px; position: absolute; top: 20px; right: 20px; background: rgba(255, 255, 255, 0.1); }
@@ -60,22 +80,15 @@
.modal-close:hover { background: rgba(255, 255, 255, 0.3); transform: rotate(90deg); }
.btn-glass-primary {
- display: flex;
- align-items: center;
- gap: 8px;
+ display: flex; align-items: center; gap: 8px;
background: rgba(139, 92, 246, 0.25);
border: 1px solid rgba(139, 92, 246, 0.4);
color: white;
- padding: 0 16px;
- height: 36px;
- border-radius: 10px;
- font-weight: 600;
- font-size: 0.9rem;
- cursor: pointer;
- backdrop-filter: blur(8px);
+ padding: 0 16px; height: 36px;
+ border-radius: 10px; font-weight: 600; font-size: 0.9rem;
+ cursor: pointer; backdrop-filter: blur(8px);
transition: all 0.2s;
}
-
.btn-glass-primary:hover {
background: rgba(139, 92, 246, 0.4);
box-shadow: 0 0 15px rgba(139, 92, 246, 0.2);
@@ -84,23 +97,13 @@
.btn-confirm, .btn-primary {
background: var(--brand-color);
- border: none;
- color: white;
- padding: 10px 24px;
- border-radius: 10px;
- font-weight: 700;
- cursor: pointer;
+ border: none; color: white;
+ padding: 10px 24px; border-radius: 10px;
+ font-weight: 700; cursor: pointer;
transition: all 0.2s;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
-}
-
-.btn-confirm:hover, .btn-primary:hover {
- background: #7c3aed;
- transform: translateY(-2px);
+ display: flex; align-items: center; justify-content: center; gap: 8px;
}
+.btn-confirm:hover, .btn-primary:hover { background: #7c3aed; transform: translateY(-2px); }
.btn-confirm:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn-confirm.full-width { width: 100%; }
@@ -108,469 +111,359 @@
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
- padding: 10px 20px;
- border-radius: 8px;
- cursor: pointer;
- font-weight: 600;
+ padding: 10px 20px; border-radius: 8px;
+ cursor: pointer; font-weight: 600;
transition: all 0.2s;
}
.btn-cancel:hover { background: rgba(255, 255, 255, 0.1); color: white; }
+.btn-quick-action {
+ display: flex; align-items: center; gap: 6px;
+ height: 34px; padding: 0 12px;
+ background: rgba(139, 92, 246, 0.15);
+ border: 1px solid rgba(139, 92, 246, 0.3);
+ border-radius: 8px; color: #d8b4fe;
+ font-size: 0.85rem; font-weight: 600;
+ cursor: pointer; transition: all 0.2s;
+}
+.btn-quick-action:hover {
+ background: var(--brand-color); border-color: var(--brand-color);
+ color: white; transform: translateY(-1px);
+}
+#copy-invite-btn { transition: all 0.3s ease; }
+#copy-invite-btn:hover { transform: scale(1.05); }
+#copy-invite-btn:active { transform: scale(0.95); }
+
+/* Inputs */
input[type="text"], input[type="password"], input[type="number"], .form-input {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
- color: white;
- padding: 12px 16px;
- border-radius: 10px;
- font-size: 1rem;
- outline: none;
- transition: border-color 0.2s;
+ color: white; padding: 12px 16px;
+ border-radius: 10px; font-size: 1rem;
+ outline: none; transition: border-color 0.2s;
}
+input:focus, .form-input:focus { border-color: var(--brand-color); background: rgba(255, 255, 255, 0.08); }
+input[type=number]::-webkit-inner-spin-button, input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
+input[type=number] { -moz-appearance: textfield; }
-input:focus, .form-input:focus {
- border-color: var(--brand-color);
- background: rgba(255, 255, 255, 0.08);
+/* Custom Select Wrappers */
+.quick-select-wrapper {
+ position: relative; display: flex; align-items: center;
+ height: 34px; background: rgba(255, 255, 255, 0.03);
+ border-radius: 8px; transition: all 0.2s;
+ padding: 0 8px; min-width: 110px;
}
+.quick-select-wrapper:hover { background: rgba(255, 255, 255, 0.08); }
+.quick-select-wrapper svg { pointer-events: none; color: var(--text-muted); margin-right: 6px; flex-shrink: 0; }
+.select-arrow { font-size: 0.6rem; color: rgba(255, 255, 255, 0.4); pointer-events: none; margin-left: 4px; }
+.quick-select, .glass-select-sm {
+ appearance: none; -webkit-appearance: none;
+ background: transparent; border: none;
+ color: #fff; width: 100%; height: 100%;
+ cursor: pointer; outline: none; position: relative; z-index: 2;
+}
.glass-select-sm {
- appearance: none;
- -webkit-appearance: none;
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
- color: #eee;
- padding: 0 32px 0 12px;
- height: 32px;
- line-height: 30px;
- border-radius: 8px;
- font-size: 0.85rem;
- font-weight: 600;
- cursor: pointer;
- outline: none;
- transition: all 0.2s;
- max-width: 140px;
- white-space: nowrap;
- text-overflow: ellipsis;
-
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.6)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
- background-repeat: no-repeat;
- background-position: right 8px center;
- background-size: 14px;
+ padding: 0 32px 0 12px; height: 32px; line-height: 30px;
+ border-radius: 8px; font-size: 0.85rem; font-weight: 600;
+ max-width: 140px; white-space: nowrap; text-overflow: ellipsis;
+ background-image: url("data:image/svg+xml,..."); /* SVG truncated for brevity */
+ background-repeat: no-repeat; background-position: right 8px center; background-size: 14px;
}
+.quick-select option, .glass-select-sm option { background: #1a1a1a; color: #eee; }
-.glass-select-sm:hover {
- background-color: rgba(255, 255, 255, 0.1);
- border-color: rgba(255,255,255,0.3);
- color: #fff;
-}
-.glass-select-sm option { background: #1a1a1a; color: #e0e0e0; }
-
-.chips-grid { display: flex; flex-wrap: wrap; gap: 10px; }
-.chip {
- padding: 8px 16px;
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 8px;
- color: #ccc;
- font-size: 0.9rem;
- cursor: pointer;
- transition: all 0.2s;
- user-select: none;
-}
-.chip:hover { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.3); transform: translateY(-2px); }
-.chip.active {
- background: var(--brand-color);
- border-color: var(--brand-color);
- color: white;
- box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
-}
-.chip.disabled { opacity: 0.5; pointer-events: none; filter: grayscale(1); }
-
+/* =========================================
+ 4. LAYOUT
+ ========================================= */
.room-layout {
display: grid;
grid-template-columns: 1fr 380px;
- height: 100vh;
- overflow: hidden;
+ height: 100vh; overflow: hidden;
transition: grid-template-columns 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
+.room-layout.chat-hidden { grid-template-columns: 1fr 0px !important; }
-.room-layout.chat-hidden {
- grid-template-columns: 1fr 0px !important;
-}
-
+/* Video Area */
.video-area {
- display: flex;
- flex-direction: column;
- background: #000;
- flex: 1;
- min-height: 0;
- overflow: hidden;
- position: relative;
-}
-
-@media (max-width: 1200px) {
- .room-layout {
- grid-template-columns: 1fr;
- grid-template-rows: 1fr auto;
- }
- .room-layout.chat-hidden {
- grid-template-rows: 1fr 0px;
- }
- .chat-sidebar {
- height: 350px;
- border-left: none;
- border-top: 1px solid var(--glass-border);
- }
+ display: flex; flex-direction: column;
+ background: #000; flex: 1; min-height: 0;
+ overflow: hidden; position: relative;
}
+/* =========================================
+ 5. HEADER
+ ========================================= */
.room-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 0 24px;
- background: rgba(10, 10, 10, 0.85);
- backdrop-filter: blur(20px);
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 0 24px; height: 64px;
+ background: rgba(10, 10, 10, 0.85); backdrop-filter: blur(20px);
border-bottom: 1px solid var(--glass-border);
- height: 64px;
- z-index: 20;
- flex-shrink: 0;
+ z-index: 20; flex-shrink: 0;
}
-.header-left, .header-right {
- display: flex;
- align-items: center;
- gap: 16px;
- flex: 1;
-}
+.header-left, .header-right { display: flex; align-items: center; gap: 16px; flex: 1; }
.header-right { justify-content: flex-end; }
+.header-center { flex: 2; display: flex; justify-content: center; z-index: 50; }
.room-info { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; }
-#room-name {
- margin: 0;
- font-size: 1rem;
- font-weight: 700;
- color: white;
- letter-spacing: -0.01em;
-}
+#room-name { margin: 0; font-size: 1rem; font-weight: 700; color: white; letter-spacing: -0.01em; }
.np-fade { display: flex; align-items: center; opacity: 0.7; font-size: 0.8rem; gap: 6px; margin-top: 2px; }
.np-title { color: #ccc; font-weight: 500; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.np-sep { color: #555; }
-.np-badge {
- background: rgba(255, 255, 255, 0.1);
- color: #fff;
- padding: 1px 6px;
- border-radius: 4px;
- font-size: 0.7rem;
- font-weight: 700;
-}
-
-.header-center { flex: 2; display: flex; justify-content: center; z-index: 50; }
+.np-badge { background: rgba(255, 255, 255, 0.1); color: #fff; padding: 1px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 700; }
+/* Header Controls */
.quick-controls-group {
- display: flex;
- align-items: center;
- gap: 8px;
- background: rgba(20, 20, 20, 0.6);
- backdrop-filter: blur(12px);
- padding: 4px 8px;
- border-radius: 10px;
- border: 1px solid var(--glass-border);
- box-shadow: 0 4px 12px rgba(0,0,0,0.2);
+ display: flex; align-items: center; gap: 4px;
+ background: rgba(15, 15, 15, 0.85);
+ backdrop-filter: blur(16px);
+ padding: 4px; height: 44px;
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
transition: all 0.2s ease;
- height: 42px;
}
-.quick-controls-group:hover { border-color: rgba(255,255,255,0.15); background: rgba(30, 30, 30, 0.7); }
+.quick-controls-group:hover { border-color: rgba(255, 255, 255, 0.2); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); }
+.control-divider { width: 1px; height: 20px; background: rgba(255, 255, 255, 0.1); margin: 0 4px; }
+
+/* Sub/Dub Toggle */
.sd-toggle.small {
- height: 32px;
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255,255,255,0.1);
- border-radius: 8px;
+ height: 34px; min-width: 86px;
+ background: transparent; border: none;
+ display: flex; position: relative; cursor: pointer; box-sizing: border-box;
padding: 2px;
- display: flex;
- position: relative;
- cursor: pointer;
- min-width: 80px;
- box-sizing: border-box;
}
.sd-toggle.small .sd-bg {
- position: absolute;
- top: 2px; left: 2px;
+ position: absolute; top: 2px; left: 2px;
width: calc(50% - 2px); height: calc(100% - 4px);
- background: rgba(255, 255, 255, 0.2);
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 6px;
transition: transform 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
}
.sd-toggle[data-state="dub"] .sd-bg { transform: translateX(100%); }
.sd-toggle.small .sd-option {
- flex: 1;
- z-index: 2;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 0.75rem;
- font-weight: 700;
+ flex: 1; z-index: 2;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 0.75rem; font-weight: 700;
color: rgba(255,255,255,0.4);
- transition: color 0.2s;
- user-select: none;
+ transition: color 0.2s; user-select: none;
}
.sd-toggle.small .sd-option.active { color: #fff; }
.viewers-pill {
- display: flex;
- align-items: center;
- gap: 6px;
- background: rgba(0,0,0,0.3);
- padding: 0 10px;
- border-radius: 20px;
- border: 1px solid rgba(255,255,255,0.05);
- font-size: 0.8rem;
- color: #aaa;
- height: 32px;
+ display: flex; align-items: center; gap: 6px;
+ background: rgba(0,0,0,0.3); padding: 0 10px;
+ border-radius: 20px; border: 1px solid rgba(255,255,255,0.05);
+ font-size: 0.8rem; color: #aaa; height: 32px;
}
+#toggle-chat-btn { position: relative; }
+#toggle-chat-btn.has-unread::after {
+ content: ''; position: absolute; top: 2px; right: 2px;
+ width: 10px; height: 10px; background-color: #ef4444;
+ border: 2px solid #1a1a1a; border-radius: 50%;
+ animation: pulse 2s infinite;
+}
+
+/* =========================================
+ 6. PLAYER
+ ========================================= */
.player-wrapper {
- display: flex !important;
- flex-direction: column;
- flex: 1;
- width: 100%;
- height: auto !important;
- min-height: 0;
- position: relative !important;
- z-index: 1 !important;
- background: transparent !important;
- overflow: hidden;
+ display: flex !important; flex-direction: column; flex: 1;
+ width: 100%; height: auto !important; min-height: 0;
+ position: relative !important; z-index: 1 !important;
+ background: transparent !important; overflow: hidden;
}
-
-.player-container {
- width: 100%;
- height: 100%;
- display: flex;
- flex-direction: column;
- position: relative;
-}
-
+.player-container { width: 100%; height: 100%; display: flex; flex-direction: column; position: relative; }
.video-frame {
- flex: 1;
- min-height: 0;
- position: relative;
- background: #000;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-#player {
- width: 100%;
- height: 100%;
- max-height: 100%;
+ flex: 1; min-height: 0; position: relative;
+ background: #000; display: flex; align-items: center; justify-content: center;
}
+#player { width: 100%; height: 100%; max-height: 100%; }
+#subtitles-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
.custom-controls {
- position: absolute;
- bottom: 0;
- left: 0;
- width: 100%;
- z-index: 60;
+ position: absolute; bottom: 0; left: 0; width: 100%; z-index: 60;
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0) 100%);
padding: 20px 10px 10px;
}
+.controls-left { display: flex; align-items: center; gap: 10px; }
+.time-display { white-space: nowrap; font-variant-numeric: tabular-nums; font-size: 0.9rem; color: #ddd; min-width: fit-content; }
-.controls-left {
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.time-display {
- white-space: nowrap;
- font-variant-numeric: tabular-nums;
- font-size: 0.9rem;
- color: #ddd;
- min-width: fit-content;
-}
-
-#subtitles-canvas {
- position: absolute;
- top: 0; left: 0;
- width: 100%; height: 100%;
- pointer-events: none;
- z-index: 10;
-}
-
-#download-btn,
-#manual-match-btn,
-#server-select,
-#extension-select,
-#sd-toggle,
-.side-nav-btn {
- display: none !important;
-}
+/* Hidden Legacy Buttons */
+#download-btn, #manual-match-btn, #server-select, #extension-select, #sd-toggle, .side-nav-btn { display: none !important; }
+/* Settings Panel */
.settings-panel {
- position: absolute;
- bottom: 70px;
- right: 20px;
- z-index: 1000;
- max-height: 60vh;
- overflow-y: auto;
+ position: absolute; bottom: 70px; right: 20px; z-index: 1000;
+ max-height: 60vh; overflow-y: auto;
background: rgba(15, 15, 15, 0.95);
- border: 1px solid var(--glass-border);
- border-radius: 8px;
+ border: 1px solid var(--glass-border); border-radius: 8px;
}
+/* Video Toasts */
+.video-toast-container {
+ position: absolute; bottom: 100px; left: 20px; z-index: 80;
+ display: flex; flex-direction: column; gap: 10px;
+ pointer-events: none; max-width: 400px;
+}
+.video-toast {
+ display: flex; align-items: center; gap: 10px;
+ background: rgba(15, 15, 15, 0.85); backdrop-filter: blur(8px);
+ padding: 8px 12px; border-radius: 8px;
+ border-left: 3px solid var(--brand-color);
+ color: white; font-size: 0.9rem;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+ animation: toastSlideIn 0.3s ease forwards, toastFadeOut 0.5s ease 4.5s forwards;
+ pointer-events: auto; opacity: 0;
+}
+.toast-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
+.toast-content { display: flex; flex-direction: column; line-height: 1.2; }
+.toast-user { font-weight: 700; font-size: 0.8rem; color: #a78bfa; }
+.toast-msg { color: #eee; margin-top: 2px; }
+.video-toast.system-toast {
+ border-left-color: #9ca3af; background: rgba(20, 20, 20, 0.7);
+ justify-content: center; padding: 6px 12px; min-height: auto;
+}
+.video-toast.system-toast .toast-msg { font-size: 0.85rem; font-style: italic; color: rgba(255, 255, 255, 0.8); margin: 0; }
+
+/* =========================================
+ 7. CHAT SIDEBAR
+ ========================================= */
.chat-sidebar {
- display: flex;
- flex-direction: column;
+ display: flex; flex-direction: column; height: 100%;
background: rgba(15, 15, 15, 0.95);
- border-left: 1px solid var(--glass-border);
- height: 100%;
- overflow: hidden;
-}
-.room-layout.chat-hidden .chat-sidebar {
- opacity: 0;
- pointer-events: none;
- border-left: none;
+ border-left: 1px solid var(--glass-border); overflow: hidden;
}
+.room-layout.chat-hidden .chat-sidebar { opacity: 0; pointer-events: none; border-left: none; }
.chat-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 16px 20px;
- border-bottom: 1px solid var(--glass-border);
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 16px 20px; border-bottom: 1px solid var(--glass-border);
}
.chat-header h3 { margin: 0; font-size: 1.1rem; font-weight: 700; color: white; }
+.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; border-bottom: 2px solid transparent;
+ color: var(--text-muted); font-weight: 600; cursor: pointer;
+ 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; }
+
+/* Users List */
.users-list {
- padding: 12px;
- border-bottom: 1px solid var(--glass-border);
- max-height: 200px;
- overflow-y: auto;
- background: rgba(0,0,0,0.2);
-}
-.user-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 8px 12px;
- border-radius: 8px;
- margin-bottom: 4px;
+ padding: 12px; border-bottom: 1px solid var(--glass-border);
+ max-height: 200px; overflow-y: auto; background: rgba(0,0,0,0.2);
}
+.user-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 8px; margin-bottom: 4px; }
.user-item:hover { background: rgba(255, 255, 255, 0.05); }
.user-avatar {
- width: 32px; height: 32px;
- border-radius: 50%;
+ width: 32px; height: 32px; border-radius: 50%;
background: var(--brand-gradient);
- display: flex;
- align-items: center;
- justify-content: center;
- font-weight: 700;
- color: white;
- font-size: 0.8rem;
+ display: flex; align-items: center; justify-content: center;
+ font-weight: 700; color: white; font-size: 0.8rem;
}
.user-avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.user-name { flex: 1; font-size: 0.9rem; color: white; }
-.user-badge {
- font-size: 0.7rem;
- background: var(--brand-color);
- color: white;
- padding: 2px 8px;
- border-radius: 4px;
- font-weight: 600;
-}
+.user-badge { font-size: 0.7rem; background: var(--brand-color); color: white; padding: 2px 8px; border-radius: 4px; font-weight: 600; }
+/* Chat Messages */
.chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: 16px;
- display: flex;
- flex-direction: column;
- gap: 12px;
- min-height: 0;
+ flex: 1; overflow-y: auto; padding: 16px;
+ display: flex; flex-direction: column; gap: 12px; min-height: 0;
}
.chat-message { display: flex; gap: 10px; }
.chat-message.system { justify-content: center; margin: 8px 0; }
.chat-message.system .message-content {
- background: rgba(255, 255, 255, 0.05);
- color: var(--text-muted);
- font-size: 0.8rem;
- text-align: center;
- padding: 4px 12px;
- border-radius: 12px;
+ background: rgba(255, 255, 255, 0.05); color: var(--text-muted);
+ font-size: 0.8rem; text-align: center; padding: 4px 12px; border-radius: 12px;
}
.message-avatar {
- width: 36px; height: 36px;
- border-radius: 50%;
+ width: 36px; height: 36px; 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;
+ display: flex; align-items: center; justify-content: center;
+ font-weight: 700; color: white; font-size: 0.9rem; flex-shrink: 0;
}
.message-body { flex: 1; min-width: 0; }
.message-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.message-username { font-weight: 600; color: white; font-size: 0.9rem; }
.message-time { font-size: 0.75rem; color: rgba(255, 255, 255, 0.4); }
-.message-content {
- color: rgba(255, 255, 255, 0.9);
- font-size: 0.95rem;
- line-height: 1.4;
- word-wrap: break-word;
-}
+.message-content { color: rgba(255, 255, 255, 0.9); font-size: 0.95rem; line-height: 1.4; word-wrap: break-word; }
-.chat-input {
- display: flex;
- gap: 8px;
- padding: 16px;
- border-top: 1px solid var(--glass-border);
-}
+.chat-input { display: flex; gap: 8px; padding: 16px; border-top: 1px solid var(--glass-border); }
.chat-input input {
- flex: 1;
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid var(--glass-border);
- color: white;
- padding: 12px 16px;
- border-radius: 10px;
- outline: none;
+ flex: 1; background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--glass-border); color: white;
+ padding: 12px 16px; border-radius: 10px; outline: none;
}
.chat-input button {
- background: var(--brand-color);
- border: none;
- color: white;
- padding: 0 16px;
- border-radius: 10px;
- cursor: pointer;
- transition: background 0.2s;
+ background: var(--brand-color); border: none; color: white;
+ padding: 0 16px; border-radius: 10px; cursor: pointer; transition: background 0.2s;
}
.chat-input button:hover { background: #7c3aed; }
+/* =========================================
+ 8. QUEUE TAB
+ ========================================= */
+.queue-list {
+ flex: 1; overflow-y: auto; padding: 10px;
+ 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; 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-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-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; }
+.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; }
+
+/* =========================================
+ 9. MODALS
+ ========================================= */
.modal-overlay {
position: fixed; inset: 0;
- background: rgba(0, 0, 0, 0.8);
- backdrop-filter: blur(8px);
- display: none;
- align-items: center;
- justify-content: center;
- z-index: 10000;
- opacity: 0;
- transition: opacity 0.3s ease;
+ background: rgba(0, 0, 0, 0.8); backdrop-filter: blur(8px);
+ display: none; align-items: center; justify-content: center;
+ z-index: 10000; opacity: 0; transition: opacity 0.3s ease;
}
.modal-overlay.show { display: flex; opacity: 1; }
.modal-content {
background: rgba(20, 20, 20, 0.95);
- border: 1px solid var(--glass-border);
- border-radius: 16px;
- padding: 32px;
- max-width: 500px;
- width: 90%;
- position: relative;
- transform: scale(0.9);
- transition: transform 0.3s ease;
+ border: 1px solid var(--glass-border); border-radius: 16px;
+ padding: 32px; max-width: 500px; width: 90%;
+ position: relative; transform: scale(0.9); transition: transform 0.3s ease;
}
.modal-overlay.show .modal-content { transform: scale(1); }
.modal-title { margin: 0 0 24px 0; font-size: 1.5rem; font-weight: 800; color: white; }
@@ -581,35 +474,36 @@ input:focus, .form-input:focus {
.form-group label { display: block; margin-bottom: 8px; font-weight: 600; color: white; }
.form-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
+/* Join Info */
+.join-host-info { display: flex; flex-direction: column; align-items: center; margin-bottom: 24px; animation: fadeIn 0.5s ease; }
+.join-avatar-container {
+ width: 80px; height: 80px; border-radius: 50%; padding: 3px;
+ background: var(--brand-gradient); margin-bottom: 12px;
+ box-shadow: 0 4px 15px rgba(139, 92, 246, 0.4);
+}
+.join-avatar-container img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; border: 3px solid #1a1a1a; background: #2a2a2a; }
+.join-text { font-size: 1.1rem; color: white; font-weight: 500; text-align: center; margin: 0; }
+.join-text span { font-weight: 700; color: #a78bfa; }
+
+/* Search Content */
.anime-search-content { max-width: 800px; max-height: 85vh; display: flex; flex-direction: column; }
.search-bar { display: flex; gap: 12px; margin-bottom: 20px; }
.search-bar button {
- background: var(--brand-color);
- border: none; color: white; padding: 12px 24px;
- border-radius: 10px; font-weight: 700; cursor: pointer;
+ background: var(--brand-color); border: none; color: white;
+ padding: 12px 24px; border-radius: 10px; font-weight: 700; cursor: pointer;
}
.anime-results {
- flex: 1;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 12px;
- min-height: 0;
- max-height: 60vh;
- padding-right: 8px;
+ flex: 1; overflow-y: auto; display: flex; flex-direction: column;
+ gap: 12px; min-height: 0; max-height: 60vh; padding-right: 8px;
}
-
.anime-result-item, .search-item {
display: flex; gap: 16px; padding: 12px;
background: rgba(255, 255, 255, 0.03);
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 12px;
- cursor: pointer; text-decoration: none; color: inherit;
- transition: all 0.2s;
+ border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px;
+ cursor: pointer; text-decoration: none; color: inherit; transition: all 0.2s;
}
.anime-result-item:hover {
- background: rgba(255, 255, 255, 0.08);
- border-color: var(--brand-color);
+ background: rgba(255, 255, 255, 0.08); border-color: var(--brand-color);
transform: translateX(4px);
}
.search-poster { width: 60px; height: 85px; border-radius: 8px; object-fit: cover; flex-shrink: 0; }
@@ -617,6 +511,7 @@ input:focus, .form-input:focus {
.search-title { font-weight: 700; color: white; margin-bottom: 4px; font-size: 1rem; }
.search-meta { font-size: 0.85rem; color: var(--text-muted); }
+/* Config Layout */
.config-layout { display: flex; gap: 24px; margin-top: 20px; }
.config-sidebar { width: 140px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.config-cover {
@@ -631,88 +526,100 @@ input:focus, .form-input:focus {
margin-bottom: 8px; font-weight: 700;
}
-input[type=number]::-webkit-inner-spin-button,
-input[type=number]::-webkit-outer-spin-button {
- -webkit-appearance: none;
- margin: 0;
+/* Chips & Toggles in Modal */
+.chips-grid { display: flex; flex-wrap: wrap; gap: 10px; }
+.chip {
+ padding: 8px 16px; background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px;
+ color: #ccc; font-size: 0.9rem; cursor: pointer;
+ transition: all 0.2s; user-select: none;
}
-input[type=number] {
- -moz-appearance: textfield;
-}
-
-.ep-control {
- display: flex;
- align-items: center;
- justify-content: space-between;
- 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;
- margin-top: 8px;
- box-shadow: inset 0 2px 4px rgba(0,0,0,0.2);
-}
-
-.ep-btn {
- width: 40px;
- height: 38px;
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(255, 255, 255, 0.1);
- border: 1px solid rgba(255,255,255,0.05);
- color: white;
- border-radius: 8px;
- cursor: pointer;
- font-size: 1.2rem;
- transition: all 0.2s ease;
-}
-
-.ep-btn:hover {
- background: var(--brand-color);
- border-color: var(--brand-color);
- transform: translateY(-1px);
-}
-
-.ep-btn:active {
- transform: translateY(1px);
-}
-
-.ep-input {
- flex: 1;
- min-width: 0;
- background: transparent;
- border: none;
- color: white;
- text-align: center;
- font-size: 1.2rem;
- font-weight: 800;
- outline: none;
- font-family: monospace;
-}
-
-.ep-input:focus {
- background: transparent;
- border: none;
-}
-
-.cat-toggle {
- display: flex; background: rgba(0,0,0,0.3);
- padding: 4px; border-radius: 10px; width: fit-content;
+.chip:hover { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.3); transform: translateY(-2px); }
+.chip.active {
+ background: var(--brand-color); border-color: var(--brand-color);
+ color: white; box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
}
+.cat-toggle { display: flex; background: rgba(0,0,0,0.3); padding: 4px; border-radius: 10px; width: fit-content; }
.cat-opt {
- padding: 6px 16px; border-radius: 8px;
- font-size: 0.85rem; color: #888;
+ padding: 6px 16px; border-radius: 8px; font-size: 0.85rem; color: #888;
cursor: pointer; transition: all 0.2s; font-weight: 600;
}
.cat-opt.active { background: rgba(255,255,255,0.15); color: white; }
.cat-opt.disabled { opacity: 0.3; pointer-events: none; text-decoration: line-through; }
-.grid-loader {
- width: 100%; padding: 20px; text-align: center;
- color: var(--text-muted); font-size: 0.9rem;
+/* Episodes Grid */
+.episodes-grid-container {
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(50px, 1fr));
+ gap: 8px; max-height: 280px; overflow-y: auto; padding: 10px;
+ background: rgba(0, 0, 0, 0.2); border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+}
+.episodes-grid-container::-webkit-scrollbar { width: 4px; }
+.episodes-grid-container::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); }
+
+.ep-grid-btn {
+ background: rgba(255,255,255,0.03); border: 1px solid rgba(255, 255, 255, 0.1);
+ color: #ccc; padding: 8px 0; border-radius: 6px; text-align: center;
+ font-size: 0.9rem; font-weight: 600; cursor: pointer;
+ transition: all 0.2s; user-select: none;
+}
+.ep-grid-btn:hover { background: white; color: black; transform: translateY(-2px); }
+.ep-grid-btn.selected, .ep-grid-btn.active {
+ background: var(--brand-color); color: white;
+ border-color: var(--brand-color); box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
+}
+
+.btn-text-tiny {
+ background: transparent; border: none; color: var(--brand-color);
+ font-size: 0.75rem; font-weight: 700; cursor: pointer; padding: 2px 6px;
+}
+.btn-text-tiny:hover { text-decoration: underline; }
+
+/* Manual Control */
+.ep-control {
+ display: flex; align-items: center; justify-content: space-between;
+ 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;
+ margin-top: 8px; box-shadow: inset 0 2px 4px rgba(0,0,0,0.2);
+}
+.ep-btn {
+ width: 40px; height: 38px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
+ background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255,255,255,0.05);
+ color: white; border-radius: 8px; cursor: pointer; font-size: 1.2rem; transition: all 0.2s ease;
+}
+.ep-btn:hover { background: var(--brand-color); border-color: var(--brand-color); transform: translateY(-1px); }
+.ep-btn:active { transform: translateY(1px); }
+.ep-input {
+ flex: 1; min-width: 0; background: transparent; border: none;
+ color: white; text-align: center; font-size: 1.2rem; font-weight: 800;
+ outline: none; font-family: monospace;
+}
+.grid-loader { width: 100%; padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.9rem; }
+
+/* Modal Pagination */
+.modal-pagination {
+ display: flex; justify-content: center; align-items: center; gap: 15px;
+ margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.05);
+}
+.modal-page-btn {
+ background: transparent; border: 1px solid rgba(255, 255, 255, 0.2);
+ color: white; width: 30px; height: 30px; border-radius: 6px;
+ display: flex; align-items: center; justify-content: center;
+ cursor: pointer; transition: all 0.2s;
+}
+.modal-page-btn:hover:not(:disabled) { background: white; color: black; }
+.modal-page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
+.modal-page-info { font-size: 0.85rem; color: #888; font-weight: 700; }
+
+/* =========================================
+ 10. MEDIA QUERIES (Combined)
+ ========================================= */
+@media (max-width: 1200px) {
+ .room-layout { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
+ .room-layout.chat-hidden { grid-template-rows: 1fr 0px; }
+ .chat-sidebar {
+ height: 350px; border-left: none; border-top: 1px solid var(--glass-border);
+ }
}
@media (max-width: 600px) {
@@ -720,437 +627,4 @@ input[type=number] {
.config-sidebar { width: 100%; flex-direction: row; }
.config-cover { width: 80px; }
.ep-control { width: auto; flex: 1; }
-}
-
-.container { max-width: 1400px; margin: 0 auto; padding: 80px 40px 40px; }
-.header h1 {
- font-size: 2.5rem; font-weight: 800;
- background: var(--brand-gradient);
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
-}
-.rooms-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
- gap: 24px;
-}
-.room-card {
- background: var(--glass-bg);
- border: 1px solid var(--glass-border);
- border-radius: 16px; padding: 24px;
- backdrop-filter: var(--glass-blur);
- cursor: pointer; transition: all 0.3s;
-}
-.room-card:hover {
- transform: translateY(-4px);
- border-color: var(--brand-color);
- box-shadow: 0 8px 32px rgba(139, 92, 246, 0.3);
-}
-.room-card-title { font-size: 1.25rem; font-weight: 700; color: white; margin-bottom: 4px; }
-.room-card-host { font-size: 0.85rem; color: var(--text-muted); }
-.room-card-footer {
- display: flex; justify-content: space-between;
- align-items: center; padding-top: 16px;
- border-top: 1px solid rgba(255, 255, 255, 0.1); margin-top: 16px;
-}
-
-.join-host-info {
- display: flex;
- flex-direction: column;
- align-items: center;
- margin-bottom: 24px;
- animation: fadeIn 0.5s ease;
-}
-
-.join-avatar-container {
- width: 80px;
- height: 80px;
- border-radius: 50%;
- padding: 3px;
- background: var(--brand-gradient);
- margin-bottom: 12px;
- box-shadow: 0 4px 15px rgba(139, 92, 246, 0.4);
-}
-
-.join-avatar-container img {
- width: 100%;
- height: 100%;
- border-radius: 50%;
- object-fit: cover;
- border: 3px solid #1a1a1a;
- background: #2a2a2a;
-}
-
-.join-text {
- font-size: 1.1rem;
- color: white;
- font-weight: 500;
- text-align: center;
- margin: 0;
-}
-
-.join-text span {
- font-weight: 700;
- color: #a78bfa;
-}
-
-@keyframes fadeIn {
- from { opacity: 0; transform: translateY(-10px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-.video-toast-container {
- position: absolute;
- bottom: 100px;
- left: 20px;
- z-index: 80;
- display: flex;
- flex-direction: column;
- gap: 10px;
- pointer-events: none;
- max-width: 400px;
-}
-
-.video-toast {
- display: flex;
- align-items: center;
- gap: 10px;
- background: rgba(15, 15, 15, 0.85);
- backdrop-filter: blur(8px);
- padding: 8px 12px;
- border-radius: 8px;
- border-left: 3px solid var(--brand-color);
- color: white;
- font-size: 0.9rem;
- box-shadow: 0 4px 12px rgba(0,0,0,0.3);
-
- animation: toastSlideIn 0.3s ease forwards, toastFadeOut 0.5s ease 4.5s forwards;
- pointer-events: auto;
- opacity: 0;
-}
-
-.toast-avatar {
- width: 28px;
- height: 28px;
- border-radius: 50%;
- object-fit: cover;
- flex-shrink: 0;
-}
-
-.toast-content {
- display: flex;
- flex-direction: column;
- line-height: 1.2;
-}
-
-.toast-user {
- font-weight: 700;
- font-size: 0.8rem;
- color: #a78bfa;
-}
-
-.toast-msg {
- color: #eee;
- margin-top: 2px;
-}
-
-#toggle-chat-btn {
- position: relative;
-}
-
-#toggle-chat-btn.has-unread::after {
- content: '';
- position: absolute;
- top: 2px;
- right: 2px;
- width: 10px;
- height: 10px;
- background-color: #ef4444;
- border: 2px solid #1a1a1a;
- border-radius: 50%;
- animation: pulse 2s infinite;
-}
-
-@keyframes toastSlideIn {
- from { opacity: 0; transform: translateX(-20px); }
- to { opacity: 1; transform: translateX(0); }
-}
-
-@keyframes toastFadeOut {
- from { opacity: 1; transform: translateY(0); }
- to { opacity: 0; transform: translateY(-10px); visibility: hidden; }
-}
-
-@keyframes pulse {
- 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
- 70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
- 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
-}
-
-.video-toast.system-toast {
- border-left-color: #9ca3af;
- background: rgba(20, 20, 20, 0.7);
- justify-content: center;
- padding: 6px 12px;
- min-height: auto;
-}
-
-.video-toast.system-toast .toast-msg {
- font-size: 0.85rem;
- font-style: italic;
- color: rgba(255, 255, 255, 0.8);
- margin: 0;
-}
-
-@keyframes slideUp {
- from {
- transform: translateX(-50%) translateY(20px);
- opacity: 0;
- }
- to {
- transform: translateX(-50%) translateY(0);
- opacity: 1;
- }
-}
-
-@keyframes fadeOut {
- from {
- opacity: 1;
- }
- to {
- opacity: 0;
- }
-}
-
-@keyframes slideUp {
- from {
- transform: translateX(-50%) translateY(20px);
- opacity: 0;
- }
- to {
- transform: translateX(-50%) translateY(0);
- opacity: 1;
- }
-}
-
-@keyframes slideDown {
- from {
- transform: translateX(-50%) translateY(-20px);
- opacity: 0;
- }
- to {
- transform: translateX(-50%) translateY(0);
- opacity: 1;
- }
-}
-
-@keyframes fadeOut {
- from {
- opacity: 1;
- }
- to {
- opacity: 0;
- }
-}
-
-#copy-invite-btn {
- transition: all 0.3s ease;
-}
-
-#copy-invite-btn:hover {
- transform: scale(1.05);
-}
-
-#copy-invite-btn:active {
- transform: scale(0.95);
-}
-
-@keyframes scaleIn {
- from {
- transform: translate(-50%, -50%) scale(0.8);
- opacity: 0;
- }
- to {
- transform: translate(-50%, -50%) scale(1);
- opacity: 1;
- }
-}
-
-.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 ed4f349..4e6b280 100644
--- a/docker/views/room.html
+++ b/docker/views/room.html
@@ -39,23 +39,32 @@
Dub