2024 lines
74 KiB
JavaScript
2024 lines
74 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 && !hasControlPermission()) {
|
|
showPermissionToast('You need playback control permission');
|
|
return;
|
|
}
|
|
|
|
if (!els.video) return;
|
|
|
|
if (els.video.paused) {
|
|
els.video.play().catch(() => {});
|
|
|
|
if (_roomMode && (_isRoomHost || hasControlPermission())) {
|
|
sendRoomEvent('play', { currentTime: els.video.currentTime });
|
|
}
|
|
} else {
|
|
els.video.pause();
|
|
|
|
if (_roomMode && (_isRoomHost || hasControlPermission())) {
|
|
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 && !hasControlPermission()) return;
|
|
|
|
const rect = els.progressContainer.getBoundingClientRect();
|
|
const pos = (e.clientX - rect.left) / rect.width;
|
|
const newTime = pos * els.video.duration;
|
|
|
|
els.video.currentTime = newTime;
|
|
|
|
if (_roomMode && (_isRoomHost || hasControlPermission())) {
|
|
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 && !hasControlPermission()) {
|
|
showPermissionToast('You need playback control permission');
|
|
return;
|
|
}
|
|
|
|
const newTime = Math.max(0, Math.min(els.video.duration, els.video.currentTime + seconds));
|
|
els.video.currentTime = newTime;
|
|
|
|
if (_roomMode && (_isRoomHost || hasControlPermission())) {
|
|
sendRoomEvent('seek', { currentTime: newTime });
|
|
}
|
|
}
|
|
|
|
function seekToPercent(percent) {
|
|
if (!els.video) return;
|
|
if (_roomMode && !_isRoomHost && !hasControlPermission()) {
|
|
showPermissionToast('You need playback control permission');
|
|
return;
|
|
}
|
|
const newTime = els.video.duration * percent;
|
|
els.video.currentTime = newTime;
|
|
|
|
if (_roomMode && (_isRoomHost || hasControlPermission())) {
|
|
sendRoomEvent('seek', { currentTime: newTime });
|
|
}
|
|
}
|
|
|
|
function hasControlPermission() {
|
|
return window.__userPermissions?.canControl || false;
|
|
}
|
|
|
|
function showPermissionToast(message) {
|
|
const toast = document.createElement('div');
|
|
toast.className = 'permission-toast';
|
|
toast.textContent = message;
|
|
toast.style.cssText = `
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
background: rgba(239, 68, 68, 0.95);
|
|
color: white;
|
|
padding: 16px 24px;
|
|
border-radius: 10px;
|
|
font-weight: 600;
|
|
z-index: 10000;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
animation: fadeIn 0.3s ease;
|
|
`;
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.style.animation = 'fadeOut 0.3s ease';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 2500);
|
|
}
|
|
|
|
// Video event handlers
|
|
function onPlay() {
|
|
if (els.playPauseBtn) {
|
|
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 || !_roomWebSocket) return;
|
|
if (!_isRoomHost && !hasControlPermission()) 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;
|
|
|
|
if (els.video && els.video.textTracks) {
|
|
Array.from(els.video.textTracks).forEach((track, i) => {
|
|
track.mode = 'hidden';
|
|
});
|
|
}
|
|
|
|
initSubtitleRenderer();
|
|
} else if (type === 'speed') {
|
|
if (els.video) els.video.playbackRate = parseFloat(value);
|
|
}
|
|
|
|
_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;
|
|
}
|
|
|
|
const activeIdx = getActiveSubtitleIndex();
|
|
if (activeIdx === -1) return;
|
|
|
|
const currentSub = _currentSubtitles[activeIdx];
|
|
if (!currentSub) return;
|
|
|
|
const src = currentSub.src.toLowerCase();
|
|
const label = (currentSub.label || '').toLowerCase();
|
|
|
|
// CASO 1: ASS (Usa JASSUB)
|
|
if (src.endsWith('.ass') || label.includes('ass')) {
|
|
try {
|
|
console.log('Initializing JASSUB for:', currentSub.label);
|
|
if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') {
|
|
subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas);
|
|
await subtitleRenderer.init(currentSub.src);
|
|
}
|
|
} catch (e) {
|
|
console.error('JASSUB setup error:', e);
|
|
}
|
|
}
|
|
// CASO 2: SRT (Usa SimpleSubtitleRenderer)
|
|
else if (src.endsWith('.srt') || label.includes('srt')) {
|
|
try {
|
|
console.log('Initializing Simple Renderer for:', currentSub.label);
|
|
if (window.SimpleSubtitleRenderer) {
|
|
subtitleRenderer = new SimpleSubtitleRenderer(els.video, els.subtitlesCanvas);
|
|
await subtitleRenderer.loadSubtitles(currentSub.src);
|
|
}
|
|
} catch (e) {
|
|
console.error('Simple Renderer setup error:', e);
|
|
}
|
|
}
|
|
else {
|
|
console.log('Using native browser rendering for VTT');
|
|
}
|
|
}
|
|
|
|
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...");
|
|
|
|
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 && els.extSelect.value === 'local') {
|
|
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 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;
|
|
}
|
|
|
|
// En la función loadStream(), cuando detectas local:
|
|
|
|
if (currentExt === 'local') {
|
|
try {
|
|
setLoading("Fetching Local Unit Data...");
|
|
|
|
// 1. Obtener unidades
|
|
const unitsRes = await fetch(`/api/library/${_animeId}/units`);
|
|
if (!unitsRes.ok) throw new Error("Could not fetch local units");
|
|
|
|
const unitsData = await unitsRes.json();
|
|
_localEntryId = unitsData.entry_id;
|
|
|
|
const targetUnit = unitsData.units ?
|
|
unitsData.units.find(u => u.number === parseInt(_currentEpisode)) : null;
|
|
|
|
if (!targetUnit) {
|
|
throw new Error(`Episode ${_currentEpisode} not found in local library`);
|
|
}
|
|
|
|
setLoading("Initializing HLS Stream...");
|
|
|
|
// 2. Obtener Manifest
|
|
const manifestRes = await fetch(
|
|
`/api/library/stream/anime/${unitsData.entry_id}/${targetUnit.number}/manifest`
|
|
);
|
|
|
|
if (!manifestRes.ok) throw new Error("Failed to generate stream manifest");
|
|
|
|
const manifestData = await manifestRes.json();
|
|
|
|
// DEBUG: Verificar que llega aquí
|
|
console.log("Manifest Loaded:", manifestData);
|
|
|
|
// 3. Chapters
|
|
if (manifestData.chapters && manifestData.chapters.length > 0) {
|
|
_skipIntervals = manifestData.chapters.map(c => ({
|
|
startTime: c.start,
|
|
endTime: c.end,
|
|
type: (c.title || '').toLowerCase().includes('op') ? 'op' :
|
|
(c.title || '').toLowerCase().includes('ed') ? 'ed' : 'chapter'
|
|
}));
|
|
renderSkipMarkers(_skipIntervals);
|
|
monitorSkipButton(_skipIntervals);
|
|
}
|
|
|
|
// 4. CORRECCIÓN DE SUBTÍTULOS
|
|
const rawSubs = manifestData.subtitles || [];
|
|
console.log("Raw Subtitles from JSON:", rawSubs);
|
|
|
|
const subs = rawSubs.map((s, index) => {
|
|
// Limpieza segura del código de idioma (ej. "English" -> "en")
|
|
let langCode = 'en';
|
|
if (s.language && typeof s.language === 'string' && s.language.length >= 2) {
|
|
langCode = s.language.substring(0, 2).toLowerCase();
|
|
}
|
|
|
|
// Título legible (Prioridad: Title > Language > Track #)
|
|
const label = s.title || s.language || `Track ${index + 1}`;
|
|
|
|
return {
|
|
label: label,
|
|
srclang: langCode,
|
|
src: s.url // Usamos la URL directa del JSON local
|
|
};
|
|
});
|
|
|
|
// ACTUALIZACIÓN CRÍTICA DEL ESTADO GLOBAL
|
|
_currentSubtitles = subs;
|
|
console.log("Processed Subtitles:", _currentSubtitles);
|
|
|
|
_rawVideoData = {
|
|
url: manifestData.masterPlaylist,
|
|
headers: {}
|
|
};
|
|
|
|
// Inicializar video pasando explícitamente los subs procesados
|
|
initVideoPlayer(manifestData.masterPlaylist, 'm3u8', subs);
|
|
|
|
} catch(e) {
|
|
console.error("Local HLS Error:", e);
|
|
setLoading("Local Error: " + e.message);
|
|
|
|
// Fallback logic...
|
|
const localOption = els.extSelect.querySelector('option[value="local"]');
|
|
if (localOption) localOption.remove();
|
|
const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource;
|
|
els.extSelect.value = fallbackSource;
|
|
handleExtensionChange(true);
|
|
}
|
|
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());
|
|
|
|
intervals.forEach(skip => {
|
|
const startPct = (skip.startTime / els.video.duration) * 100;
|
|
const endPct = (skip.endTime / els.video.duration) * 100;
|
|
|
|
const range = document.createElement('div');
|
|
range.className = `skip-range ${skip.type}`;
|
|
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>`;
|
|
|
|
// --- CAMBIO AQUÍ: Calcular duración del video actual ---
|
|
let totalDuration = 0;
|
|
if (els.video && isFinite(els.video.duration) && els.video.duration > 0) {
|
|
totalDuration = Math.floor(els.video.duration);
|
|
}
|
|
// -------------------------------------------------------
|
|
|
|
let body = {
|
|
anilist_id: parseInt(_animeId),
|
|
episode_number: parseInt(_currentEpisode),
|
|
stream_url: _rawVideoData.url,
|
|
headers: _rawVideoData.headers || {},
|
|
duration: totalDuration, // <--- ENVIAMOS LA DURACIÓN
|
|
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
|
|
};
|
|
|
|
try {
|
|
const res = await fetch('/api/watch/mpv', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
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; |