827 lines
30 KiB
JavaScript
827 lines
30 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 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: `<svg viewBox="0 0 24 24"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg>`,
|
|
audio: `<svg viewBox="0 0 24 24"><path d="M12,2C6.48,2,2,6.48,2,12v6c0,2.21,1.79,4,4,4h1c1.1,0,2-0.9,2-2v-5c0-1.1-0.9-2-2-2H3v-1c0-4.97,4.03-9,9-9s9,4.03,9,9v1 h-4c-1.1,0-2,0.9-2,2v5c0,1.1,0.9,2,2,2h1c2.21,0,4-1.79,4-4v-6C22,6.48,17.52,2,12,2z"/></svg>`
|
|
};
|
|
|
|
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} <span class="label-text">Auto</span>`;
|
|
|
|
// 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 = `<span class="label-text">${text}</span>`;
|
|
}
|
|
|
|
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 = `<span class="label-text">Audio 1</span>`;
|
|
|
|
// 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} <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');
|
|
}
|
|
|
|
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
|
|
};
|
|
})(); |