player now supports quality settings

This commit is contained in:
2025-12-31 15:42:34 +01:00
parent 776079d5c6
commit 7b3c559d03
2 changed files with 226 additions and 109 deletions

View File

@@ -10,6 +10,12 @@ const AnimePlayer = (function() {
let _skipIntervals = [];
let _progressUpdated = false;
let _animeTitle = "Anime";
let _rpcActive = false;
let _rawVideoData = null;
let _currentSubtitles = [];
let _localEntryId = null;
let _totalEpisodes = 0;
@@ -25,7 +31,10 @@ const AnimePlayer = (function() {
serverSelect: null,
extSelect: null,
subDubToggle: null,
epTitle: null
epTitle: null,
prevBtn: null,
nextBtn: null,
mpvBtn: null
};
function init(animeId, initialSource, isLocal, animeData) {
@@ -34,85 +43,133 @@ const AnimePlayer = (function() {
_isLocal = isLocal;
_malId = animeData.idMal || null;
// Guardar total de episodios (por defecto 1000 si no hay info)
_totalEpisodes = animeData.episodes || 1000;
if (animeData.title) {
_animeTitle = animeData.title.romaji || animeData.title.english || animeData.title.native || animeData.title || "Anime";
}
_skipIntervals = [];
_localEntryId = null;
// --- 1. REFERENCIAS BÁSICAS DEL DOM (Asegúrate de tener todas estas) ---
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');
// --- 2. REFERENCIAS QUE FALTABAN (Causantes del error) ---
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');
// --- 3. REFERENCIAS DE NAVEGACIÓN (Nuevas) ---
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);
// --- 4. CONFIGURACIÓN DE NAVEGACIÓN ---
if(els.prevBtn) els.prevBtn.addEventListener('click', () => playEpisode(_currentEpisode - 1));
if(els.nextBtn) els.nextBtn.addEventListener('click', () => playEpisode(_currentEpisode + 1));
// --- 5. CONFIGURACIÓN BOTÓN FLOTANTE (SKIP/NEXT) ---
if (!document.getElementById('skip-overlay-btn')) {
const btn = document.createElement('button');
btn.id = 'skip-overlay-btn';
// No le ponemos contenido inicial, se maneja dinámicamente
const container = document.querySelector('.player-container');
container.appendChild(btn);
if(container) container.appendChild(btn);
_skipBtn = btn;
} else {
_skipBtn = document.getElementById('skip-overlay-btn');
}
if(_skipBtn) _skipBtn.onclick = () => handleOverlayClick();
// Listener único para el botón flotante
_skipBtn.onclick = () => handleOverlayClick();
// --- 6. LISTENERS DE CONTROLES (Audio, Servers, Ext) ---
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));
// Cargar lista inicial
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;
// Si es modo "Next Episode"
if (_skipBtn.classList.contains('is-next')) {
playEpisode(_currentEpisode + 1);
}
// Si es modo "Skip Intro/Ending" (saltar tiempo)
else if (_skipBtn.dataset.seekTo) {
} else if (_skipBtn.dataset.seekTo) {
els.video.currentTime = parseFloat(_skipBtn.dataset.seekTo);
}
// Ocultar tras click
_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);
@@ -122,26 +179,19 @@ const AnimePlayer = (function() {
function playEpisode(episodeNumber) {
const targetEp = parseInt(episodeNumber);
// Validar límites
if (targetEp < 1 || targetEp > _totalEpisodes) return;
_currentEpisode = targetEp;
// Actualizar UI
if(els.epTitle) els.epTitle.innerText = `Episode ${targetEp}`;
// Habilitar/Deshabilitar flechas de navegación
if(els.prevBtn) els.prevBtn.disabled = (_currentEpisode <= 1);
if(els.nextBtn) els.nextBtn.disabled = (_currentEpisode >= _totalEpisodes);
// Ocultar botón flotante al cambiar de cap
if(_skipBtn) {
_skipBtn.classList.remove('visible');
_skipBtn.classList.remove('is-next');
}
// URL Update y lógica existente...
const newUrl = new URL(window.location);
newUrl.searchParams.set('episode', targetEp);
window.history.pushState({}, '', newUrl);
@@ -149,10 +199,11 @@ const AnimePlayer = (function() {
if(els.playerWrapper) els.playerWrapper.style.display = 'block';
document.body.classList.add('stop-scrolling');
// Pausar trailer fondo si existe
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;
@@ -175,6 +226,9 @@ const AnimePlayer = (function() {
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');
@@ -195,7 +249,6 @@ const AnimePlayer = (function() {
if (_isLocal && !extensions.includes('local')) extensions.push('local');
els.extSelect.innerHTML = '';
extensions.forEach(ext => {
const opt = document.createElement('option');
opt.value = ext;
@@ -215,13 +268,11 @@ const AnimePlayer = (function() {
} 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';
@@ -230,7 +281,6 @@ const AnimePlayer = (function() {
}
setLoading("Loading Extension Settings...");
try {
const res = await fetch(`/api/extensions/${selectedExt}/settings`);
const settings = await res.json();
@@ -239,7 +289,6 @@ const AnimePlayer = (function() {
setAudioMode('sub');
els.serverSelect.innerHTML = '';
if (settings.episodeServers && settings.episodeServers.length > 0) {
settings.episodeServers.forEach(srv => {
const opt = document.createElement('option');
@@ -258,7 +307,6 @@ const AnimePlayer = (function() {
} else {
if(els.loader) els.loader.style.display = 'none';
}
} catch (error) {
console.error("Failed to load settings:", error);
setLoading("Failed to load extension settings.");
@@ -285,33 +333,34 @@ const AnimePlayer = (function() {
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}`;
console.log("Playing Local:", localUrl);
initVideoPlayer(localUrl, 'mp4');
_rawVideoData = {
url: window.location.origin + localUrl,
headers: {}
};
_currentSubtitles = [];
initVideoPlayer(localUrl, 'mp4');
} catch(e) {
setLoading("Local Error: " + e.message);
console.error(e);
}
return;
}
@@ -319,11 +368,9 @@ const AnimePlayer = (function() {
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 {
console.log('Fetching stream:', url);
const res = await fetch(url);
const data = await res.json();
@@ -335,6 +382,11 @@ const AnimePlayer = (function() {
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'])}`;
@@ -345,11 +397,15 @@ const AnimePlayer = (function() {
src: `/api/proxy?url=${encodeURIComponent(sub.url)}`
}));
initVideoPlayer(proxyUrl, source.type, subtitles);
_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);
}
}
@@ -376,29 +432,46 @@ const AnimePlayer = (function() {
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'))) {
console.log("Using HLS.js");
hlsInstance = new Hls();
hlsInstance.attachMedia(video);
hlsInstance.attachMedia(els.video);
hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
hlsInstance.loadSource(url);
});
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
attachSubtitles(subtitles);
initPlyr();
plyrInstance.on('ready', () => {
createAudioSelector(hlsInstance);
createQualitySelector(hlsInstance);
});
els.video.play().catch(() => {});
els.loader.style.display = 'none';
});
hlsInstance.on(Hls.Events.ERROR, function (event, data) {
console.error("HLS Error:", data);
if (data.fatal) {
setLoading("Playback Error: " + data.details);
}
});
} else {
els.video.src = url;
attachSubtitles(subtitles);
@@ -423,6 +496,45 @@ const AnimePlayer = (function() {
});
}
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) return;
if (controls.querySelector('#quality-select')) return;
const wrapper = document.createElement('div');
wrapper.className = 'plyr__control';
const select = document.createElement('select');
select.id = 'quality-select';
// AUTO
const auto = document.createElement('option');
auto.value = -1;
auto.textContent = 'Auto';
select.appendChild(auto);
levels.forEach((l, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = `${l.height}p`;
select.appendChild(opt);
});
select.value = hls.currentLevel;
select.onchange = () => {
hls.currentLevel = Number(select.value);
};
wrapper.appendChild(select);
controls.insertBefore(wrapper, controls.children[4]);
}
function createAudioSelector(hls) {
if (!hls.audioTracks || hls.audioTracks.length < 2) return;
@@ -457,6 +569,7 @@ const AnimePlayer = (function() {
function initPlyr(enableAudio = false) {
if (plyrInstance) return;
const settings = ['captions', 'quality', 'speed'];
if (enableAudio) settings.unshift('audio');
@@ -481,32 +594,21 @@ const AnimePlayer = (function() {
});
const container = document.querySelector('.player-container');
plyrInstance.on('controlshidden', () => container.classList.add('ui-hidden'));
plyrInstance.on('controlsshown', () => container.classList.remove('ui-hidden'));
plyrInstance.on('controlshidden', () => {
container.classList.add('ui-hidden');
});
const tracks = els.video.textTracks;
if (tracks && tracks.length) tracks[0].mode = 'showing';
plyrInstance.on('ready', () => {
if (hlsInstance) createAudioSelector(hlsInstance);
});
plyrInstance.on('controlsshown', () => {
container.classList.remove('ui-hidden');
});
// ------------------------
const tracks = els.video.textTracks;
if (tracks && tracks.length) {
tracks[0].mode = 'showing';
}
plyrInstance.on('timeupdate', (event) => {
const instance = event.detail.plyr;
if (!instance.duration || _progressUpdated) return;
const percentage = instance.currentTime / instance.duration;
if (percentage >= 0.8) {
console.log("Reaching 80% - Updating Progress...");
updateProgress();
_progressUpdated = true;
}
@@ -526,7 +628,6 @@ const AnimePlayer = (function() {
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');
@@ -553,14 +654,11 @@ const AnimePlayer = (function() {
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;
@@ -569,11 +667,8 @@ const AnimePlayer = (function() {
endTime: item.interval.endTime,
type: item.skipType
}));
injectAniSkipChapters(_skipIntervals);
requestAnimationFrame(() => {
renderSkipMarkers(_skipIntervals);
});
requestAnimationFrame(() => renderSkipMarkers(_skipIntervals));
} catch (e) { console.error('AniSkip Error:', e); }
}
@@ -588,23 +683,18 @@ const AnimePlayer = (function() {
el.className = `skip-marker ${skip.type}`;
const startPct = (skip.startTime / els.video.duration) * 100;
const endPct = (skip.endTime / els.video.duration) * 100;
const widthPct = endPct - startPct;
el.style.left = `${startPct}%`;
el.style.width = `${widthPct}%`;
el.style.width = `${endPct - startPct}%`;
progressContainer.appendChild(el);
});
monitorSkipButton(intervals);
}
function monitorSkipButton(intervals) {
if (!_skipBtn) return;
// Limpiar listener anterior para no acumular
els.video.removeEventListener('timeupdate', checkTime);
els.video.addEventListener('timeupdate', checkTime);
// Auto-Next al terminar el video
els.video.addEventListener('ended', () => {
if (_currentEpisode < _totalEpisodes) playEpisode(_currentEpisode + 1);
}, { once: true });
@@ -612,57 +702,42 @@ const AnimePlayer = (function() {
function checkTime() {
const ct = els.video.currentTime;
const duration = els.video.duration;
// 1. Revisar intervalos de AniSkip (Opening / Ending)
const activeInterval = intervals.find(i => ct >= i.startTime && ct <= i.endTime);
if (activeInterval) {
// Caso OPENING
if (activeInterval.type === 'op') {
showSkipButton('Skip Intro', activeInterval.endTime, false);
return;
}
// Caso ENDING (Funciona como Next Episode)
else if (activeInterval.type === 'ed') {
// Si hay próximo episodio, mostramos botón Next
} else if (activeInterval.type === 'ed') {
if (_currentEpisode < _totalEpisodes) {
showSkipButton('Next Episode', null, true);
} else {
// Si es el último ep, solo saltamos el ending
showSkipButton('Skip Ending', activeInterval.endTime, false);
}
return;
}
}
// 2. Fallback: Si NO estamos en un intervalo AniSkip,
// pero estamos cerca del final del video (ej. faltan 90s)
if (_currentEpisode < _totalEpisodes && (duration - ct) < 90 && (duration - ct) > 0) {
// Solo mostrar si no hay un intervalo activo impidiendo esto
if (!activeInterval) {
showSkipButton('Next Episode', null, true);
return;
}
}
// Si nada de lo anterior aplica, ocultar botón
_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'); // Estilo morado
_skipBtn.dataset.seekTo = ''; // No busca tiempo, cambia ep
_skipBtn.classList.add('is-next');
_skipBtn.dataset.seekTo = '';
} else {
_skipBtn.classList.remove('is-next'); // Estilo blanco
_skipBtn.classList.remove('is-next');
_skipBtn.dataset.seekTo = seekTime;
}
_skipBtn.classList.add('visible');
}