Files
WaifuBoard/docker/src/scripts/anime/player.js

1952 lines
71 KiB
JavaScript

const AnimePlayer = (function() {
let _animeId = null;
let _currentEpisode = 0;
let _entrySource = 'anilist';
let _audioMode = 'sub';
let _isLocal = false;
let _malId = null;
let _skipBtn = null;
let _skipIntervals = [];
let _progressUpdated = false;
let _animeTitle = "Anime";
let _rpcActive = false;
let _rawVideoData = null;
let _currentSubtitles = [];
let _localEntryId = null;
let _totalEpisodes = 0;
let _manualExtensionId = null;
let _activeSubtitleIndex = -1;
let _roomMode = false;
let _isRoomHost = false;
let _roomWebSocket = null;
let hlsInstance = null;
let subtitleRenderer = null;
let cursorTimeout = null;
let settingsPanelActive = false;
let _settingsView = 'main';
const els = {
wrapper: null,
playerWrapper: null,
playerContainer: null,
video: null,
loader: null,
loaderText: null,
serverSelect: null,
extSelect: null,
subDubToggle: null,
epTitle: null,
prevBtn: null,
nextBtn: null,
mpvBtn: null,
downloadBtn: null,
downloadModal: null,
dlQualityList: null,
dlAudioList: null,
dlSubsList: null,
dlConfirmBtn: null,
dlCancelBtn: null,
manualMatchBtn: null,
// Custom Controls
playPauseBtn: null,
volumeBtn: null,
volumeSlider: null,
timeDisplay: null,
settingsBtn: null,
settingsPanel: null,
fullscreenBtn: null,
progressContainer: null,
progressPlayed: null,
progressBuffer: null,
progressHandle: null,
subtitlesCanvas: null
};
function init(animeId, initialSource, isLocal, animeData, roomMode = false) {
_roomMode = roomMode;
_animeId = animeId;
_entrySource = initialSource || 'anilist';
_isLocal = isLocal;
_totalEpisodes = animeData.episodes || 1000;
if (animeData.title) {
_animeTitle = animeData.title.romaji || animeData.title.english || "Anime";
}
initElements();
setupEventListeners();
if (_roomMode) {
if(els.playerWrapper) {
els.playerWrapper.style.display = 'block';
els.playerWrapper.classList.add('room-mode');
}
} else {
loadExtensionsList();
}
}
function initElements() {
els.wrapper = document.getElementById('hero-wrapper');
els.playerWrapper = document.getElementById('player-wrapper');
els.playerContainer = els.playerWrapper?.querySelector('.player-container');
els.video = document.getElementById('player');
els.loader = document.getElementById('player-loading');
els.loaderText = document.getElementById('player-loading-text');
// Header controls
els.downloadBtn = document.getElementById('download-btn');
els.downloadModal = document.getElementById('download-modal');
els.dlQualityList = document.getElementById('dl-quality-list');
els.dlAudioList = document.getElementById('dl-audio-list');
els.dlSubsList = document.getElementById('dl-subs-list');
els.dlConfirmBtn = document.getElementById('confirm-dl-btn');
els.dlCancelBtn = document.getElementById('cancel-dl-btn');
els.manualMatchBtn = document.getElementById('manual-match-btn');
els.mpvBtn = document.getElementById('mpv-btn');
els.serverSelect = document.getElementById('server-select');
els.extSelect = document.getElementById('extension-select');
els.subDubToggle = document.getElementById('sd-toggle');
els.epTitle = document.getElementById('player-episode-title');
els.prevBtn = document.getElementById('prev-ep-btn');
els.nextBtn = document.getElementById('next-ep-btn');
// Custom controls
els.playPauseBtn = document.getElementById('play-pause-btn');
els.volumeBtn = document.getElementById('volume-btn');
els.volumeSlider = document.getElementById('volume-slider');
els.timeDisplay = document.getElementById('time-display');
els.settingsBtn = document.getElementById('settings-btn');
els.settingsPanel = document.getElementById('settings-panel');
els.fullscreenBtn = document.getElementById('fullscreen-btn');
els.progressContainer = document.querySelector('.progress-container');
els.progressPlayed = document.querySelector('.progress-played');
els.progressBuffer = document.querySelector('.progress-buffer');
els.progressHandle = document.querySelector('.progress-handle');
els.subtitlesCanvas = document.getElementById('subtitles-canvas');
if (!document.getElementById('skip-overlay-btn')) {
const btn = document.createElement('button');
btn.id = 'skip-overlay-btn';
if(els.playerContainer) els.playerContainer.appendChild(btn);
_skipBtn = btn;
} else {
_skipBtn = document.getElementById('skip-overlay-btn');
}
}
function setupEventListeners() {
if(!_roomMode) {
const closeBtn = document.getElementById('close-player-btn');
if(closeBtn) closeBtn.addEventListener('click', closePlayer);
}
if(els.prevBtn) els.prevBtn.onclick = () => playEpisode(_currentEpisode - 1);
if(els.nextBtn) els.nextBtn.onclick = () => playEpisode(_currentEpisode + 1);
// Skip button
if(_skipBtn) _skipBtn.onclick = () => handleOverlayClick();
// Audio mode toggle
if(els.subDubToggle) els.subDubToggle.addEventListener('click', toggleAudioMode);
// Server/Extension changes
if(els.serverSelect) els.serverSelect.addEventListener('change', () => loadStream());
if(els.extSelect) els.extSelect.addEventListener('change', () => handleExtensionChange(true));
// Manual match
if (els.manualMatchBtn) {
els.manualMatchBtn.addEventListener('click', openMatchModal);
}
// Download
if (els.downloadBtn) {
els.downloadBtn.addEventListener('click', downloadEpisode);
}
if (els.dlConfirmBtn) els.dlConfirmBtn.onclick = executeDownload;
if (els.dlCancelBtn) els.dlCancelBtn.onclick = closeDownloadModal;
const closeDlModalBtn = document.getElementById('close-download-modal');
if (closeDlModalBtn) closeDlModalBtn.onclick = closeDownloadModal;
if (els.downloadModal) {
els.downloadModal.addEventListener('click', (e) => {
if (e.target === els.downloadModal) closeDownloadModal();
});
}
// MPV
if(els.mpvBtn) els.mpvBtn.addEventListener('click', openInMPV);
// Custom controls
setupCustomControls();
// Cursor management
setupCursorManagement();
// Keyboard shortcuts
setupKeyboardShortcuts();
}
function loadVideoFromRoom(videoData) {
console.log('AnimePlayer.loadVideoFromRoom called with:', videoData);
if (!videoData || !videoData.url) {
console.error('Invalid video data provided to loadVideoFromRoom');
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';
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;
}
if(els.volumeSlider) {
els.volumeSlider.oninput = (e) => {
setVolume(e.target.value / 100);
};
}
// 3. Settings (TODOS - Aquí están los subtítulos y audio)
if(els.settingsBtn) {
els.settingsBtn.onclick = (e) => {
e.stopPropagation();
settingsPanelActive = !settingsPanelActive;
if (settingsPanelActive) {
_settingsView = 'main';
buildSettingsPanel();
els.settingsPanel?.classList.add('active');
} else {
els.settingsPanel?.classList.remove('active');
}
};
}
// Close settings when clicking outside (TODOS)
document.onclick = (e) => {
if (settingsPanelActive && els.settingsPanel &&
!els.settingsPanel.contains(e.target) &&
!els.settingsBtn.contains(e.target)) {
settingsPanelActive = false;
els.settingsPanel.classList.remove('active');
}
};
// 4. Fullscreen (TODOS)
if(els.fullscreenBtn) {
els.fullscreenBtn.onclick = toggleFullscreen;
}
// 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;
}
// 6. Video events (TODOS - Necesarios para actualizar la UI localmente)
if(els.video) {
els.video.onplay = onPlay;
els.video.onpause = onPause;
els.video.ontimeupdate = onTimeUpdate;
els.video.onprogress = onProgress;
els.video.onloadedmetadata = onLoadedMetadata;
els.video.onended = onEnded;
els.video.onvolumechange = onVolumeChange;
els.video.onseeked = onSeeked;
}
}
function setupCursorManagement() {
if (!els.playerContainer) return;
const showCursor = () => {
els.playerContainer.classList.add('show-cursor');
clearTimeout(cursorTimeout);
if (!els.video?.paused) {
cursorTimeout = setTimeout(() => {
els.playerContainer.classList.remove('show-cursor');
}, 3000);
}
};
els.playerContainer.addEventListener('mousemove', showCursor);
els.playerContainer.addEventListener('mouseenter', showCursor);
els.video?.addEventListener('pause', () => {
clearTimeout(cursorTimeout);
els.playerContainer.classList.add('show-cursor');
});
els.video?.addEventListener('play', () => {
showCursor();
});
}
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (!els.playerWrapper || els.playerWrapper.style.display === 'none') return;
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':
e.preventDefault();
togglePlayPause();
break;
case 'f':
e.preventDefault();
toggleFullscreen();
break;
case 'm':
e.preventDefault();
toggleMute();
break;
case 'arrowleft':
e.preventDefault();
seekRelative(-10);
break;
case 'arrowright':
e.preventDefault();
seekRelative(10);
break;
case 'j':
e.preventDefault();
seekRelative(-10);
break;
case 'l':
e.preventDefault();
seekRelative(10);
break;
case 'arrowup':
e.preventDefault();
adjustVolume(0.1);
break;
case 'arrowdown':
e.preventDefault();
adjustVolume(-0.1);
break;
case 'n':
e.preventDefault();
if (_currentEpisode < _totalEpisodes) playEpisode(_currentEpisode + 1);
break;
case 'p':
e.preventDefault();
if (_currentEpisode > 1) playEpisode(_currentEpisode - 1);
break;
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
e.preventDefault();
const percent = parseInt(e.key) / 10;
seekToPercent(percent);
break;
}
});
}
// 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 });
}
}
}
function toggleMute() {
if (!els.video) return;
els.video.muted = !els.video.muted;
}
function setVolume(vol) {
if (!els.video) return;
els.video.volume = Math.max(0, Math.min(1, vol));
els.video.muted = vol === 0;
}
function adjustVolume(delta) {
if (!els.video) return;
setVolume(els.video.volume + delta);
if (els.volumeSlider) {
els.volumeSlider.value = els.video.volume * 100;
}
}
function toggleFullscreen() {
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
const elem = els.playerContainer || els.playerWrapper;
if (elem.requestFullscreen) {
elem.requestFullscreen();
} else if (elem.webkitRequestFullscreen) {
elem.webkitRequestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
}
}
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;
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) {
if (!els.progressHandle || !els.progressContainer) return;
const rect = els.progressContainer.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
els.progressHandle.style.left = `${pos * 100}%`;
}
function seekRelative(seconds) {
if (!els.video) return;
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;
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
function onPlay() {
if (els.playPauseBtn) {
els.playPauseBtn.innerHTML = `
<svg viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
`;
}
if (!els.video.duration) return;
const elapsed = Math.floor(els.video.currentTime);
const start = Math.floor(Date.now() / 1000) - elapsed;
const end = start + Math.floor(els.video.duration);
sendRPC({ startTimestamp: start, endTimestamp: end });
_rpcActive = true;
}
function onSeeked() {
if (!els.video || els.video.paused || !els.video.duration) return;
const elapsed = Math.floor(els.video.currentTime);
const start = Math.floor(Date.now() / 1000) - elapsed;
const end = start + Math.floor(els.video.duration);
sendRPC({ startTimestamp: start, endTimestamp: end });
}
function onPause() {
if (els.playPauseBtn) {
els.playPauseBtn.innerHTML = `
<svg viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
`;
}
if (_rpcActive) sendRPC({ paused: true });
}
function onProgress() {
if (!els.video || !els.progressBuffer) return;
if (els.video.buffered.length > 0) {
const bufferedEnd = els.video.buffered.end(els.video.buffered.length - 1);
const percent = (bufferedEnd / els.video.duration) * 100;
els.progressBuffer.style.width = `${percent}%`;
}
}
function onLoadedMetadata() {
if (els.video) {
applyAniSkip(_malId, _currentEpisode);
}
}
function onEnded() {
if (!_roomMode && _currentEpisode < _totalEpisodes) {
playEpisode(_currentEpisode + 1);
}
}
function onVolumeChange() {
if (!els.video || !els.volumeBtn || !els.volumeSlider) return;
const volume = els.video.volume;
const muted = els.video.muted;
els.volumeSlider.value = volume * 100;
let icon;
if (muted || volume === 0) {
icon = '<svg viewBox="0 0 24 24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>';
} else if (volume < 0.5) {
icon = '<svg viewBox="0 0 24 24"><path d="M7 9v6h4l5 5V4l-5 5H7z"/></svg>';
} else {
icon = '<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>';
}
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);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
}
const Icons = {
back: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>`,
check: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
chevron: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"></polyline></svg>`,
quality: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"></path><path d="M14.31 8l5.74 9.94"></path><path d="M9.69 8h11.48"></path><path d="M7.38 12l5.74-9.94"></path><path d="M9.69 16L3.95 6.06"></path><path d="M14.31 16H2.83"></path><path d="M16.62 12l-5.74 9.94"></path></svg>`,
audio: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`,
subs: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`,
speed: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>`
};
function buildSettingsPanel() {
if (!els.settingsPanel) return;
els.settingsPanel.innerHTML = '';
if (_settingsView === 'main') {
buildMainMenu();
} else {
buildSubMenu(_settingsView);
}
}
function buildMainMenu() {
let html = `<div class="settings-content">`;
// 1. Quality
if (hlsInstance && hlsInstance.levels && hlsInstance.levels.length > 1) {
const currentLevel = hlsInstance.currentLevel;
const label = currentLevel === -1 ? 'Auto' : (hlsInstance.levels[currentLevel]?.height + 'p');
html += createMenuItem('quality', 'Quality', label, Icons.quality);
}
// 2. Audio
if (hlsInstance && hlsInstance.audioTracks && hlsInstance.audioTracks.length > 1) {
const currentAudio = hlsInstance.audioTrack;
const track = hlsInstance.audioTracks[currentAudio];
const label = track ? (track.name || track.lang || `Track ${currentAudio + 1}`) : 'Default';
html += createMenuItem('audio', 'Audio', label, Icons.audio);
}
// 3. Subtitles
if (_currentSubtitles && _currentSubtitles.length > 0) {
let label = 'Off';
const activeIndex = getActiveSubtitleIndex();
if (activeIndex !== -1 && _currentSubtitles[activeIndex]) {
label = _currentSubtitles[activeIndex].label || _currentSubtitles[activeIndex].language;
}
html += createMenuItem('subtitle', 'Subtitles', label, Icons.subs);
}
// 4. Playback Speed
if (els.video && (!_roomMode || _isRoomHost)) {
const label = els.video.playbackRate === 1 ? 'Normal' : `${els.video.playbackRate}x`;
html += createMenuItem('speed', 'Playback Speed', label, Icons.speed);
}
html += `</div>`;
els.settingsPanel.innerHTML = html;
// Listeners del menú principal
els.settingsPanel.querySelectorAll('.settings-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
_settingsView = item.dataset.target;
buildSettingsPanel();
});
});
}
function createMenuItem(target, title, value, icon) {
return `
<div class="settings-item settings-item-main" data-target="${target}">
<div class="settings-label-left">
<span class="settings-label-icon">${icon}</span>
<span>${title}</span>
</div>
<div class="settings-value-right">
<span>${value}</span>
${Icons.chevron}
</div>
</div>
`;
}
function buildSubMenu(type) {
let title = '';
let content = '';
if (type === 'quality') {
title = 'Quality';
content = renderQualityOptions();
} else if (type === 'audio') {
title = 'Audio Track';
content = renderAudioOptions();
} else if (type === 'subtitle') {
title = 'Subtitles';
content = renderSubtitleOptions();
} else if (type === 'speed') {
title = 'Playback Speed';
content = renderSpeedOptions();
}
const html = `
<div class="settings-header">
<button class="settings-back-btn">${Icons.back}</button>
<span class="settings-title">${title}</span>
</div>
<div class="settings-content">
${content}
</div>
`;
els.settingsPanel.innerHTML = html;
// Listener para volver atrás
els.settingsPanel.querySelector('.settings-back-btn').addEventListener('click', (e) => {
e.stopPropagation();
_settingsView = 'main';
buildSettingsPanel();
});
// Listeners para opciones CORREGIDO
els.settingsPanel.querySelectorAll('.settings-item-option').forEach(opt => {
opt.addEventListener('click', (e) => {
e.stopPropagation();
const val = opt.dataset.value;
applySetting(type, val);
});
});
}
// Funciones de renderizado de opciones
function renderQualityOptions() {
if (!hlsInstance) return '';
let html = '';
// Auto option
const isAuto = hlsInstance.currentLevel === -1;
html += `<div class="settings-item settings-item-option ${isAuto ? 'selected' : ''}" data-value="-1">
<span>Auto</span>${isAuto ? Icons.check : ''}
</div>`;
// Levels desc
hlsInstance.levels.forEach((level, i) => {
const isSelected = hlsInstance.currentLevel === i;
html += `<div class="settings-item settings-item-option ${isSelected ? 'selected' : ''}" data-value="${i}">
<span>${level.height}p</span>${isSelected ? Icons.check : ''}
</div>`;
});
return html;
}
function renderAudioOptions() {
if (!hlsInstance) return '';
let html = '';
hlsInstance.audioTracks.forEach((track, i) => {
const isSelected = hlsInstance.audioTrack === i;
const label = track.name || track.lang || `Audio ${i + 1}`;
html += `<div class="settings-item settings-item-option ${isSelected ? 'selected' : ''}" data-value="${i}">
<span>${label}</span>${isSelected ? Icons.check : ''}
</div>`;
});
return html;
}
function renderSubtitleOptions() {
let html = '';
const activeIdx = getActiveSubtitleIndex();
// Off
html += `<div class="settings-item settings-item-option ${activeIdx === -1 ? 'selected' : ''}" data-value="-1">
<span>Off</span>${activeIdx === -1 ? Icons.check : ''}
</div>`;
_currentSubtitles.forEach((sub, i) => {
const isSelected = activeIdx === i;
html += `<div class="settings-item settings-item-option ${isSelected ? 'selected' : ''}" data-value="${i}">
<span>${sub.label || sub.language}</span>${isSelected ? Icons.check : ''}
</div>`;
});
return html;
}
function renderSpeedOptions() {
const speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
let html = '';
const currentRate = els.video ? els.video.playbackRate : 1;
speeds.forEach(speed => {
const isSelected = Math.abs(currentRate - speed) < 0.1;
html += `<div class="settings-item settings-item-option ${isSelected ? 'selected' : ''}" data-value="${speed}">
<span>${speed === 1 ? 'Normal' : speed + 'x'}</span>${isSelected ? Icons.check : ''}
</div>`;
});
return html;
}
// Aplicar configuración
function applySetting(type, value) {
if (type === 'quality') {
if (hlsInstance) hlsInstance.currentLevel = parseInt(value);
} else if (type === 'audio') {
if (hlsInstance) hlsInstance.audioTrack = parseInt(value);
} else if (type === 'subtitle') {
const idx = parseInt(value);
_activeSubtitleIndex = idx; // <--- ACTUALIZAMOS EL ESTADO AQUÍ
// 1. Lógica nativa (para mantener compatibilidad interna)
if (els.video && els.video.textTracks) {
Array.from(els.video.textTracks).forEach((track, i) => {
// Si usamos JASSUB, ocultamos la nativa. Si no, mostramos la seleccionada.
track.mode = (subtitleRenderer && idx !== -1) ? 'hidden' : ((i === idx) ? 'showing' : 'hidden');
});
}
// 2. Lógica de JASSUB
if (subtitleRenderer) {
if (idx === -1) {
subtitleRenderer.dispose();
} else {
const sub = _currentSubtitles[idx];
if (sub) {
subtitleRenderer.setTrack(sub.src);
}
}
}
} else if (type === 'speed') {
if (els.video) els.video.playbackRate = parseFloat(value);
}
// Volvemos al menú principal para confirmar visualmente (opcional, estilo YouTube)
_settingsView = 'main';
buildSettingsPanel();
}
function getActiveSubtitleIndex() {
return _activeSubtitleIndex;
}
async function initSubtitleRenderer() {
if (!els.video) return;
// Cleanup previous instance
if (subtitleRenderer) {
try {
subtitleRenderer.dispose();
} catch(e) {
console.warn('Error disposing renderer:', e);
}
subtitleRenderer = null;
}
// Find ASS subtitle
const assSubtitle = _currentSubtitles.find(sub =>
(sub.src && sub.src.endsWith('.ass')) ||
(sub.label && sub.label.toLowerCase().includes('ass'))
);
if (!assSubtitle) {
console.log('No ASS subtitles found in current list');
return;
}
try {
console.log('Initializing JASSUB for:', assSubtitle.label);
// Check if JASSUB global is available
if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') {
// --- CAMBIO AQUÍ: Pasamos els.subtitlesCanvas ---
subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas);
await subtitleRenderer.init(assSubtitle.src);
} else {
console.warn('JASSUB library not loaded.');
}
} catch (e) {
console.error('Subtitle renderer setup error:', e);
subtitleRenderer = null;
}
}
function onTimeUpdate() {
if (!els.video) return;
// Update progress bar
const percent = (els.video.currentTime / els.video.duration) * 100;
if (els.progressPlayed) {
els.progressPlayed.style.width = `${percent}%`;
}
if (els.progressHandle) {
els.progressHandle.style.left = `${percent}%`;
}
// Update time display
if (els.timeDisplay) {
const current = formatTime(els.video.currentTime);
const total = formatTime(els.video.duration);
els.timeDisplay.textContent = `${current} / ${total}`;
}
// Update progress for AniList
if (!_roomMode && !_progressUpdated && els.video.duration) {
const percentage = els.video.currentTime / els.video.duration;
if (percentage >= 0.8) {
updateProgress();
_progressUpdated = true;
}
}
}
// Player lifecycle
async function playEpisode(episodeNumber) {
const targetEp = parseInt(episodeNumber);
if (targetEp < 1 || targetEp > _totalEpisodes) return;
_currentEpisode = targetEp;
_progressUpdated = false;
if(els.epTitle) els.epTitle.innerText = `Episode ${targetEp}`;
if(els.prevBtn) els.prevBtn.disabled = (_currentEpisode <= 1);
if(els.nextBtn) els.nextBtn.disabled = (_currentEpisode >= _totalEpisodes);
if(_skipBtn) {
_skipBtn.classList.remove('visible');
_skipBtn.classList.remove('is-next');
}
const newUrl = new URL(window.location);
newUrl.searchParams.set('episode', targetEp);
window.history.pushState({}, '', newUrl);
if(els.playerWrapper) els.playerWrapper.style.display = 'block';
document.body.classList.add('stop-scrolling');
const trailer = document.querySelector('#trailer-player iframe');
if(trailer) trailer.contentWindow.postMessage('{"event":"command","func":"pauseVideo","args":""}', '*');
_rpcActive = false;
setLoading("Checking availability...");
// Check local availability
let shouldPlayLocal = false;
try {
const check = await fetch(`/api/library/${_animeId}/units`);
const data = await check.json();
const localUnit = data.units ? data.units.find(u => u.number === targetEp) : null;
if (localUnit) shouldPlayLocal = true;
} catch (e) {
console.warn("Availability check failed:", e);
shouldPlayLocal = (els.extSelect.value === 'local');
}
if (els.manualMatchBtn) {
els.manualMatchBtn.style.display = shouldPlayLocal ? 'none' : 'flex';
}
if (shouldPlayLocal) {
let localOption = els.extSelect.querySelector('option[value="local"]');
if (!localOption) {
localOption = document.createElement('option');
localOption.value = 'local';
localOption.innerText = 'Local';
els.extSelect.appendChild(localOption);
}
els.extSelect.value = 'local';
if(els.subDubToggle) els.subDubToggle.style.display = 'none';
if(els.serverSelect) els.serverSelect.style.display = 'none';
loadStream();
} else {
if (els.extSelect.value === 'local') {
const localOption = els.extSelect.querySelector('option[value="local"]');
if (localOption) localOption.remove();
let fallbackSource = (_entrySource !== 'local') ? _entrySource : 'anilist';
if (!els.extSelect.querySelector(`option[value="${fallbackSource}"]`)) {
if (els.extSelect.options.length > 0) {
fallbackSource = els.extSelect.options[0].value;
}
}
els.extSelect.value = fallbackSource;
handleExtensionChange(true);
} else {
if (els.serverSelect.options.length === 0) {
handleExtensionChange(true);
} else {
loadStream();
}
}
}
}
function closePlayer() {
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
if (subtitleRenderer) {
try { subtitleRenderer.dispose(); } catch(e) {}
subtitleRenderer = null;
}
// Stop video
if (els.video) {
els.video.pause();
els.video.removeAttribute('src');
els.video.load();
}
if(els.playerWrapper) els.playerWrapper.style.display = 'none';
document.body.classList.remove('stop-scrolling');
_skipIntervals = [];
_rpcActive = false;
sendRPC({ paused: true });
const newUrl = new URL(window.location);
newUrl.searchParams.delete('episode');
window.history.pushState({}, '', newUrl);
const trailer = document.querySelector('#trailer-player iframe');
if(trailer) {
trailer.contentWindow.postMessage('{"event":"command","func":"playVideo","args":""}', '*');
}
}
async function loadExtensionsList() {
try {
const res = await fetch('/api/extensions/anime');
const data = await res.json();
const extensions = data.extensions || [];
if (_isLocal && !extensions.includes('local')) extensions.push('local');
els.extSelect.innerHTML = '';
extensions.forEach(ext => {
const opt = document.createElement('option');
opt.value = ext;
opt.innerText = ext.charAt(0).toUpperCase() + ext.slice(1);
els.extSelect.appendChild(opt);
});
if (extensions.includes(_entrySource)) {
els.extSelect.value = _entrySource;
} else if (extensions.length > 0) {
els.extSelect.value = extensions[0];
}
if (els.extSelect.value === 'local') {
els.subDubToggle.style.display = 'none';
els.serverSelect.style.display = 'none';
} else if (els.extSelect.value) {
handleExtensionChange(false);
}
} catch (e) { console.error("Error loading extensions:", e); }
}
async function handleExtensionChange(shouldPlay = true) {
const selectedExt = els.extSelect.value;
if (els.manualMatchBtn) {
els.manualMatchBtn.style.display = (selectedExt === 'local') ? 'none' : 'flex';
}
if (selectedExt === 'local') {
els.subDubToggle.style.display = 'none';
els.serverSelect.style.display = 'none';
if (shouldPlay && _currentEpisode > 0) loadStream();
return;
}
_manualExtensionId = null;
setLoading("Loading Extension Settings...");
try {
const res = await fetch(`/api/extensions/${selectedExt}/settings`);
const settings = await res.json();
els.subDubToggle.style.display = settings.supportsDub ? 'flex' : 'none';
setAudioMode('sub');
els.serverSelect.innerHTML = '';
if (settings.episodeServers && settings.episodeServers.length > 0) {
settings.episodeServers.forEach(srv => {
const opt = document.createElement('option');
opt.value = srv;
opt.innerText = srv;
els.serverSelect.appendChild(opt);
});
els.serverSelect.value = settings.episodeServers[0];
els.serverSelect.style.display = 'block';
} else {
els.serverSelect.style.display = 'none';
}
if (shouldPlay && _currentEpisode > 0) {
loadStream();
} else {
if(els.loader) els.loader.style.display = 'none';
}
} catch (error) {
console.error("Failed to load settings:", error);
setLoading("Failed to load extension settings.");
}
}
function toggleAudioMode() {
_audioMode = _audioMode === 'sub' ? 'dub' : 'sub';
setAudioMode(_audioMode);
loadStream();
}
function setAudioMode(mode) {
_audioMode = mode;
els.subDubToggle.setAttribute('data-state', mode);
document.getElementById('opt-sub').classList.toggle('active', mode === 'sub');
document.getElementById('opt-dub').classList.toggle('active', mode === 'dub');
}
function setLoading(msg) {
if(els.loaderText) els.loaderText.innerText = msg;
if(els.loader) els.loader.style.display = 'flex';
}
async function getLocalEntryId() {
if (_localEntryId) return _localEntryId;
try {
const res = await fetch(`/api/library/anime/${_animeId}`);
if (!res.ok) return null;
const data = await res.json();
_localEntryId = data.id;
return _localEntryId;
} catch (e) {
console.error("Error fetching local ID:", e);
return null;
}
}
async function loadStream() {
if (!_currentEpisode) return;
_progressUpdated = false;
setLoading("Fetching Stream...");
_rawVideoData = null;
_currentSubtitles = [];
// Cleanup before fetch to prevent ghost events
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
if (subtitleRenderer) {
try { subtitleRenderer.dispose(); } catch(e) {}
subtitleRenderer = null;
}
const currentExt = els.extSelect.value;
if (currentExt !== 'local') {
_isLocal = false;
_rawVideoData = null;
}
if (currentExt === 'local') {
try {
const localId = await getLocalEntryId();
const check = await fetch(`/api/library/${_animeId}/units`);
const data = await check.json();
const targetUnit = data.units ? data.units.find(u => u.number === parseInt(_currentEpisode)) : null;
if (!targetUnit) {
console.log(`Episode ${_currentEpisode} not found locally.`);
const localOption = els.extSelect.querySelector('option[value="local"]');
if (localOption) localOption.remove();
const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource;
if (els.extSelect.querySelector(`option[value="${fallbackSource}"]`)) {
els.extSelect.value = fallbackSource;
} else if (els.extSelect.options.length > 0) {
els.extSelect.selectedIndex = 0;
}
handleExtensionChange(true);
return;
}
const ext = targetUnit.format || targetUnit.name.split('.').pop().toLowerCase();
if (![''].includes(ext)) {
setLoading(`Local files are not supported on the web player yet. Use MPV.`);
_rawVideoData = {
url: targetUnit.path,
headers: {}
};
if (els.mpvBtn) els.mpvBtn.style.display = 'flex';
return;
}
const localUrl = `/api/library/stream/${targetUnit.id}`;
_rawVideoData = {
url: localUrl,
headers: {}
};
_currentSubtitles = [];
initVideoPlayer(localUrl, 'mp4');
} catch(e) {
console.error(e);
setLoading("Local Error: " + e.message);
}
return;
}
const server = els.serverSelect.value || "";
const extParam = `&ext=${currentExt}`;
const realSource = _entrySource === 'local' ? 'anilist' : _entrySource;
let url = `/api/watch/stream?animeId=${_animeId}` +
`&episode=${_currentEpisode}` +
`&server=${encodeURIComponent(server)}` +
`&category=${_audioMode}` +
`${extParam}` +
`&source=${realSource}`;
if (_manualExtensionId) {
url += `&extensionAnimeId=${encodeURIComponent(_manualExtensionId)}`;
}
try {
const res = await fetch(url);
const data = await res.json();
if (data.error || !data.videoSources?.length) {
setLoading(data.error || "No sources found.");
return;
}
const source = data.videoSources.find(s => s.type === 'm3u8') || data.videoSources[0];
const headers = data.headers || {};
_rawVideoData = {
url: source.url,
headers: headers
};
let proxyUrl = `/api/proxy?url=${encodeURIComponent(source.url)}`;
if (headers['Referer'] && headers['Referer'] !== "null") {
proxyUrl += `&referer=${encodeURIComponent(headers['Referer'])}`;
}
if (headers['User-Agent']) {
proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`;
}
const subtitles = (source.subtitles || []).map(sub => ({
label: sub.language,
srclang: sub.language.toLowerCase().slice(0, 2),
src: `/api/proxy?url=${encodeURIComponent(sub.url)}`
}));
_currentSubtitles = (source.subtitles || []).map(sub => ({
label: sub.language,
srclang: sub.id,
src: sub.url
}));
initVideoPlayer(proxyUrl, source.type, subtitles);
} catch (err) {
setLoading("Stream Error: " + err.message);
console.error(err);
}
}
function initVideoPlayer(url, type, subtitles = []) {
console.log('initVideoPlayer called:', { url, type, subtitles });
// 1. CLEANUP FIRST
if (subtitleRenderer) {
try {
subtitleRenderer.dispose();
} catch(e) {
console.warn('Renderer dispose error (clean):', e);
}
subtitleRenderer = null;
}
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
const container = document.querySelector('.video-frame');
if (!container) {
console.error('Video frame container not found!');
return;
}
// 2. Remove OLD Elements
const oldVideo = container.querySelector('video');
const oldCanvas = container.querySelector('#subtitles-canvas');
if (oldVideo) {
oldVideo.removeAttribute('src');
oldVideo.load();
oldVideo.remove();
}
if (oldCanvas) {
oldCanvas.remove();
}
// 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;
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();
// 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,
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 && !_roomMode) els.downloadBtn.style.display = 'flex';
// --- FIX: Inicializar el renderizador de subtítulos para HLS ---
if (els.video.readyState >= 1) {
initSubtitleRenderer();
} else {
els.video.addEventListener('loadedmetadata', () => {
initSubtitleRenderer();
}, { once: true });
}
// -------------------------------------------------------------
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();
els.video.addEventListener('loadedmetadata', () => {
console.log('Video metadata loaded');
initSubtitleRenderer();
}, { once: true });
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';
}
}
function attachSubtitles(subtitles) {
if (!els.video) return;
_activeSubtitleIndex = -1;
Array.from(els.video.querySelectorAll('track')).forEach(t => t.remove());
if (subtitles.length === 0) return;
subtitles.forEach((sub, i) => {
const track = document.createElement('track');
track.kind = 'subtitles';
track.label = sub.label;
track.srclang = sub.srclang;
track.src = sub.src;
track.default = i === 0;
els.video.appendChild(track);
});
setTimeout(() => {
if (els.video.textTracks && els.video.textTracks.length > 0) {
_activeSubtitleIndex = 0;
if (!subtitleRenderer) {
els.video.textTracks[0].mode = 'showing';
} else {
els.video.textTracks[0].mode = 'hidden';
}
}
}, 100);
}
// AniSkip integration
async function applyAniSkip(malId, episodeNumber) {
if (!malId || !els.video) return;
const duration = await waitForDuration(els.video);
try {
const url = `https://api.aniskip.com/v2/skip-times/${malId}/${episodeNumber}` +
`?types[]=op&types[]=ed&episodeLength=${Math.floor(duration)}`;
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
if (!data.found) return;
_skipIntervals = data.results.map(item => ({
startTime: item.interval.startTime,
endTime: item.interval.endTime,
type: item.skipType
}));
renderSkipMarkers(_skipIntervals);
monitorSkipButton(_skipIntervals);
} catch (e) {
console.error('AniSkip Error:', e);
}
}
function waitForDuration(video) {
return new Promise(resolve => {
if (video.duration && video.duration > 0) return resolve(video.duration);
const check = () => {
if (video.duration && video.duration > 0) {
video.removeEventListener('loadedmetadata', check);
resolve(video.duration);
}
};
video.addEventListener('loadedmetadata', check);
});
}
function renderSkipMarkers(intervals) {
if (!els.progressContainer || !els.video.duration) return;
els.progressContainer.querySelectorAll('.skip-range, .skip-cut').forEach(e => e.remove());
const duration = els.video.duration;
intervals.forEach(skip => {
const startPct = (skip.startTime / duration) * 100;
const endPct = (skip.endTime / duration) * 100;
const range = document.createElement('div');
range.className = `skip-range ${skip.type}`; // 'op' o 'ed'
range.style.left = `${startPct}%`;
range.style.width = `${endPct - startPct}%`;
els.progressContainer.appendChild(range);
createCut(startPct);
createCut(endPct);
});
}
function createCut(percent) {
if (percent < 0.5 || percent > 99.5) return;
const cut = document.createElement('div');
cut.className = 'skip-cut';
cut.style.left = `${percent}%`;
els.progressContainer.appendChild(cut);
}
function monitorSkipButton(intervals) {
if (!_skipBtn || !els.video) return;
const checkTime = () => {
const ct = els.video.currentTime;
const duration = els.video.duration;
const activeInterval = intervals.find(i => ct >= i.startTime && ct <= i.endTime);
if (activeInterval) {
if (activeInterval.type === 'op') {
showSkipButton('Skip Intro', activeInterval.endTime, false);
return;
} else if (activeInterval.type === 'ed') {
if (_currentEpisode < _totalEpisodes) {
showSkipButton('Next Episode', null, true);
} else {
showSkipButton('Skip Ending', activeInterval.endTime, false);
}
return;
}
}
if (_currentEpisode < _totalEpisodes && (duration - ct) < 90 && (duration - ct) > 0) {
if (!activeInterval) {
showSkipButton('Next Episode', null, true);
return;
}
}
_skipBtn.classList.remove('visible');
};
els.video.addEventListener('timeupdate', checkTime);
}
function showSkipButton(text, seekTime, isNextAction) {
if (!_skipBtn) return;
_skipBtn.innerHTML = `${text} <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M13 17l5-5-5-5M6 17l5-5-5-5"/></svg>`;
if (isNextAction) {
_skipBtn.classList.add('is-next');
_skipBtn.dataset.seekTo = '';
} else {
_skipBtn.classList.remove('is-next');
_skipBtn.dataset.seekTo = seekTime;
}
_skipBtn.classList.add('visible');
}
function handleOverlayClick() {
if (!_skipBtn) return;
if (_skipBtn.classList.contains('is-next')) {
playEpisode(_currentEpisode + 1);
} else if (_skipBtn.dataset.seekTo) {
els.video.currentTime = parseFloat(_skipBtn.dataset.seekTo);
}
_skipBtn.classList.remove('visible');
}
// Download functionality
async function downloadEpisode() {
if (!_rawVideoData || !_rawVideoData.url) {
alert("Stream not loaded yet.");
return;
}
const isInFullscreen = document.fullscreenElement || document.webkitFullscreenElement;
if (isInFullscreen) {
try {
if (document.exitFullscreen) {
await document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
await document.webkitExitFullscreen();
}
await new Promise(resolve => setTimeout(resolve, 100));
} catch (err) {
console.warn("Error exiting fullscreen:", err);
}
}
const isM3U8 = hlsInstance && hlsInstance.levels && hlsInstance.levels.length > 0;
const hasMultipleAudio = hlsInstance && hlsInstance.audioTracks && hlsInstance.audioTracks.length > 1;
const hasSubs = _currentSubtitles && _currentSubtitles.length > 0;
if (isM3U8 || hasMultipleAudio || hasSubs) {
await new Promise(resolve => requestAnimationFrame(resolve));
openDownloadModal();
} else {
executeDownload(null, true);
}
}
function openDownloadModal() {
if(!els.downloadModal) return;
els.dlQualityList.innerHTML = '';
els.dlAudioList.innerHTML = '';
els.dlSubsList.innerHTML = '';
let showQuality = false;
let showAudio = false;
let showSubs = false;
// Quality options
if (hlsInstance && hlsInstance.levels && hlsInstance.levels.length > 0) {
showQuality = true;
const levels = hlsInstance.levels.map((l, index) => ({...l, originalIndex: index}))
.sort((a, b) => b.height - a.height);
levels.forEach((level, i) => {
const isSelected = i === 0;
const div = document.createElement('div');
div.className = 'dl-item';
div.innerHTML = `
<input type="radio" name="dl-quality" value="${level.originalIndex}" ${isSelected ? 'checked' : ''}>
<span>${level.height}p</span>
<span class="tag-info">${(level.bitrate / 1000000).toFixed(1)} Mbps</span>
`;
div.onclick = (e) => {
if(e.target.tagName !== 'INPUT') div.querySelector('input').checked = true;
};
els.dlQualityList.appendChild(div);
});
}
document.getElementById('dl-quality-section').style.display = showQuality ? 'block' : 'none';
// Audio tracks
if (hlsInstance && hlsInstance.audioTracks && hlsInstance.audioTracks.length > 0) {
showAudio = true;
hlsInstance.audioTracks.forEach((track, index) => {
const div = document.createElement('div');
div.className = 'dl-item';
div.innerHTML = `
<input type="checkbox" name="dl-audio" value="${index}" checked>
<span>${track.name || track.lang || `Audio ${index+1}`}</span>
<span class="tag-info">${track.lang || 'unk'}</span>
`;
div.onclick = (e) => {
if(e.target.tagName !== 'INPUT') {
const cb = div.querySelector('input');
cb.checked = !cb.checked;
}
};
els.dlAudioList.appendChild(div);
});
}
document.getElementById('dl-audio-section').style.display = showAudio ? 'block' : 'none';
// Subtitles
if (_currentSubtitles && _currentSubtitles.length > 0) {
showSubs = true;
_currentSubtitles.forEach((sub, index) => {
const div = document.createElement('div');
div.className = 'dl-item';
div.innerHTML = `
<input type="checkbox" name="dl-subs" value="${index}" checked>
<span>${sub.label || sub.language || 'Unknown'}</span>
`;
div.onclick = (e) => {
if(e.target.tagName !== 'INPUT') {
const cb = div.querySelector('input');
cb.checked = !cb.checked;
}
};
els.dlSubsList.appendChild(div);
});
}
document.getElementById('dl-subs-section').style.display = showSubs ? 'block' : 'none';
els.downloadModal.style.display = 'flex';
els.downloadModal.offsetHeight;
els.downloadModal.classList.add('show');
}
function closeDownloadModal() {
if (els.downloadModal) {
els.downloadModal.classList.remove('show');
setTimeout(() => {
if(!els.downloadModal.classList.contains('show')) {
els.downloadModal.style.display = 'none';
}
}, 300);
}
}
async function executeDownload(e, skipModal = false) {
closeDownloadModal();
const btn = els.downloadBtn;
if (!btn) return;
const originalBtnContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<div class="spinner" style="width:18px; height:18px; border-width:2px;"></div>`;
let body = {
anilist_id: parseInt(_animeId),
episode_number: parseInt(_currentEpisode),
stream_url: _rawVideoData.url,
headers: _rawVideoData.headers || {},
chapters: _skipIntervals.map(i => ({
title: i.type === 'op' ? 'Opening' : 'Ending',
start_time: i.startTime,
end_time: i.endTime
})),
subtitles: []
};
if (skipModal) {
if (_currentSubtitles) {
body.subtitles = _currentSubtitles.map(sub => ({
language: sub.label || 'Unknown',
url: sub.src
}));
}
} else {
const selectedSubs = Array.from(els.dlSubsList.querySelectorAll('input:checked'));
body.subtitles = selectedSubs.map(cb => {
const i = parseInt(cb.value);
return {
language: _currentSubtitles[i].label || 'Unknown',
url: _currentSubtitles[i].src
};
});
const isQualityVisible = document.getElementById('dl-quality-section').style.display !== 'none';
if (isQualityVisible && hlsInstance && hlsInstance.levels) {
body.is_master = true;
const qualityInput = document.querySelector('input[name="dl-quality"]:checked');
const qualityIndex = qualityInput ? parseInt(qualityInput.value) : 0;
const level = hlsInstance.levels[qualityIndex];
if (level) {
body.variant = {
resolution: level.width ? `${level.width}x${level.height}` : '1920x1080',
bandwidth: level.bitrate,
codecs: level.attrs ? level.attrs.CODECS : '',
playlist_url: level.url
};
}
const audioInputs = document.querySelectorAll('input[name="dl-audio"]:checked');
if (audioInputs.length > 0 && hlsInstance.audioTracks) {
body.audio = Array.from(audioInputs).map(input => {
const i = parseInt(input.value);
const track = hlsInstance.audioTracks[i];
return {
group: track.groupId || 'audio',
language: track.lang || 'unk',
name: track.name || `Audio ${i}`,
playlist_url: track.url
};
});
}
}
}
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/library/download/anime', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify(body)
});
const data = await res.json();
if (res.status === 200) {
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2.5"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
} else if (res.status === 409) {
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2.5"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`;
} else {
console.error("Download Error:", data);
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
}
} catch (err) {
console.error("Request failed:", err);
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
} finally {
setTimeout(() => {
if (btn) {
btn.disabled = false;
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`;
}
}, 3000);
}
}
// MPV functionality
async function openInMPV() {
if (!_rawVideoData) {
alert("No video loaded yet.");
return;
}
const token = localStorage.getItem('token');
if (!token) {
alert("You need to be logged in.");
return;
}
const body = {
title: `${_animeTitle} - Episode ${_currentEpisode}`,
video: _rawVideoData,
subtitles: _currentSubtitles,
chapters: _skipIntervals,
animeId: _animeId,
episode: _currentEpisode,
entrySource: _entrySource,
token: token
};
try {
const res = await fetch('/api/watch/mpv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res.ok) {
console.log("MPV Request Sent");
closePlayer();
} else {
console.error("MPV Request Failed");
}
} catch (e) {
console.error("MPV Error:", e);
}
}
// Match Modal
function openMatchModal() {
const currentExt = els.extSelect.value;
if (!currentExt || currentExt === 'local') return;
if (typeof MatchModal !== 'undefined') {
MatchModal.open({
provider: currentExt,
initialQuery: _animeTitle,
onSearch: async (query, prov) => {
const res = await fetch(`/api/search/${prov}?q=${encodeURIComponent(query)}`);
const data = await res.json();
return data.results || [];
},
onSelect: (item) => {
_manualExtensionId = item.id;
loadStream();
}
});
}
}
// 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: detailsText,
state: stateText,
mode: "watching",
startTimestamp,
endTimestamp,
paused
})
}).catch(e => console.warn("RPC Error:", e));
}
// Progress update
async function updateProgress() {
const token = localStorage.getItem('token');
if (!token) return;
try {
await fetch('/api/list/entry', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
entry_id: _animeId,
source: _entrySource,
entry_type: "ANIME",
status: 'CURRENT',
progress: _currentEpisode
})
});
} catch (e) {
console.error("Progress update failed", e);
}
}
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,
loadVideoFromRoom,
getVideoElement: () => els.video,
setRoomHost,
setWebSocket
};
})();
window.AnimePlayer = AnimePlayer;