added watchparties

This commit is contained in:
2026-01-04 16:49:59 +01:00
parent cde09c6ffa
commit d9c1ba3d27
48 changed files with 7585 additions and 167 deletions

View File

@@ -16,6 +16,9 @@ const AnimePlayer = (function() {
let _totalEpisodes = 0;
let _manualExtensionId = null;
let _activeSubtitleIndex = -1;
let _roomMode = false;
let _isRoomHost = false;
let _roomWebSocket = null;
let hlsInstance = null;
let subtitleRenderer = null;
@@ -61,23 +64,30 @@ const AnimePlayer = (function() {
subtitlesCanvas: null
};
function init(animeId, initialSource, isLocal, animeData) {
function init(animeId, initialSource, isLocal, animeData, roomMode = false) {
_roomMode = roomMode;
_animeId = animeId;
_entrySource = initialSource || 'anilist';
_isLocal = isLocal;
_malId = animeData.idMal || null;
_totalEpisodes = animeData.episodes || 1000;
if (animeData.title) {
_animeTitle = animeData.title.romaji || animeData.title.english || animeData.title.native || animeData.title || "Anime";
_animeTitle = animeData.title.romaji || animeData.title.english || "Anime";
}
_skipIntervals = [];
_localEntryId = null;
initElements();
setupEventListeners();
loadExtensionsList();
// 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();
}
}
function initElements() {
@@ -134,8 +144,10 @@ const AnimePlayer = (function() {
function setupEventListeners() {
// Close player
const closeBtn = document.getElementById('close-player-btn');
if(closeBtn) closeBtn.addEventListener('click', closePlayer);
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);
@@ -183,18 +195,35 @@ const AnimePlayer = (function() {
setupKeyboardShortcuts();
}
function setupCustomControls() {
// Play/Pause
if(els.playPauseBtn) {
els.playPauseBtn.onclick = togglePlayPause;
}
if(els.video) {
// Remove old listeners to be safe (though usually new element)
els.video.onclick = togglePlayPause;
els.video.ondblclick = toggleFullscreen;
function loadVideoFromRoom(videoData) {
console.log('AnimePlayer.loadVideoFromRoom called with:', videoData);
if (!videoData || !videoData.url) {
console.error('Invalid video data provided to loadVideoFromRoom');
return;
}
// Volume
_currentSubtitles = videoData.subtitles || [];
if (els.loader) els.loader.style.display = 'none';
initVideoPlayer(videoData.url, videoData.type || 'm3u8', videoData.subtitles || []);
}
function setupCustomControls() {
// ELIMINADO: if (_roomMode && !_isRoomHost) return;
// Ahora permitimos que el código fluya para habilitar volumen y ajustes a todos
// 1. Play/Pause (SOLO HOST)
if(els.playPauseBtn) {
els.playPauseBtn.onclick = togglePlayPause; // La validación de permiso se hará dentro de togglePlayPause
}
if(els.video) {
els.video.onclick = togglePlayPause; // Click en video para pausar
els.video.ondblclick = toggleFullscreen; // Doble click siempre permitido
}
// 2. Volume (TODOS)
if(els.volumeBtn) {
els.volumeBtn.onclick = toggleMute;
}
@@ -204,7 +233,7 @@ const AnimePlayer = (function() {
};
}
// Settings
// 3. Settings (TODOS - Aquí están los subtítulos y audio)
if(els.settingsBtn) {
els.settingsBtn.onclick = (e) => {
e.stopPropagation();
@@ -219,7 +248,7 @@ const AnimePlayer = (function() {
};
}
// Close settings when clicking outside
// Close settings when clicking outside (TODOS)
document.onclick = (e) => {
if (settingsPanelActive && els.settingsPanel &&
!els.settingsPanel.contains(e.target) &&
@@ -229,19 +258,19 @@ const AnimePlayer = (function() {
}
};
// Fullscreen
// 4. Fullscreen (TODOS)
if(els.fullscreenBtn) {
els.fullscreenBtn.onclick = toggleFullscreen;
}
// Progress bar
// 5. Progress bar (SOLO HOST para buscar, TODOS para ver)
if(els.progressContainer) {
// El listener se añade, pero seekToPosition bloqueará a los invitados
els.progressContainer.onclick = seekToPosition;
}
// Video events
// 6. Video events (TODOS - Necesarios para actualizar la UI localmente)
if(els.video) {
// Remove previous listeners first if sticking to same element, but we replace element usually
els.video.onplay = onPlay;
els.video.onpause = onPause;
els.video.ontimeupdate = onTimeUpdate;
@@ -283,9 +312,27 @@ const AnimePlayer = (function() {
document.addEventListener('keydown', (e) => {
if (!els.playerWrapper || els.playerWrapper.style.display === 'none') return;
// Ignore if typing in input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// En room mode, solo el host puede usar shortcuts de control
if (_roomMode && !_isRoomHost) {
// Permitir fullscreen y volumen para todos
if (e.key.toLowerCase() === 'f') {
e.preventDefault();
toggleFullscreen();
} else if (e.key.toLowerCase() === 'm') {
e.preventDefault();
toggleMute();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
adjustVolume(0.1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
adjustVolume(-0.1);
}
return;
}
switch(e.key.toLowerCase()) {
case ' ':
case 'k':
@@ -352,11 +399,23 @@ const AnimePlayer = (function() {
// Control functions
function togglePlayPause() {
if (_roomMode && !_isRoomHost) {
console.log('Guests cannot control playback');
return;
}
if (!els.video) return;
if (els.video.paused) {
els.video.play().catch(() => {});
if (_roomMode && _isRoomHost) {
sendRoomEvent('play', { currentTime: els.video.currentTime });
}
} else {
els.video.pause();
if (_roomMode && _isRoomHost) {
sendRoomEvent('pause', { currentTime: els.video.currentTime });
}
}
}
@@ -398,9 +457,18 @@ const AnimePlayer = (function() {
function seekToPosition(e) {
if (!els.video || !els.progressContainer) return;
if (_roomMode && !_isRoomHost) return;
const rect = els.progressContainer.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
els.video.currentTime = pos * els.video.duration;
const newTime = pos * els.video.duration;
els.video.currentTime = newTime;
// En room mode, enviar evento de seek
if (_roomMode && _isRoomHost) {
sendRoomEvent('seek', { currentTime: newTime });
}
}
function updateProgressHandle(e) {
@@ -410,14 +478,30 @@ const AnimePlayer = (function() {
els.progressHandle.style.left = `${pos * 100}%`;
}
function seekRelative(seconds) {
if (!els.video) return;
els.video.currentTime = Math.max(0, Math.min(els.video.duration, els.video.currentTime + seconds));
}
if (_roomMode && !_isRoomHost) return;
const newTime = Math.max(0, Math.min(els.video.duration, els.video.currentTime + seconds));
els.video.currentTime = newTime;
// En room mode, enviar evento de seek
if (_roomMode && _isRoomHost) {
sendRoomEvent('seek', { currentTime: newTime });
}
}
function seekToPercent(percent) {
if (!els.video) return;
els.video.currentTime = els.video.duration * percent;
if (_roomMode && !_isRoomHost) return;
const newTime = els.video.duration * percent;
els.video.currentTime = newTime;
// En room mode, enviar evento de seek
if (_roomMode && _isRoomHost) {
sendRoomEvent('seek', { currentTime: newTime });
}
}
// Video event handlers
@@ -476,7 +560,7 @@ const AnimePlayer = (function() {
}
function onEnded() {
if (_currentEpisode < _totalEpisodes) {
if (!_roomMode && _currentEpisode < _totalEpisodes) {
playEpisode(_currentEpisode + 1);
}
}
@@ -501,6 +585,22 @@ const AnimePlayer = (function() {
els.volumeBtn.innerHTML = icon;
}
function sendRoomEvent(eventType, data = {}) {
if (!_roomMode || !_isRoomHost || !_roomWebSocket) return;
if (_roomWebSocket.readyState !== WebSocket.OPEN) return;
console.log('Sending room event:', eventType, data);
_roomWebSocket.send(JSON.stringify({
type: eventType,
...data
}));
}
function setWebSocket(ws) {
console.log('Setting WebSocket reference in AnimePlayer');
_roomWebSocket = ws;
}
function formatTime(seconds) {
if (!isFinite(seconds) || isNaN(seconds)) return '0:00';
const h = Math.floor(seconds / 3600);
@@ -563,7 +663,7 @@ const AnimePlayer = (function() {
}
// 4. Playback Speed
if (els.video) {
if (els.video && (!_roomMode || _isRoomHost)) {
const label = els.video.playbackRate === 1 ? 'Normal' : `${els.video.playbackRate}x`;
html += createMenuItem('speed', 'Playback Speed', label, Icons.speed);
}
@@ -812,7 +912,7 @@ const AnimePlayer = (function() {
}
// Update progress for AniList
if (!_progressUpdated && els.video.duration) {
if (!_roomMode && !_progressUpdated && els.video.duration) {
const percentage = els.video.currentTime / els.video.duration;
if (percentage >= 0.8) {
updateProgress();
@@ -1183,8 +1283,9 @@ const AnimePlayer = (function() {
}
function initVideoPlayer(url, type, subtitles = []) {
// 1. CLEANUP FIRST: Destroy subtitle renderer while elements still exist
// This prevents "removeChild" errors because the DOM is still intact
console.log('initVideoPlayer called:', { url, type, subtitles });
// 1. CLEANUP FIRST
if (subtitleRenderer) {
try {
subtitleRenderer.dispose();
@@ -1194,16 +1295,18 @@ const AnimePlayer = (function() {
subtitleRenderer = null;
}
// 2. Destroy HLS instance
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
const container = document.querySelector('.video-frame');
if (!container) return;
if (!container) {
console.error('Video frame container not found!');
return;
}
// 3. Remove OLD Elements
// 2. Remove OLD Elements
const oldVideo = container.querySelector('video');
const oldCanvas = container.querySelector('#subtitles-canvas');
@@ -1216,65 +1319,65 @@ const AnimePlayer = (function() {
oldCanvas.remove();
}
// 4. Create NEW Elements
// 3. Create NEW Elements - CANVAS FIRST, then VIDEO
const newCanvas = document.createElement('canvas');
newCanvas.id = 'subtitles-canvas';
const newVideo = document.createElement('video');
newVideo.id = 'player';
newVideo.crossOrigin = 'anonymous';
newVideo.playsInline = true;
const newCanvas = document.createElement('canvas');
newCanvas.id = 'subtitles-canvas';
container.appendChild(newCanvas)
container.appendChild(newCanvas);
container.appendChild(newVideo);
els.video = newVideo;
els.subtitlesCanvas = newCanvas;
console.log('Video and canvas elements created:', { video: els.video, canvas: els.subtitlesCanvas });
// Re-setup controls with new video element
setupCustomControls();
// 5. Initialize Player (HLS or Native)
// Hide loader
if (els.loader) els.loader.style.display = 'none';
// 4. Initialize Player
if (Hls.isSupported() && type === 'm3u8') {
console.log('Initializing HLS player');
hlsInstance = new Hls({
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 90
backBufferLength: 90,
debug: false
});
hlsInstance.on(Hls.Events.ERROR, (event, data) => {
console.error('HLS Error:', data);
if (data.fatal) {
if (els.loader) {
els.loader.style.display = 'flex';
if (els.loaderText) els.loaderText.textContent = 'Stream error: ' + (data.details || 'Unknown');
}
}
});
hlsInstance.attachMedia(els.video);
hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
console.log('HLS media attached, loading source:', url);
hlsInstance.loadSource(url);
});
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('HLS manifest parsed, attaching subtitles');
attachSubtitles(subtitles);
buildSettingsPanel();
if (els.downloadBtn) els.downloadBtn.style.display = 'flex';
els.video.play().catch(() => {});
if (els.loader) els.loader.style.display = 'none';
});
hlsInstance.on(Hls.Events.LEVEL_SWITCHED, () => buildSettingsPanel());
hlsInstance.on(Hls.Events.AUDIO_TRACK_SWITCHED, () => buildSettingsPanel());
if (els.downloadBtn && !_roomMode) els.downloadBtn.style.display = 'flex';
} else {
els.video.src = url;
attachSubtitles(subtitles);
buildSettingsPanel();
els.video.play().catch(() => {});
if(els.loader) els.loader.style.display = 'none';
if (els.downloadBtn) els.downloadBtn.style.display = 'flex';
}
// 6. Init Subtitles with explicit delay
// We use setTimeout instead of requestAnimationFrame to let the Layout Engine catch up
if (type === 'm3u8') {
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
attachSubtitles(subtitles);
buildSettingsPanel();
if (els.downloadBtn) els.downloadBtn.style.display = 'flex';
// IMPORTANTE: Esperar a loadedmetadata antes de init subtitles
// --- FIX: Inicializar el renderizador de subtítulos para HLS ---
if (els.video.readyState >= 1) {
initSubtitleRenderer();
} else {
@@ -1282,23 +1385,34 @@ const AnimePlayer = (function() {
initSubtitleRenderer();
}, { once: true });
}
// -------------------------------------------------------------
els.video.play().catch(() => {});
if (els.loader) els.loader.style.display = 'none';
console.log('Attempting to play video');
els.video.play().catch(err => {
console.error('Play error:', err);
});
});
hlsInstance.on(Hls.Events.LEVEL_SWITCHED, () => buildSettingsPanel());
hlsInstance.on(Hls.Events.AUDIO_TRACK_SWITCHED, () => buildSettingsPanel());
} else {
console.log('Using native video player');
els.video.src = url;
attachSubtitles(subtitles);
buildSettingsPanel();
// Para video directo, esperar metadata
els.video.addEventListener('loadedmetadata', () => {
console.log('Video metadata loaded');
initSubtitleRenderer();
}, { once: true });
els.video.play().catch(() => {});
if(els.loader) els.loader.style.display = 'none';
if (els.downloadBtn) els.downloadBtn.style.display = 'flex';
console.log('Attempting to play video');
els.video.play().catch(err => {
console.error('Play error:', err);
});
if (els.downloadBtn && !_roomMode) els.downloadBtn.style.display = 'flex';
}
}
@@ -1762,12 +1876,19 @@ const AnimePlayer = (function() {
// RPC
function sendRPC({ startTimestamp, endTimestamp, paused = false } = {}) {
let stateText = `Episode ${_currentEpisode}`;
let detailsText = _animeTitle;
if (_roomMode) {
stateText = `Watch Party - Ep ${_currentEpisode}`;
}
fetch("/api/rpc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
details: _animeTitle,
state: `Episode ${_currentEpisode}`,
details: detailsText,
state: stateText,
mode: "watching",
startTimestamp,
endTimestamp,
@@ -1800,9 +1921,29 @@ const AnimePlayer = (function() {
}
}
function setRoomHost(isHost) {
console.log('Setting player host status:', isHost);
_isRoomHost = isHost;
// Re-ejecutar la configuración de controles con el nuevo permiso
setupCustomControls();
// Forzar actualización visual si es necesario
if (els.playerWrapper) {
if (isHost) els.playerWrapper.classList.add('is-host');
else els.playerWrapper.classList.remove('is-host');
}
}
return {
init,
playEpisode,
getCurrentEpisode: () => _currentEpisode
getCurrentEpisode: () => _currentEpisode,
loadVideoFromRoom,
getVideoElement: () => els.video,
setRoomHost,
setWebSocket
};
})();
})();
window.AnimePlayer = AnimePlayer;

View File

@@ -115,4 +115,16 @@ function setupDropdown() {
})
}
loadMeUI()
loadMeUI()
const createRoomModal = new CreateRoomModal();
const createBtn = document.getElementById('nav-create-party');
if (createBtn) {
createBtn.addEventListener('click', (e) => {
e.preventDefault();
const dropdown = document.getElementById('nav-dropdown');
if(dropdown) dropdown.classList.remove('active');
createRoomModal.open();
});
}

View File

@@ -0,0 +1,128 @@
class CreateRoomModal {
constructor() {
this.modalId = 'cr-modal-overlay';
this.isRendered = false;
this.render(); // Crear el HTML en el DOM al instanciar
}
render() {
if (document.getElementById(this.modalId)) return;
const modalHtml = `
<div class="cr-modal-overlay" id="${this.modalId}">
<div class="cr-modal-content">
<button class="cr-modal-close" id="cr-close">✕</button>
<h2 class="cr-modal-title">Create Watch Party</h2>
<form id="cr-form">
<div class="cr-form-group">
<label>Room Name</label>
<input type="text" class="cr-input" name="name" placeholder="My Awesome Room" required maxlength="50" />
</div>
<div class="cr-form-group">
<label>Password (Optional)</label>
<input type="password" class="cr-input" name="password" placeholder="Leave empty for public" maxlength="50" />
</div>
<div class="cr-actions">
<button type="button" class="cr-btn-cancel" id="cr-cancel">Cancel</button>
<button type="submit" class="cr-btn-confirm">Create Room</button>
</div>
</form>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
this.bindEvents();
this.isRendered = true;
}
bindEvents() {
const modal = document.getElementById(this.modalId);
const closeBtn = document.getElementById('cr-close');
const cancelBtn = document.getElementById('cr-cancel');
const form = document.getElementById('cr-form');
const close = () => this.close();
closeBtn.onclick = close;
cancelBtn.onclick = close;
// Cerrar si clicamos fuera del contenido
modal.onclick = (e) => {
if (e.target === modal) close();
};
form.onsubmit = (e) => this.handleSubmit(e);
}
open() {
const token = localStorage.getItem('token');
if (!token) {
// Aquí puedes disparar tu modal de login o redirigir
alert('You must be logged in to create a room');
window.location.href = '/login'; // Opcional
return;
}
const modal = document.getElementById(this.modalId);
modal.classList.add('show');
document.querySelector('#cr-form input[name="name"]').focus();
}
close() {
const modal = document.getElementById(this.modalId);
modal.classList.remove('show');
document.getElementById('cr-form').reset();
}
async handleSubmit(e) {
e.preventDefault();
const btn = e.target.querySelector('button[type="submit"]');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Creating...';
const formData = new FormData(e.target);
const name = formData.get('name').trim();
const password = formData.get('password').trim();
const token = localStorage.getItem('token');
try {
const res = await fetch('/api/rooms', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
name,
password: password || undefined
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create room');
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}`;
} catch (err) {
alert(err.message);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
}
window.CreateRoomModal = CreateRoomModal;

1196
desktop/src/scripts/room.js Normal file

File diff suppressed because it is too large Load Diff