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 plyrInstance = null; let hlsInstance = null; const els = { wrapper: null, playerWrapper: null, video: null, loader: null, loaderText: null, serverSelect: null, extSelect: null, subDubToggle: null, epTitle: null, prevBtn: null, nextBtn: null, mpvBtn: null }; function init(animeId, initialSource, isLocal, animeData) { _animeId = animeId; _entrySource = initialSource || 'anilist'; _isLocal = isLocal; _malId = animeData.idMal || null; _totalEpisodes = animeData.episodes || 1000; if (animeData.title) { _animeTitle = animeData.title.romaji || animeData.title.english || animeData.title.native || animeData.title || "Anime"; } _skipIntervals = []; _localEntryId = null; els.wrapper = document.getElementById('hero-wrapper'); els.playerWrapper = document.getElementById('player-wrapper'); els.video = document.getElementById('player'); els.loader = document.getElementById('player-loading'); els.loaderText = document.getElementById('player-loading-text'); els.mpvBtn = document.getElementById('mpv-btn'); if (els.mpvBtn) els.mpvBtn.addEventListener('click', openInMPV); 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'); const closeBtn = document.getElementById('close-player-btn'); if(closeBtn) closeBtn.addEventListener('click', closePlayer); if(els.prevBtn) els.prevBtn.addEventListener('click', () => playEpisode(_currentEpisode - 1)); if(els.nextBtn) els.nextBtn.addEventListener('click', () => playEpisode(_currentEpisode + 1)); if (!document.getElementById('skip-overlay-btn')) { const btn = document.createElement('button'); btn.id = 'skip-overlay-btn'; const container = document.querySelector('.player-container'); if(container) container.appendChild(btn); _skipBtn = btn; } else { _skipBtn = document.getElementById('skip-overlay-btn'); } if(_skipBtn) _skipBtn.onclick = () => handleOverlayClick(); if(els.subDubToggle) els.subDubToggle.addEventListener('click', toggleAudioMode); if(els.serverSelect) els.serverSelect.addEventListener('change', () => loadStream()); if(els.extSelect) els.extSelect.addEventListener('change', () => handleExtensionChange(true)); loadExtensionsList(); } 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: localStorage.getItem('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); } finally { if(els.mpvBtn) { els.mpvBtn.innerHTML = originalContent; els.mpvBtn.disabled = false; } } } function sendRPC({ startTimestamp, endTimestamp, paused = false } = {}) { fetch("/api/rpc", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ details: _animeTitle, state: `Episode ${_currentEpisode}`, mode: "watching", startTimestamp, endTimestamp, paused }) }).catch(e => console.warn("RPC Error:", e)); } 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'); } 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; } } function playEpisode(episodeNumber) { const targetEp = parseInt(episodeNumber); if (targetEp < 1 || targetEp > _totalEpisodes) return; _currentEpisode = targetEp; 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; if (els.extSelect.value === 'local') { loadStream(); return; } if (els.serverSelect.options.length === 0) { handleExtensionChange(true); } else { loadStream(); } } function closePlayer() { if (plyrInstance) plyrInstance.destroy(); if (hlsInstance) hlsInstance.destroy(); plyrInstance = null; hlsInstance = null; if(els.playerWrapper) els.playerWrapper.style.display = 'none'; document.body.classList.remove('stop-scrolling'); document.body.classList.remove('watch-mode'); _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 (selectedExt === 'local') { els.subDubToggle.style.display = 'none'; els.serverSelect.style.display = 'none'; if (shouldPlay && _currentEpisode > 0) loadStream(); return; } 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 = []; if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } const currentExt = els.extSelect.value; if (currentExt === 'local') { try { const localId = await getLocalEntryId(); if (!localId) { setLoading("Local entry not found in library."); return; } const localUrl = `/api/library/stream/anime/${localId}/${_currentEpisode}`; _rawVideoData = { url: window.location.origin + localUrl, headers: {} }; _currentSubtitles = []; initVideoPlayer(localUrl, 'mp4'); } catch(e) { setLoading("Local Error: " + e.message); } return; } const server = els.serverSelect.value || ""; const sourceParam = `&source=${_entrySource}`; const extParam = `&ext=${currentExt}`; const url = `/api/watch/stream?animeId=${_animeId}&episode=${_currentEpisode}&server=${encodeURIComponent(server)}&category=${_audioMode}${extParam}${sourceParam}`; 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.id, 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); } } function initVideoPlayer(url, type, subtitles = []) { if (plyrInstance) { plyrInstance.destroy(); plyrInstance = null; } if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } const container = document.querySelector('.video-frame'); container.innerHTML = ''; const newVideo = document.createElement('video'); newVideo.id = 'player'; newVideo.controls = true; newVideo.crossOrigin = 'anonymous'; newVideo.playsInline = true; container.appendChild(newVideo); els.video = newVideo; els.video.addEventListener("play", () => { 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; }); els.video.addEventListener("pause", () => { if (_rpcActive) sendRPC({ paused: true }); }); els.video.addEventListener("seeked", () => { if (els.video.paused || !_rpcActive) 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 }); }); if (Hls.isSupported() && (type === 'm3u8' || url.includes('.m3u8'))) { hlsInstance = new Hls(); hlsInstance.attachMedia(els.video); hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => { hlsInstance.loadSource(url); }); hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { initPlyr(); plyrInstance.on('ready', () => { createAudioSelector(hlsInstance); createQualitySelector(hlsInstance); }); els.video.play().catch(() => {}); }); } else { els.video.src = url; attachSubtitles(subtitles); initPlyr(); els.video.play().catch(e => console.log("Autoplay blocked", e)); els.video.addEventListener('loadedmetadata', () => { applyAniSkip(_malId, _currentEpisode); }, { once: true }); if(els.loader) els.loader.style.display = 'none'; } } function attachSubtitles(subtitles) { 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); }); } const ICONS = { settings: ``, audio: `` }; function createQualitySelector(hls) { const levels = hls.levels; if (!levels || !levels.length) return; const plyrEl = els.video.closest('.plyr'); const controls = plyrEl.querySelector('.plyr__controls'); if (!controls || controls.querySelector('#quality-control-wrapper')) return; // 1. Crear el Wrapper const wrapper = document.createElement('div'); wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper'; wrapper.id = 'quality-control-wrapper'; // 2. Crear el Botón Visual (Fake) const btn = document.createElement('div'); btn.className = 'plyr__custom-control-btn'; // Icono + Texto Inicial btn.innerHTML = `${ICONS.settings} Auto`; // 3. Crear el Select Real (Invisible) const select = document.createElement('select'); select.className = 'plyr__sr-only-select'; // Clase auxiliar si quieres depurar, sino usa el CSS wrapper // Opción AUTO const autoOpt = document.createElement('option'); autoOpt.value = -1; autoOpt.textContent = 'Auto'; select.appendChild(autoOpt); // Opciones de Niveles levels.forEach((l, i) => { const opt = document.createElement('option'); opt.value = i; opt.textContent = `${l.height}p`; // Texto que sale en el dropdown nativo select.appendChild(opt); }); // Sincronizar estado inicial select.value = hls.currentLevel; updateLabel(select.value); // Evento Change select.onchange = () => { hls.currentLevel = Number(select.value); updateLabel(select.value); }; function updateLabel(val) { const index = Number(val); let text = 'Auto'; if (index !== -1 && levels[index]) { // Solo el número + p (ej: 720p) text = `${levels[index].height}p`; } btn.innerHTML = `${text}`; } wrapper.appendChild(select); wrapper.appendChild(btn); // Insertar en controles Plyr (antes del botón de pantalla completa o ajustes) // Insertamos antes del 5º elemento (usualmente settings o fullscreen) const insertIndex = controls.children.length > 4 ? 4 : controls.children.length - 1; controls.insertBefore(wrapper, controls.children[insertIndex]); } function createAudioSelector(hls) { if (!hls.audioTracks || hls.audioTracks.length < 2) return; const plyrEl = els.video.closest('.plyr'); const controls = plyrEl.querySelector('.plyr__controls'); if (!controls || controls.querySelector('#audio-control-wrapper')) return; // 1. Wrapper const wrapper = document.createElement('div'); wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper'; wrapper.id = 'audio-control-wrapper'; // 2. Botón Visual const btn = document.createElement('div'); btn.className = 'plyr__custom-control-btn'; btn.innerHTML = `Audio 1`; // 3. Select Invisible const select = document.createElement('select'); hls.audioTracks.forEach((t, i) => { const opt = document.createElement('option'); opt.value = i; opt.textContent = t.name || t.lang || `Audio ${i + 1}`; select.appendChild(opt); }); select.value = hls.audioTrack; updateLabel(select.value); select.onchange = () => { hls.audioTrack = Number(select.value); updateLabel(select.value); }; function updateLabel(val) { const index = Number(val); const track = hls.audioTracks[index]; // Priorizamos el idioma (lang), luego el nombre let rawText = track.lang || track.name || `A${index + 1}`; // Tomamos solo las 2 primeras letras y las pasamos a Mayúsculas let shortText = rawText.substring(0, 2).toUpperCase(); btn.querySelector('.label-text').innerText = shortText; } wrapper.appendChild(select); wrapper.appendChild(btn); // Insertar antes del selector de calidad si existe, o en la posición 4 const qualityWrapper = controls.querySelector('#quality-control-wrapper'); if(qualityWrapper) { controls.insertBefore(wrapper, qualityWrapper); } else { const insertIndex = controls.children.length > 4 ? 4 : controls.children.length - 1; controls.insertBefore(wrapper, controls.children[insertIndex]); } } function initPlyr(enableAudio = false) { if (plyrInstance) return; const settings = ['captions', 'quality', 'speed']; if (enableAudio) settings.unshift('audio'); plyrInstance = new Plyr(els.video, { captions: { active: true, update: true, language: els.video.querySelector('track')?.srclang || 'en' }, fullscreen: { enabled: true, fallback: true, iosNative: true, container: '.player-container' }, controls: [ 'play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'captions', 'settings', 'fullscreen', 'airplay' ], settings }); const container = document.querySelector('.player-container'); plyrInstance.on('controlshidden', () => container.classList.add('ui-hidden')); plyrInstance.on('controlsshown', () => container.classList.remove('ui-hidden')); const tracks = els.video.textTracks; if (tracks && tracks.length) tracks[0].mode = 'showing'; plyrInstance.on('ready', () => { if (hlsInstance) createAudioSelector(hlsInstance); }); plyrInstance.on('timeupdate', (event) => { const instance = event.detail.plyr; if (!instance.duration || _progressUpdated) return; const percentage = instance.currentTime / instance.duration; if (percentage >= 0.8) { updateProgress(); _progressUpdated = true; } }); } function toVtt(sec) { const h = String(Math.floor(sec / 3600)).padStart(2, '0'); const m = String(Math.floor(sec % 3600 / 60)).padStart(2, '0'); const s = (sec % 60).toFixed(3).padStart(6, '0'); return `${h}:${m}:${s}`; } function injectAniSkipChapters(intervals) { const vtt = ['WEBVTT', '']; intervals.forEach(skip => { const label = skip.type === 'op' ? 'Opening' : 'Ending'; vtt.push(`${toVtt(skip.startTime)} --> ${toVtt(skip.endTime)}`, label, ''); }); const blob = new Blob([vtt.join('\n')], { type: 'text/vtt' }); const url = URL.createObjectURL(blob); const track = document.createElement('track'); track.kind = 'chapters'; track.label = 'Chapters'; track.srclang = 'en'; track.src = url; els.video.appendChild(track); } 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('timeupdate', check); resolve(video.duration); } }; video.addEventListener('timeupdate', check); }); } async function applyAniSkip(malId, episodeNumber) { if (!malId) 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 })); injectAniSkipChapters(_skipIntervals); requestAnimationFrame(() => renderSkipMarkers(_skipIntervals)); } catch (e) { console.error('AniSkip Error:', e); } } function renderSkipMarkers(intervals) { const progressContainer = els.video.closest('.plyr')?.querySelector('.plyr__progress'); if (!progressContainer || !els.video.duration) return; progressContainer.querySelectorAll('.skip-marker').forEach(e => e.remove()); intervals.forEach(skip => { const el = document.createElement('div'); el.className = `skip-marker ${skip.type}`; const startPct = (skip.startTime / els.video.duration) * 100; const endPct = (skip.endTime / els.video.duration) * 100; el.style.left = `${startPct}%`; el.style.width = `${endPct - startPct}%`; progressContainer.appendChild(el); }); monitorSkipButton(intervals); } function monitorSkipButton(intervals) { if (!_skipBtn) return; els.video.removeEventListener('timeupdate', checkTime); els.video.addEventListener('timeupdate', checkTime); els.video.addEventListener('ended', () => { if (_currentEpisode < _totalEpisodes) playEpisode(_currentEpisode + 1); }, { once: true }); function 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'); } } function showSkipButton(text, seekTime, isNextAction) { if (!_skipBtn) return; _skipBtn.innerHTML = `${text} `; if (isNextAction) { _skipBtn.classList.add('is-next'); _skipBtn.dataset.seekTo = ''; } else { _skipBtn.classList.remove('is-next'); _skipBtn.dataset.seekTo = seekTime; } _skipBtn.classList.add('visible'); } 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); } } return { init, playEpisode, getCurrentEpisode: () => _currentEpisode }; })();