From 7b3c559d035d1c37caa76e62e61a552be87bc232 Mon Sep 17 00:00:00 2001 From: lenafx Date: Wed, 31 Dec 2025 15:42:34 +0100 Subject: [PATCH] player now supports quality settings --- desktop/src/scripts/anime/player.js | 46 ++++- docker/src/scripts/anime/player.js | 289 ++++++++++++++++++---------- 2 files changed, 226 insertions(+), 109 deletions(-) diff --git a/desktop/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js index 39adba2..058bbdd 100644 --- a/desktop/src/scripts/anime/player.js +++ b/desktop/src/scripts/anime/player.js @@ -462,11 +462,14 @@ const AnimePlayer = (function() { }); 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'; }); } else { @@ -493,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; diff --git a/docker/src/scripts/anime/player.js b/docker/src/scripts/anime/player.js index 2524669..058bbdd 100644 --- a/docker/src/scripts/anime/player.js +++ b/docker/src/scripts/anime/player.js @@ -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} `; - 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'); }