diff --git a/desktop/src/api/anime/anime.controller.ts b/desktop/src/api/anime/anime.controller.ts index 84b94bf..0bbec30 100644 --- a/desktop/src/api/anime/anime.controller.ts +++ b/desktop/src/api/anime/anime.controller.ts @@ -116,7 +116,6 @@ export async function getWatchStream(req: WatchStreamRequest, reply: FastifyRepl export async function openInMPV(req: any, reply: any) { try { - const { title, video, subtitles = [], chapters = [], animeId, episode, entrySource, token } = req.body; if (!video?.url) return { error: 'Missing video url' }; @@ -132,7 +131,7 @@ export async function openInMPV(req: any, reply: any) { `&userAgent=${encodeURIComponent(video.headers?.['User-Agent'] ?? '')}`; const proxySubs = subtitles.map((s: any) => - `${proxyBase}?url=${encodeURIComponent(s.url)}` + + `${proxyBase}?url=${encodeURIComponent(s.src)}` + `&referer=${encodeURIComponent(video.headers?.Referer ?? '')}` + `&origin=${encodeURIComponent(video.headers?.Origin ?? '')}` + `&userAgent=${encodeURIComponent(video.headers?.['User-Agent'] ?? '')}` @@ -142,20 +141,26 @@ export async function openInMPV(req: any, reply: any) { let chaptersArg: string[] = []; if (chapters.length) { - chapters.sort((a: any, b: any) => a.interval.startTime - b.interval.startTime); + + chapters.sort((a: any, b: any) => a.startTime - b.startTime); + const lines = [';FFMETADATA1']; for (let i = 0; i < chapters.length; i++) { const c = chapters[i]; - const start = Math.floor(c.interval.startTime * 1000); - const end = Math.floor(c.interval.endTime * 1000); + + const start = Math.floor(c.startTime * 1000); + const end = Math.floor(c.endTime * 1000); + + const title = (c.type || 'chapter').toUpperCase(); lines.push( - `[CHAPTER]`, `TIMEBASE=1/1000`, `START=${start}`, `END=${end}`, `title=${c.skipType.toUpperCase()}` + `[CHAPTER]`, `TIMEBASE=1/1000`, `START=${start}`, `END=${end}`, `title=${title}` ); if (i < chapters.length - 1) { - const nextStart = Math.floor(chapters[i + 1].interval.startTime * 1000); + const nextStart = Math.floor(chapters[i + 1].startTime * 1000); + if (nextStart - end > 1000) { lines.push( `[CHAPTER]`, `TIMEBASE=1/1000`, `START=${end}`, `END=${nextStart}`, `title=Episode` @@ -293,9 +298,17 @@ export async function openInMPV(req: any, reply: any) { commands.forEach(cmd => socket.write(JSON.stringify(cmd) + '\n')); - for (const sub of proxySubs) { - socket.write(JSON.stringify({ command: ['sub-add', sub, 'auto'] }) + '\n'); - } + subtitles.forEach((s: any, i: number) => { + socket.write(JSON.stringify({ + command: [ + 'sub-add', + proxySubs[i], + 'auto', + s.label || 'Subtitle', + s.srclang || '' + ] + }) + '\n'); + }); return { success: true }; } catch (e) { diff --git a/desktop/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js index b20db5c..20b17ec 100644 --- a/desktop/src/scripts/anime/player.js +++ b/desktop/src/scripts/anime/player.js @@ -10,10 +10,12 @@ const AnimePlayer = (function() { let _skipIntervals = []; let _progressUpdated = false; - // Variables nuevas para RPC let _animeTitle = "Anime"; let _rpcActive = false; + let _rawVideoData = null; + let _currentSubtitles = []; + let _localEntryId = null; let _totalEpisodes = 0; @@ -31,7 +33,8 @@ const AnimePlayer = (function() { subDubToggle: null, epTitle: null, prevBtn: null, - nextBtn: null + nextBtn: null, + mpvBtn: null }; function init(animeId, initialSource, isLocal, animeData) { @@ -40,10 +43,8 @@ const AnimePlayer = (function() { _isLocal = isLocal; _malId = animeData.idMal || null; - // Guardar total de episodios _totalEpisodes = animeData.episodes || 1000; - // Extraer título para RPC (Lógica traída de player.js) if (animeData.title) { _animeTitle = animeData.title.romaji || animeData.title.english || animeData.title.native || animeData.title || "Anime"; } @@ -51,13 +52,15 @@ const AnimePlayer = (function() { _skipIntervals = []; _localEntryId = null; - // --- REFERENCIAS DOM --- 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'); @@ -69,11 +72,9 @@ const AnimePlayer = (function() { const closeBtn = document.getElementById('close-player-btn'); if(closeBtn) closeBtn.addEventListener('click', closePlayer); - // 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)); - // Botón Flotante (Skip) if (!document.getElementById('skip-overlay-btn')) { const btn = document.createElement('button'); btn.id = 'skip-overlay-btn'; @@ -85,7 +86,6 @@ const AnimePlayer = (function() { } if(_skipBtn) _skipBtn.onclick = () => handleOverlayClick(); - // Listeners Controles 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)); @@ -93,7 +93,51 @@ const AnimePlayer = (function() { loadExtensionsList(); } - // --- FUNCIÓN RPC (Integrada desde player.js) --- + 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", @@ -158,7 +202,6 @@ const AnimePlayer = (function() { const trailer = document.querySelector('#trailer-player iframe'); if(trailer) trailer.contentWindow.postMessage('{"event":"command","func":"pauseVideo","args":""}', '*'); - // Reset RPC state on new episode _rpcActive = false; if (els.extSelect.value === 'local') { @@ -185,7 +228,6 @@ const AnimePlayer = (function() { _skipIntervals = []; _rpcActive = false; - // Enviar señal de pausa o limpieza al cerrar sendRPC({ paused: true }); const newUrl = new URL(window.location); @@ -294,6 +336,9 @@ const AnimePlayer = (function() { _progressUpdated = false; setLoading("Fetching Stream..."); + _rawVideoData = null; + _currentSubtitles = []; + if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; } const currentExt = els.extSelect.value; @@ -306,6 +351,13 @@ const AnimePlayer = (function() { 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); @@ -330,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'])}`; @@ -340,6 +397,12 @@ const AnimePlayer = (function() { 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); @@ -350,17 +413,10 @@ const AnimePlayer = (function() { const video = els.video; Array.from(video.querySelectorAll('track')).forEach(t => t.remove()); - // Limpiar listeners de video antiguos para evitar duplicados en RPC const newVideo = video.cloneNode(true); video.parentNode.replaceChild(newVideo, video); els.video = newVideo; - // Nota: Al clonar perdemos referencia en 'els', hay que reasignar - // Sin embargo, clonar rompe Plyr si no se tiene cuidado. - // Mejor estrategia: Remover listeners específicos si fuera posible, - // pero dado que son anónimos, la clonación es efectiva si reinicializamos todo. - // Como initPlyr se llama después, esto funciona. - // --- INYECCIÓN DE EVENTOS RPC --- els.video.addEventListener("play", () => { if (!els.video.duration) return; const elapsed = Math.floor(els.video.currentTime); @@ -381,7 +437,6 @@ const AnimePlayer = (function() { const end = start + Math.floor(els.video.duration); sendRPC({ startTimestamp: start, endTimestamp: end }); }); - // ------------------------------- if (Hls.isSupported() && (type === 'm3u8' || url.includes('.m3u8'))) { hlsInstance = new Hls(); @@ -430,7 +485,7 @@ const AnimePlayer = (function() { } function initPlyr() { - // Asegurarnos de usar el elemento video actualizado + if (plyrInstance) return; plyrInstance = new Plyr(els.video, { diff --git a/desktop/views/anime/anime.html b/desktop/views/anime/anime.html index 343191d..7f88f1d 100644 --- a/desktop/views/anime/anime.html +++ b/desktop/views/anime/anime.html @@ -74,6 +74,12 @@
+
Sub
diff --git a/desktop/views/css/anime/player.css b/desktop/views/css/anime/player.css index 27215b6..c0e2030 100644 --- a/desktop/views/css/anime/player.css +++ b/desktop/views/css/anime/player.css @@ -504,4 +504,36 @@ body.stop-scrolling { opacity: 1 !important; visibility: visible !important; pointer-events: auto !important; +} + +.glass-btn-mpv { + display: flex; + align-items: center; + gap: 6px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.15); + color: white; + padding: 6px 12px; + border-radius: 8px; + font-weight: 700; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; + backdrop-filter: blur(10px); + height: 36px; /* Para igualar la altura de los selects/toggles */ +} + +.glass-btn-mpv:hover { + background: white; + color: black; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.2); +} + +.glass-btn-mpv svg { + margin-top: -1px; +} + +.glass-btn-mpv:active { + transform: scale(0.95); } \ No newline at end of file