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
};
})();