From 2afb9742b1a19600ba9ea175fe31abe558669a95 Mon Sep 17 00:00:00 2001 From: lenafx Date: Wed, 31 Dec 2025 02:05:10 +0100 Subject: [PATCH] full enhance of anime page and player. --- desktop/src/api/anime/anime.service.ts | 54 +- desktop/src/scripts/anime/anime.js | 327 ----- desktop/src/scripts/anime/entry.js | 408 ++++++ desktop/src/scripts/anime/player.js | 1090 +++++++++-------- .../utils/continue-watching-manager.js | 5 +- desktop/src/views/views.routes.ts | 6 - desktop/views/anime/anime.html | 243 ++-- desktop/views/anime/watch.html | 197 --- desktop/views/css/anime/anime.css | 457 ++++--- desktop/views/css/anime/player.css | 507 ++++++++ desktop/views/css/anime/watch.css | 830 ------------- desktop/views/css/components/titlebar.css | 5 + docker/src/api/anime/anime.service.ts | 54 +- docker/src/scripts/anime/anime.js | 326 ----- docker/src/scripts/anime/entry.js | 408 ++++++ docker/src/scripts/anime/player.js | 1082 +++++++++------- .../utils/continue-watching-manager.js | 5 +- docker/src/views/views.routes.ts | 6 - docker/views/anime/anime.html | 355 +++--- docker/views/anime/watch.html | 184 --- docker/views/css/anime/anime.css | 457 ++++--- docker/views/css/anime/player.css | 507 ++++++++ docker/views/css/anime/watch.css | 830 ------------- 23 files changed, 3900 insertions(+), 4443 deletions(-) delete mode 100644 desktop/src/scripts/anime/anime.js create mode 100644 desktop/src/scripts/anime/entry.js delete mode 100644 desktop/views/anime/watch.html create mode 100644 desktop/views/css/anime/player.css delete mode 100644 desktop/views/css/anime/watch.css delete mode 100644 docker/src/scripts/anime/anime.js create mode 100644 docker/src/scripts/anime/entry.js delete mode 100644 docker/views/anime/watch.html create mode 100644 docker/views/css/anime/player.css delete mode 100644 docker/views/css/anime/watch.css diff --git a/desktop/src/api/anime/anime.service.ts b/desktop/src/api/anime/anime.service.ts index 3e494bf..ce1836c 100644 --- a/desktop/src/api/anime/anime.service.ts +++ b/desktop/src/api/anime/anime.service.ts @@ -41,17 +41,21 @@ const MEDIA_FIELDS = ` siteUrl tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult } relations { - edges { - relationType - node { - id - title { romaji } - type - format - status + edges { + relationType + node { + id + title { romaji english } + type + format + status + coverImage { medium large color } + bannerImage + season + seasonYear + } } } - } studios { edges { isMain @@ -70,12 +74,28 @@ const MEDIA_FIELDS = ` mediaRecommendation { id title { romaji } - coverImage { medium } + coverImage { medium large} format type } } } + characters(perPage: 12, sort: [ROLE, RELEVANCE]) { + edges { + role + node { + id + name { full native } + image { medium large } + } + voiceActors { + id + name { full } + language + image { medium } + } + } + } `; export async function refreshTrendingAnime(): Promise { @@ -140,7 +160,19 @@ async function fetchAniList(query: string, variables: any) { export async function getAnimeById(id: string | number): Promise { const row = await queryOne("SELECT full_data FROM anime WHERE id = ?", [id]); - if (row) return JSON.parse(row.full_data); + if (row) { + const cached = JSON.parse(row.full_data); + + if (cached?.characters?.edges?.length) { + return cached; + } + + await queryOne( + "DELETE FROM anime WHERE id = ?", + [id] + ); + } + const query = ` query ($id: Int) { diff --git a/desktop/src/scripts/anime/anime.js b/desktop/src/scripts/anime/anime.js deleted file mode 100644 index b637837..0000000 --- a/desktop/src/scripts/anime/anime.js +++ /dev/null @@ -1,327 +0,0 @@ -let animeData = null; -let extensionName = null; -let animeId = null; -let isLocal = false; - -const episodePagination = Object.create(PaginationManager); -episodePagination.init(12, renderEpisodes); - -YouTubePlayerUtils.init('player'); - -document.addEventListener('DOMContentLoaded', () => { - loadAnime(); - setupDescriptionModal(); - setupEpisodeSearch(); -}); - -function markAsLocal() { - isLocal = true; - const pill = document.getElementById('local-pill'); - if (!pill) return; - - pill.textContent = 'Local'; - pill.style.display = 'inline-flex'; - pill.style.background = 'rgba(34,197,94,.2)'; - pill.style.color = '#22c55e'; - pill.style.borderColor = 'rgba(34,197,94,.3)'; -} - -async function checkLocalLibraryEntry() { - try { - const res = await fetch(`/api/library/anime/${animeId}`); - if (!res.ok) return; - - markAsLocal(); - - } catch (e) { - } -} - - -async function loadAnime() { - try { - - const urlData = URLUtils.parseEntityPath('anime'); - if (!urlData) { - showError("Invalid URL"); - return; - } - - extensionName = urlData.extensionName; - animeId = urlData.entityId; - await checkLocalLibraryEntry(); - - const fetchUrl = extensionName - ? `/api/anime/${animeId}?source=${extensionName}` - : `/api/anime/${animeId}?source=anilist`; - - const res = await fetch(fetchUrl, { headers: AuthUtils.getSimpleAuthHeaders() }); - const data = await res.json(); - - if (data.error) { - showError("Anime Not Found"); - return; - } - - animeData = data; - animeData.entry_type = 'ANIME'; - const metadata = MediaMetadataUtils.formatAnimeData(data, !!extensionName); - - updatePageTitle(metadata.title); - updateMetadata(metadata); - updateDescription(data.description || data.summary); - updateCharacters(metadata.characters); - updateExtensionPill(); - - setupWatchButton(); - - const hasTrailer = YouTubePlayerUtils.playTrailer( - metadata.trailer, - 'player', - metadata.banner - ); - - setupEpisodes(metadata.episodes); - - await setupAddToListButton(); - - } catch (err) { - console.error('Error loading anime:', err); - showError("Error loading anime"); - } -} - -function updatePageTitle(title) { - document.title = `${title} | WaifuBoard`; - document.getElementById('title').innerText = title; -} - -function updateMetadata(metadata) { - - if (metadata.poster) { - document.getElementById('poster').src = metadata.poster; - } - - document.getElementById('score').innerText = `${metadata.score}% Score`; - - document.getElementById('year').innerText = metadata.year; - - document.getElementById('genres').innerText = metadata.genres; - - document.getElementById('format').innerText = metadata.format; - - document.getElementById('status').innerText = metadata.status; - - document.getElementById('season').innerText = metadata.season; - - document.getElementById('studio').innerText = metadata.studio; - - document.getElementById('episodes').innerText = metadata.episodes; -} - -function updateDescription(rawDescription) { - const desc = MediaMetadataUtils.truncateDescription(rawDescription, 4); - - document.getElementById('description-preview').innerHTML = desc.short; - document.getElementById('full-description').innerHTML = desc.full; - - const readMoreBtn = document.getElementById('read-more-btn'); - if (desc.isTruncated) { - readMoreBtn.style.display = 'inline-flex'; - } else { - readMoreBtn.style.display = 'none'; - } -} - -function updateCharacters(characters) { - const container = document.getElementById('char-list'); - container.innerHTML = ''; - - if (characters.length > 0) { - characters.forEach(char => { - container.innerHTML += ` -
-
${char.name} -
`; - }); - } else { - container.innerHTML = ` -
- No character data available -
`; - } -} - -function updateExtensionPill() { - const pill = document.getElementById('extension-pill'); - if (!pill) return; - - if (extensionName) { - pill.textContent = extensionName.charAt(0).toUpperCase() + extensionName.slice(1).toLowerCase(); - pill.style.display = 'inline-flex'; - } else { - pill.style.display = 'none'; - } -} - -function setupWatchButton() { - const watchBtn = document.getElementById('watch-btn'); - if (watchBtn) { - watchBtn.onclick = () => { - const source = isLocal ? 'local' : (extensionName || 'anilist'); - window.location.href = URLUtils.buildWatchUrl(animeId, num, source); - }; - } -} - -async function setupAddToListButton() { - const btn = document.getElementById('add-to-list-btn'); - if (!btn || !animeData) return; - - ListModalManager.currentData = animeData; - const entryType = ListModalManager.getEntryType(animeData); - - await ListModalManager.checkIfInList(animeId, extensionName || 'anilist', entryType); - - const tempBtn = document.querySelector('.hero-buttons .btn-blur'); - if (tempBtn) { - ListModalManager.updateButton('.hero-buttons .btn-blur'); - } else { - - updateCustomAddButton(); - } - - btn.onclick = () => ListModalManager.open(animeData, extensionName || 'anilist'); -} - -function updateCustomAddButton() { - const btn = document.getElementById('add-to-list-btn'); - if (!btn) return; - - if (ListModalManager.isInList) { - btn.innerHTML = ` - - - - In Your List - `; - btn.style.background = 'rgba(34, 197, 94, 0.2)'; - btn.style.color = '#22c55e'; - btn.style.borderColor = 'rgba(34, 197, 94, 0.3)'; - } else { - btn.innerHTML = '+ Add to List'; - btn.style.background = null; - btn.style.color = null; - btn.style.borderColor = null; - } -} - -function setupEpisodes(totalEpisodes) { - - const limitedTotal = Math.min(Math.max(totalEpisodes, 1), 5000); - - episodePagination.setTotalItems(limitedTotal); - renderEpisodes(); -} - -function renderEpisodes() { - const grid = document.getElementById('episodes-grid'); - if (!grid) return; - - grid.innerHTML = ''; - - const range = episodePagination.getPageRange(); - const start = range.start + 1; - - const end = range.end; - - for (let i = start; i <= end; i++) { - createEpisodeButton(i, grid); - } - - episodePagination.renderControls( - 'pagination-controls', - 'page-info', - 'prev-page', - 'next-page' - ); -} - -function createEpisodeButton(num, container) { - const btn = document.createElement('div'); - btn.className = 'episode-btn'; - btn.innerText = `Ep ${num}`; - btn.onclick = () => { - const source = isLocal ? 'local' : (extensionName || 'anilist'); - window.location.href = URLUtils.buildWatchUrl(animeId, num, source); - }; - container.appendChild(btn); -} - -function setupDescriptionModal() { - const modal = document.getElementById('desc-modal'); - if (!modal) return; - - modal.addEventListener('click', (e) => { - if (e.target.id === 'desc-modal') { - closeDescriptionModal(); - } - }); -} - -function openDescriptionModal() { - document.getElementById('desc-modal').classList.add('active'); - document.body.style.overflow = 'hidden'; -} - -function closeDescriptionModal() { - document.getElementById('desc-modal').classList.remove('active'); - document.body.style.overflow = ''; -} - -function setupEpisodeSearch() { - const searchInput = document.getElementById('ep-search'); - if (!searchInput) return; - - searchInput.addEventListener('input', (e) => { - const val = parseInt(e.target.value); - const grid = document.getElementById('episodes-grid'); - const totalEpisodes = episodePagination.totalItems; - - if (val > 0 && val <= totalEpisodes) { - grid.innerHTML = ''; - createEpisodeButton(val, grid); - document.getElementById('pagination-controls').style.display = 'none'; - } else if (!e.target.value) { - renderEpisodes(); - } else { - grid.innerHTML = '
Episode not found
'; - document.getElementById('pagination-controls').style.display = 'none'; - } - }); -} - -function showError(message) { - document.getElementById('title').innerText = message; -} - -function saveToList() { - if (!animeId) return; - ListModalManager.save(animeId, extensionName || 'anilist'); -} - -function deleteFromList() { - if (!animeId) return; - ListModalManager.delete(animeId, extensionName || 'anilist'); -} - -function closeAddToListModal() { - ListModalManager.close(); -} - -window.openDescriptionModal = openDescriptionModal; -window.closeDescriptionModal = closeDescriptionModal; -window.changePage = (delta) => { - if (delta > 0) episodePagination.nextPage(); - else episodePagination.prevPage(); -}; \ No newline at end of file diff --git a/desktop/src/scripts/anime/entry.js b/desktop/src/scripts/anime/entry.js new file mode 100644 index 0000000..e9310bb --- /dev/null +++ b/desktop/src/scripts/anime/entry.js @@ -0,0 +1,408 @@ +let animeData = null; +let extensionName = null; + +let animeId = null; +let isLocal = false; + +const episodePagination = Object.create(PaginationManager); +episodePagination.init(50, renderEpisodes); + +document.addEventListener('DOMContentLoaded', () => { + loadAnimeData(); + setupDescriptionModal(); + setupEpisodeSearch(); + + const urlParams = new URLSearchParams(window.location.search); + const initialEp = urlParams.get('episode'); + + if (initialEp) { + setTimeout(() => { + if (animeData) { + + const source = isLocal ? 'local' : (extensionName || 'anilist'); + + if (typeof AnimePlayer !== 'undefined') { + AnimePlayer.init(animeId, source, isLocal, animeData); + AnimePlayer.playEpisode(parseInt(initialEp)); + } + } + }, 800); + } +}); + +async function loadAnimeData() { + try { + + const pathParts = window.location.pathname.split('/'); + + const cleanParts = pathParts.filter(p => p.length > 0); + + if (cleanParts.length >= 3 && cleanParts[0] === 'anime') { + + extensionName = cleanParts[1]; + animeId = cleanParts[2]; + } else if (cleanParts.length === 2 && cleanParts[0] === 'anime') { + + extensionName = null; + + animeId = cleanParts[1]; + } else { + showError("Invalid URL Format"); + return; + } + + try { + const localRes = await fetch(`/api/library/anime/${animeId}`); + if (localRes.ok) isLocal = true; + } catch {} + + const fetchUrl = extensionName + ? `/api/anime/${animeId}?source=${extensionName}` + : `/api/anime/${animeId}?source=anilist`; + + const res = await fetch(fetchUrl, { headers: AuthUtils.getSimpleAuthHeaders() }); + const data = await res.json(); + + if (data.error) { + showError("Anime Not Found"); + return; + } + + animeData = data; + animeData.entry_type = 'ANIME'; + + const metadata = MediaMetadataUtils.formatAnimeData(data, !!extensionName); + + document.title = `${metadata.title} | WaifuBoard`; + document.getElementById('title').innerText = metadata.title; + if (metadata.poster) document.getElementById('poster').src = metadata.poster; + + document.getElementById('score').innerText = `${metadata.score}% Score`; + document.getElementById('year').innerText = metadata.year; + document.getElementById('genres').innerText = metadata.genres.split(',').join(' • '); + document.getElementById('format').innerText = metadata.format; + document.getElementById('status').innerText = metadata.status; + document.getElementById('season').innerText = metadata.season; + document.getElementById('studio').innerText = metadata.studio; + document.getElementById('episodes').innerText = `${metadata.episodes} Ep`; + + updateLocalPill(); + updateDescription(data.description || data.summary); + updateRelations(data.relations?.edges); + updateCharacters(metadata.characters); + updateRecommendations(data.recommendations?.nodes); + + const source = isLocal ? 'local' : (extensionName || 'anilist'); + + if (typeof AnimePlayer !== 'undefined') { + AnimePlayer.init(animeId, source, isLocal, animeData); + } + + YouTubePlayerUtils.init('trailer-player'); + YouTubePlayerUtils.playTrailer(metadata.trailer, 'trailer-player', metadata.banner); + + const watchBtn = document.getElementById('watch-btn'); + if (watchBtn) { + watchBtn.onclick = () => { + if (typeof AnimePlayer !== 'undefined') AnimePlayer.playEpisode(1); + }; + } + + const total = metadata.episodes || 12; + setupEpisodes(total); + + setupAddToListButton(); + + } catch (err) { + console.error('Error loading anime details:', err); + showError("Error loading anime"); + } +} + +function updateLocalPill() { + if (isLocal) { + const pill = document.getElementById('local-pill'); + if(pill) pill.style.display = 'inline-flex'; + } + if (extensionName) { + const pill = document.getElementById('extension-pill'); + if(pill) { + pill.innerText = extensionName; + pill.style.display = 'inline-flex'; + } + } +} + +function updateDescription(rawDescription) { + const desc = MediaMetadataUtils.truncateDescription(rawDescription, 3); + const previewEl = document.getElementById('description-preview'); + const fullEl = document.getElementById('full-description'); + + if (previewEl) { + previewEl.innerHTML = desc.short + + (desc.isTruncated ? ' Read more' : ''); + } + if (fullEl) fullEl.innerHTML = desc.full; +} + +function setupDescriptionModal() { + const modal = document.getElementById('desc-modal'); + if (!modal) return; + modal.addEventListener('click', (e) => { + if (e.target.id === 'desc-modal') { + closeDescriptionModal(); + } + }); +} + +function openDescriptionModal() { + const modal = document.getElementById('desc-modal'); + if(modal) modal.classList.add('active'); +} + +function closeDescriptionModal() { + const modal = document.getElementById('desc-modal'); + if(modal) modal.classList.remove('active'); +} + +function setupEpisodes(totalEpisodes) { + const limitedTotal = Math.min(Math.max(totalEpisodes, 1), 5000); + episodePagination.setTotalItems(limitedTotal); + renderEpisodes(); +} + +function renderEpisodes() { + const grid = document.getElementById('episodes-grid'); + if (!grid) return; + grid.innerHTML = ''; + + const range = episodePagination.getPageRange(); + const currentEp = (typeof AnimePlayer !== 'undefined') ? AnimePlayer.getCurrentEpisode() : 0; + + for (let i = range.start + 1; i <= range.end; i++) { + const btn = document.createElement('div'); + btn.className = 'episode-btn'; + if (currentEp === i) btn.classList.add('active-playing'); + + btn.innerText = i; + + btn.onclick = () => { + if (typeof AnimePlayer !== 'undefined') { + AnimePlayer.playEpisode(i); + renderEpisodes(); + } + }; + grid.appendChild(btn); + } + + const totalItems = episodePagination.totalItems || 0; + const itemsPerPage = episodePagination.itemsPerPage || 50; + const safeTotalPages = Math.ceil(totalItems / itemsPerPage) || 1; + + const info = document.getElementById('page-info'); + if(info) info.innerText = `Page ${episodePagination.currentPage} of ${safeTotalPages}`; + + const prevBtn = document.getElementById('prev-page'); + const nextBtn = document.getElementById('next-page'); + + if(prevBtn) { + prevBtn.disabled = episodePagination.currentPage === 1; + prevBtn.onclick = () => { episodePagination.prevPage(); }; + } + + if(nextBtn) { + + nextBtn.disabled = episodePagination.currentPage >= safeTotalPages; + nextBtn.onclick = () => { episodePagination.nextPage(); }; + } +} + +function setupEpisodeSearch() { + const searchInput = document.getElementById('ep-search'); + if (!searchInput) return; + + searchInput.addEventListener('input', (e) => { + const val = parseInt(e.target.value); + const grid = document.getElementById('episodes-grid'); + const controls = document.getElementById('pagination-controls'); + + if (val > 0) { + grid.innerHTML = ''; + const btn = document.createElement('div'); + btn.className = 'episode-btn'; + btn.innerText = `${val}`; + btn.onclick = () => { if (typeof AnimePlayer !== 'undefined') AnimePlayer.playEpisode(val); }; + grid.appendChild(btn); + if(controls) controls.style.display = 'none'; + } else if (!e.target.value) { + if(controls) controls.style.display = 'flex'; + renderEpisodes(); + } + }); +} + +function updateCharacters(characters) { + const container = document.getElementById('char-list'); + if (!container) return; + container.innerHTML = ''; + if (!characters || characters.length === 0) return; + + characters.forEach((char, index) => { + const img = char.node?.image?.large || char.image; + const name = char.node?.name?.full || char.name; + const role = char.role || 'Supporting'; + + const card = document.createElement('div'); + card.className = `character-item ${index >= 12 ? 'hidden-char' : ''}`; + card.style.display = index >= 12 ? 'none' : 'flex'; + + card.innerHTML = ` +
+
${name}
${role}
+ `; + container.appendChild(card); + }); + + const showMoreBtn = document.getElementById('show-more-chars'); + if (showMoreBtn) { + if (characters.length > 12) { + showMoreBtn.style.display = 'block'; + showMoreBtn.onclick = () => { + document.querySelectorAll('.hidden-char').forEach(el => el.style.display = 'flex'); + showMoreBtn.style.display = 'none'; + }; + } else { + showMoreBtn.style.display = 'none'; + } + } +} + +function updateRelations(relations) { + const container = document.getElementById('relations-grid'); + const section = document.getElementById('relations-section'); + + if (!container || !relations || relations.length === 0) { + if (section) section.style.display = 'none'; + return; + } + + section.style.display = 'block'; + container.innerHTML = ''; + + const priorityMap = { + 'ADAPTATION': 1, + 'PREQUEL': 2, + 'SEQUEL': 3, + 'PARENT': 4, + 'SIDE_STORY': 5, + 'SPIN_OFF': 6, + 'ALTERNATIVE': 7, + 'CHARACTER': 8, + 'OTHER': 99 + }; + + relations.sort((a, b) => { + const pA = priorityMap[a.relationType] || 50; + const pB = priorityMap[b.relationType] || 50; + return pA - pB; + }); + + relations.forEach(rel => { + const media = rel.node; + if (!media) return; + + const title = media.title?.romaji || media.title?.english || 'Unknown'; + const cover = media.coverImage?.large || media.coverImage?.medium || ''; + + const typeDisplay = rel.relationType ? rel.relationType.replace(/_/g, ' ') : 'RELATION'; + const rawType = rel.relationType; + + const el = document.createElement('div'); + el.className = 'relation-card-horizontal'; + + let targetUrl = null; + + if (rawType === 'ADAPTATION') { + targetUrl = `/book/${media.id}`; + + } else if (media.type === 'ANIME' && rawType !== 'OTHER') { + targetUrl = `/anime/${media.id}`; + + } + + if (targetUrl) { + el.onclick = () => { window.location.href = targetUrl; }; + } else { + el.classList.add('no-link'); + + el.onclick = null; + } + + el.innerHTML = ` + ${title} +
+ ${typeDisplay} + ${title} +
+ `; + container.appendChild(el); + }); +} + +function updateRecommendations(recommendations) { + const container = document.getElementById('recommendations-grid'); + if (!container || !recommendations) return; + container.innerHTML = ''; + + recommendations.forEach(rec => { + const media = rec.mediaRecommendation; + if (!media) return; + + const title = media.title?.romaji || 'Unknown'; + const cover = media.coverImage?.large || media.coverImage?.medium || ''; + + const el = document.createElement('div'); + el.className = 'card'; + + el.onclick = () => { window.location.href = `/anime/${media.id}`; }; + + el.innerHTML = ` +
+

${title}

+ `; + container.appendChild(el); + }); +} + +async function setupAddToListButton() { + const btn = document.getElementById('add-to-list-btn'); + if (!btn || !animeData) return; + + ListModalManager.currentData = animeData; + const entryType = 'ANIME'; + await ListModalManager.checkIfInList(animeId, extensionName || 'anilist', entryType); + updateCustomAddButton(); + btn.onclick = () => ListModalManager.open(animeData, extensionName || 'anilist'); +} + +function updateCustomAddButton() { + const btn = document.getElementById('add-to-list-btn'); + if (btn && ListModalManager.isInList) { + btn.innerHTML = `✓ In Your List`; + btn.style.background = 'rgba(34, 197, 94, 0.2)'; + btn.style.borderColor = '#22c55e'; + btn.style.color = '#22c55e'; + } +} + +function showError(msg) { + const el = document.getElementById('title'); + if(el) el.innerText = msg; +} + +window.openDescriptionModal = openDescriptionModal; +window.closeDescriptionModal = closeDescriptionModal; +window.scrollRecommendations = (dir) => { + const el = document.getElementById('recommendations-grid'); + if(el) el.scrollBy({ left: 240 * 3 * dir, behavior: 'smooth' }); +}; \ No newline at end of file diff --git a/desktop/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js index c76fde5..b20db5c 100644 --- a/desktop/src/scripts/anime/player.js +++ b/desktop/src/scripts/anime/player.js @@ -1,487 +1,623 @@ -const pathParts = window.location.pathname.split('/'); -const animeId = pathParts[2]; -const currentEpisode = parseInt(pathParts[3]); - -let audioMode = 'sub'; -let currentExtension = ''; -let plyrInstance; -let hlsInstance; -let totalEpisodes = 0; -let animeTitle = ""; -let aniSkipData = null; - -let isAnilist = false; -let malId = null; - -const params = new URLSearchParams(window.location.search); -const firstKey = params.keys().next().value; -let extName; -if (firstKey) extName = firstKey; - -// URL de retroceso: Si es local, volvemos a la vista de Anilist normal -const href = (extName && extName !== 'local') - ? `/anime/${extName}/${animeId}` - : `/anime/${animeId}`; - -document.getElementById('back-link').href = href; -document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`; - - -let localEntryId = null; - -async function checkLocal() { - try { - const res = await fetch(`/api/library/anime/${animeId}`); - if (!res.ok) return null; - const data = await res.json(); - return data.id; - } catch { - return null; - } -} - -async function loadAniSkip(malId, episode, duration) { - try { - const res = await fetch(`https://api.aniskip.com/v2/skip-times/${malId}/${episode}?types[]=op&types[]=ed&episodeLength=${duration}`); - if (!res.ok) return null; - const data = await res.json(); - return data.results || []; - } catch (error) { - console.error('Error loading AniSkip data:', error); - return null; - } -} - -async function loadMetadata() { - localEntryId = await checkLocal(); - try { - const sourceQuery = (extName === 'local' || !extName) ? "source=anilist" : `source=${extName}`; - const res = await fetch(`/api/anime/${animeId}?${sourceQuery}`); - const data = await res.json(); - - if (data.error) { - console.error("Error from API:", data.error); - return; - } - - const isAnilistFormat = data.title && (data.title.romaji || data.title.english); - - let title = '', description = '', coverImage = '', averageScore = '', format = '', seasonYear = '', season = ''; - - if (isAnilistFormat) { - title = data.title.romaji || data.title.english || data.title.native || 'Anime Title'; - description = data.description || 'No description available.'; - coverImage = data.coverImage?.large || data.coverImage?.medium || ''; - averageScore = data.averageScore ? `${data.averageScore}%` : '--'; - format = data.format || '--'; - season = data.season ? data.season.charAt(0) + data.season.slice(1).toLowerCase() : ''; - seasonYear = data.seasonYear || ''; - } else { - title = data.title || 'Anime Title'; - description = data.summary || 'No description available.'; - coverImage = data.image || ''; - averageScore = data.score ? `${Math.round(data.score * 10)}%` : '--'; - format = '--'; - season = data.season || ''; - seasonYear = data.year || ''; - } - - if (isAnilistFormat && data.idMal) { - isAnilist = true; - malId = data.idMal; - } else { - isAnilist = false; - malId = null; - } - - document.getElementById('anime-title-details').innerText = title; - document.getElementById('anime-title-details2').innerText = title; - animeTitle = title; - document.title = `Watching ${title} - Ep ${currentEpisode}`; - - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = description; - document.getElementById('detail-description').innerText = tempDiv.textContent || tempDiv.innerText || 'No description available.'; - - document.getElementById('detail-format').innerText = format; - document.getElementById('detail-score').innerText = averageScore; - document.getElementById('detail-season').innerText = season && seasonYear ? `${season} ${seasonYear}` : (season || seasonYear || '--'); - document.getElementById('detail-cover-image').src = coverImage || '/default-cover.jpg'; - - // Solo cargamos episodios de extensión si hay extensión real y no es local - if (extName && extName !== 'local') { - await loadExtensionEpisodes(); - } else { - if (data.nextAiringEpisode?.episode) { - totalEpisodes = data.nextAiringEpisode.episode - 1; - } else if (data.episodes) { - totalEpisodes = data.episodes; - } else { - totalEpisodes = 12; - } - const simpleEpisodes = []; - for (let i = 1; i <= totalEpisodes; i++) { - simpleEpisodes.push({ number: i, title: null, thumbnail: null, isDub: false }); - } - populateEpisodeCarousel(simpleEpisodes); - } - - if (currentEpisode >= totalEpisodes && totalEpisodes > 0) { - document.getElementById('next-btn').disabled = true; - } - - } catch (error) { - console.error('Error loading metadata:', error); - } - await loadExtensions(); -} - -async function applyAniSkip(video) { - if (!isAnilist || !malId) return; - - aniSkipData = await loadAniSkip(malId, currentEpisode, Math.floor(video.duration)); - - if (!aniSkipData || aniSkipData.length === 0) return; - - const markers = []; - aniSkipData.forEach(item => { - const { startTime, endTime } = item.interval; - markers.push({ - start: startTime, - end: endTime, - label: item.skipType === 'op' ? 'Opening' : 'Ending' - }); - }); - - if (plyrInstance && markers.length > 0) { - setTimeout(() => { - const progressContainer = document.querySelector('.plyr__progress'); - if (!progressContainer) return; - - const oldMarkers = progressContainer.querySelector('.plyr__markers'); - if (oldMarkers) oldMarkers.remove(); - - const markersContainer = document.createElement('div'); - markersContainer.className = 'plyr__markers'; - - markers.forEach(marker => { - const markerElement = document.createElement('div'); - markerElement.className = 'plyr__marker'; - markerElement.dataset.label = marker.label; - - const startPercent = (marker.start / video.duration) * 100; - const widthPercent = ((marker.end - marker.start) / video.duration) * 100; - - markerElement.style.left = `${startPercent}%`; - markerElement.style.width = `${widthPercent}%`; - - markerElement.addEventListener('click', (e) => { - e.stopPropagation(); - video.currentTime = marker.start; - }); - - markersContainer.appendChild(markerElement); - }); - progressContainer.appendChild(markersContainer); - }, 500); - } -} - -async function loadExtensionEpisodes() { - try { - const res = await fetch(`/api/anime/${animeId}/episodes?source=${extName}`); - const data = await res.json(); - totalEpisodes = Array.isArray(data) ? data.length : 0; - populateEpisodeCarousel(Array.isArray(data) ? data : []); - } catch (e) { - console.error("Error cargando episodios:", e); - } -} - -function populateEpisodeCarousel(episodesData) { - const carousel = document.getElementById('episode-carousel'); - carousel.innerHTML = ''; - - episodesData.forEach((ep, index) => { - const epNumber = ep.number || ep.episodeNumber || ep.id || (index + 1); - if (!epNumber) return; - - const extParam = (extName && extName !== 'local') ? `?${extName}` : ""; - const hasThumbnail = ep.thumbnail && ep.thumbnail.trim() !== ''; - - const link = document.createElement('a'); - link.href = `/watch/${animeId}/${epNumber}${extParam}`; - link.classList.add('carousel-item'); - if (parseInt(epNumber) === currentEpisode) link.classList.add('active-ep-carousel'); - - const imgContainer = document.createElement('div'); - imgContainer.classList.add('carousel-item-img-container'); - - if (hasThumbnail) { - const img = document.createElement('img'); - img.src = ep.thumbnail; - img.classList.add('carousel-item-img'); - imgContainer.appendChild(img); - } - - link.appendChild(imgContainer); - const info = document.createElement('div'); - info.classList.add('carousel-item-info'); - info.innerHTML = `

Ep ${epNumber}: ${ep.title || 'Untitled'}

`; - link.appendChild(info); - carousel.appendChild(link); - }); -} - -async function loadExtensions() { - try { - const res = await fetch('/api/extensions/anime'); - const data = await res.json(); - const select = document.getElementById('extension-select'); - let extensions = data.extensions || []; - - if (extName === 'local' && !extensions.includes('local')) { - extensions.push('local'); - } - - select.innerHTML = ''; - extensions.forEach(ext => { - const opt = document.createElement('option'); - opt.value = opt.innerText = ext; - select.appendChild(opt); - }); - - if (extName && extensions.includes(extName)) { - select.value = extName; - } else if (extensions.length > 0) { - select.value = extensions[0]; - } - - currentExtension = select.value; - onExtensionChange(); - } catch (error) { - console.error("Extension Error:", error); - } -} - -async function onExtensionChange() { - const select = document.getElementById('extension-select'); - currentExtension = select.value; - - if (currentExtension === 'local') { - document.getElementById('sd-toggle').style.display = 'none'; - document.getElementById('server-select').style.display = 'none'; - loadStream(); - return; - } - - setLoading("Fetching extension settings..."); - try { - const res = await fetch(`/api/extensions/${currentExtension}/settings`); - const settings = await res.json(); - - const toggle = document.getElementById('sd-toggle'); - toggle.style.display = settings.supportsDub ? 'flex' : 'none'; - setAudioMode('sub'); - - const serverSelect = document.getElementById('server-select'); - serverSelect.innerHTML = ''; - if (settings.episodeServers?.length > 0) { - settings.episodeServers.forEach(srv => { - const opt = document.createElement('option'); - opt.value = opt.innerText = srv; - serverSelect.appendChild(opt); - }); - serverSelect.style.display = 'block'; - } else { - serverSelect.style.display = 'none'; - } - loadStream(); - } catch (error) { - setLoading("Failed to load settings."); - } -} - -async function loadStream() { - if (!currentExtension) return; - - if (currentExtension === 'local') { - console.log(localEntryId); - if (!localEntryId) { - setLoading("No existe en local"); - return; - } - - const localUrl = `/api/library/stream/anime/${localEntryId}/${currentEpisode}`; - playVideo(localUrl, []); - document.getElementById('loading-overlay').style.display = 'none'; - return; - } - - - const serverSelect = document.getElementById('server-select'); - const server = serverSelect.value || "default"; - setLoading(`Loading stream (${audioMode})...`); - - try { - const sourc = (extName && extName !== 'local') ? `&source=${extName}` : "&source=anilist"; - const url = `/api/watch/stream?animeId=${animeId}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}${sourc}`; - const res = await fetch(url); - const data = await res.json(); - - if (data.error || !data.videoSources?.length) { - setLoading(data.error || "No video sources."); - return; - } - - const source = data.videoSources.find(s => s.type === 'm3u8') || data.videoSources[0]; - const headers = data.headers || {}; - - let proxyUrl = `/api/proxy?url=${encodeURIComponent(source.url)}`; - if (headers['Referer']) proxyUrl += `&referer=${encodeURIComponent(headers['Referer'])}`; - if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`; - if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`; - - playVideo(proxyUrl, source.subtitles || data.subtitles || []); - document.getElementById('loading-overlay').style.display = 'none'; - } catch (error) { - setLoading("Stream error."); - } -} - -function playVideo(url, subtitles = []) { - const video = document.getElementById('player'); - const isLocal = url.includes('/api/library/stream/'); - - if (!isLocal && Hls.isSupported()) { - if (hlsInstance) hlsInstance.destroy(); - hlsInstance = new Hls({ xhrSetup: (xhr) => xhr.withCredentials = false }); - hlsInstance.loadSource(url); - hlsInstance.attachMedia(video); - } else { - if (hlsInstance) hlsInstance.destroy(); - video.src = url; - } - - if (plyrInstance) plyrInstance.destroy(); - while (video.textTracks.length > 0) video.removeChild(video.textTracks[0]); - - subtitles.forEach(sub => { - const track = document.createElement('track'); - track.kind = 'captions'; - track.label = sub.language || 'Unknown'; - track.srclang = (sub.language || '').slice(0, 2).toLowerCase(); - track.src = sub.url; - if (sub.default || sub.language?.toLowerCase().includes('english')) track.default = true; - video.appendChild(track); - }); - - plyrInstance = new Plyr(video, { - captions: { active: true, update: true, language: 'en' }, - controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'], - settings: ['captions', 'quality', 'speed'] - }); - - video.addEventListener('loadedmetadata', () => applyAniSkip(video)); - - // LÓGICA DE RPC (Discord) - let rpcActive = false; - video.addEventListener("play", () => { - if (!video.duration) return; - const elapsed = Math.floor(video.currentTime); - const start = Math.floor(Date.now() / 1000) - elapsed; - const end = start + Math.floor(video.duration); - sendRPC({ startTimestamp: start, endTimestamp: end }); - rpcActive = true; - }); - - video.addEventListener("pause", () => { - if (rpcActive) sendRPC({ paused: true }); - }); - - video.addEventListener("seeked", () => { - if (video.paused || !rpcActive) return; - const elapsed = Math.floor(video.currentTime); - const start = Math.floor(Date.now() / 1000) - elapsed; - const end = start + Math.floor(video.duration); - sendRPC({ startTimestamp: start, endTimestamp: end }); - }); -} - -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 - }) - }); -} - -async function sendProgress() { - const token = localStorage.getItem('token'); - if (!token) return; - const source = (extName && extName !== 'local') ? extName : "anilist"; - - const body = { - entry_id: animeId, - source: source, - entry_type: "ANIME", - status: 'CURRENT', - progress: currentEpisode +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; + + // Variables nuevas para RPC + let _animeTitle = "Anime"; + let _rpcActive = false; + + 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 }; - try { - await fetch('/api/list/entry', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(body) - }); - } catch (err) { - console.error('Error updating progress:', err); + function init(animeId, initialSource, isLocal, animeData) { + _animeId = animeId; + _entrySource = initialSource || 'anilist'; + _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"; + } + + _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.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); + + // 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'; + 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(); + + // 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)); + + loadExtensionsList(); } -} -// Botones y Toggle -document.getElementById('sd-toggle').onclick = () => { - audioMode = audioMode === 'sub' ? 'dub' : 'sub'; - setAudioMode(audioMode); - loadStream(); -}; + // --- FUNCIÓN RPC (Integrada desde player.js) --- + 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 setAudioMode(mode) { - const toggle = document.getElementById('sd-toggle'); - toggle.setAttribute('data-state', mode); - document.getElementById('opt-sub').classList.toggle('active', mode === 'sub'); - document.getElementById('opt-dub').classList.toggle('active', mode === 'dub'); -} + 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'); + } -function setLoading(message) { - document.getElementById('loading-text').innerText = message; - document.getElementById('loading-overlay').style.display = 'flex'; -} + 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; + } + } -const extParam = (extName && extName !== 'local') ? `?${extName}` : ""; -document.getElementById('prev-btn').onclick = () => { - if (currentEpisode > 1) window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`; -}; -document.getElementById('next-btn').onclick = () => { - if (currentEpisode < totalEpisodes || totalEpisodes === 0) window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`; -}; + function playEpisode(episodeNumber) { + const targetEp = parseInt(episodeNumber); + if (targetEp < 1 || targetEp > _totalEpisodes) return; -if (currentEpisode <= 1) document.getElementById('prev-btn').disabled = true; + _currentEpisode = targetEp; -// Actualizar progreso cada 1 minuto si el video está reproduciéndose -setInterval(() => { - if (plyrInstance && !plyrInstance.paused) sendProgress(); -}, 60000); + if(els.epTitle) els.epTitle.innerText = `Episode ${targetEp}`; + if(els.prevBtn) els.prevBtn.disabled = (_currentEpisode <= 1); + if(els.nextBtn) els.nextBtn.disabled = (_currentEpisode >= _totalEpisodes); -loadMetadata(); + 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":""}', '*'); + + // Reset RPC state on new episode + _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; + + // Enviar señal de pausa o limpieza al cerrar + 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..."); + + 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}`; + 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 || {}; + + 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)}` + })); + + initVideoPlayer(proxyUrl, source.type, subtitles); + } catch (err) { + setLoading("Stream Error: " + err.message); + } + } + + function initVideoPlayer(url, type, subtitles = []) { + 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); + 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, () => { + attachSubtitles(subtitles); + els.video.addEventListener('loadedmetadata', () => { + applyAniSkip(_malId, _currentEpisode); + }, { once: true }); + initPlyr(); + els.video.addEventListener('canplay', () => { + els.video.play().catch(() => {}); + }, { once: true }); + if (els.loader) els.loader.style.display = 'none'; + }); + hlsInstance.on(Hls.Events.ERROR, function (event, data) { + if (data.fatal) setLoading("Playback Error: " + data.details); + }); + } 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); + }); + } + + function initPlyr() { + // Asegurarnos de usar el elemento video actualizado + if (plyrInstance) return; + + 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: ['captions', 'quality', 'speed'] + }); + + 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('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 + }; +})(); \ No newline at end of file diff --git a/desktop/src/scripts/utils/continue-watching-manager.js b/desktop/src/scripts/utils/continue-watching-manager.js index dbdea0c..456d3ea 100644 --- a/desktop/src/scripts/utils/continue-watching-manager.js +++ b/desktop/src/scripts/utils/continue-watching-manager.js @@ -52,10 +52,9 @@ const ContinueWatchingManager = { if (entryType === 'ANIME') { url = item.source === 'anilist' - ? `/watch/${item.entry_id}/${nextProgress}` - : `/watch/${item.entry_id}/${nextProgress}?${item.source}`; + ? `/anime/${item.entry_id}?episode=${nextProgress}` + : `/anime/${item.entry_id}/${item.source}/?episode=${nextProgress}`; } else { - url = item.source === 'anilist' ? `/book/${item.entry_id}?chapter=${nextProgress}` : `/read/${item.source}/${nextProgress}/${item.entry_id}?source=${item.source}`; diff --git a/desktop/src/views/views.routes.ts b/desktop/src/views/views.routes.ts index 04b3b9a..529f5df 100644 --- a/desktop/src/views/views.routes.ts +++ b/desktop/src/views/views.routes.ts @@ -112,12 +112,6 @@ async function viewsRoutes(fastify: FastifyInstance) { reply.type('text/html').send(html); }); - fastify.get('/watch/:id/:episode', (req: FastifyRequest, reply: FastifyReply) => { - const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'watch.html'); - const html = fs.readFileSync(htmlPath, 'utf-8'); - reply.type('text/html').send(html); - }); - fastify.get('/book/:id', (req: FastifyRequest, reply: FastifyReply) => { const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'book.html'); const html = fs.readFileSync(htmlPath, 'utf-8'); diff --git a/desktop/views/anime/anime.html b/desktop/views/anime/anime.html index ae75aaa..343191d 100644 --- a/desktop/views/anime/anime.html +++ b/desktop/views/anime/anime.html @@ -1,17 +1,21 @@ - + - - - + + + WaifuBoard - - - - - - + + + + + + + + + +
@@ -20,137 +24,158 @@ WaifuBoard
- +
+ + + + + Back + - - - Back to Home - +
-
-
-
+
+
+
-
-
-
- - -
-
-

Loading...

- -
- - -
--% Score
-
----
-
Action
-
- -
- - -
-
- -
-
- -
-
-
-
-

Episodes

+ + +
+
+ +
+ Watching + Episode 1 +
-
- + +
+
+
+
+
Sub
+
Dub
+
+ + + +
-
- -
- - Page 1 of 1 - +
+ +
+
+

Loading Stream...

+
-
+
- +
+
+

Loading...

+
+ + + --% Score-------- Ep +
+
+
- - - +
+ + +
+
+ +
+ + +
+ +
+
+

Episodes

+ +
+
+ +
+ + Page 1 of 1 + +
+
+ + + +
+
+ +
+

Characters

+
+ +
+ +
+

Recommended

+ +
+
- - + + + + \ No newline at end of file diff --git a/desktop/views/anime/watch.html b/desktop/views/anime/watch.html deleted file mode 100644 index 77d87cc..0000000 --- a/desktop/views/anime/watch.html +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - WaifuBoard Watch - - - - - - - - - - - -
- - WaifuBoard -
-
- - - -
-
- - -
- - - - - Back to Series - -
- -
-
- -
-
-
- Anime Cover -
-
-

Loading...

-
- -- - -- - -- -
-
-

Loading description...

-
-
-
- -
- -
-
-
-
-
Sub
-
Dub
-
-
-
- - -
-
- -
- -
-
-

Select a source...

-
-
- -
-
-

Loading...

-

Episode --

-
- -
- - -
-
-
- - - - - - - - - - - \ No newline at end of file diff --git a/desktop/views/css/anime/anime.css b/desktop/views/css/anime/anime.css index 5e7b343..23e2676 100644 --- a/desktop/views/css/anime/anime.css +++ b/desktop/views/css/anime/anime.css @@ -1,297 +1,274 @@ +:root { + --bg-card: rgba(255, 255, 255, 0.04); + --border-subtle: rgba(255, 255, 255, 0.1); + --color-primary: #8b5cf6; + --player-height: 85vh; +} + +body { + background: #0b0b0b; + color: white; + + margin: 0; + padding: 0; + overflow-x: hidden; + width: 100%; + font-family: system-ui, -apple-system, sans-serif; +} + +.top-media-wrapper { + position: relative; + + width: 100vw; + left: 0; + background: #000; + transition: all 0.5s ease-in-out; +} + +.hero-wrapper { + position: relative; + width: 100%; + height: 100vh; + overflow: hidden; + z-index: 1; +} + .video-background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; + pointer-events: none; + overflow: hidden; +} +.video-background iframe, +.video-background #trailer-player { position: absolute; top: 50%; left: 50%; - transform: translate(-50%, -50%) scale(1.35); - width: 100%; - height: 100%; - pointer-events: none; - z-index: 0; - opacity: 0.6; + + width: 100vw; + height: 56.25vw; + min-height: 100vh; + min-width: 177.77vh; + + transform: translate(-50%, -50%); + +} + +.hero-overlay { + position: absolute; top: 0; left: 0; width: 100%; height: 100%; + background: linear-gradient(to bottom, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.8) 70%, #0b0b0b 100%); + z-index: 2; } .content-container { position: relative; z-index: 10; - max-width: 1600px; - margin: -350px auto 0 auto; + max-width: 1400px; + margin: -45vh auto 0 auto; padding: 0 3rem 4rem 3rem; - display: grid; - grid-template-columns: 280px 1fr; - gap: 3rem; - animation: slideUp 0.8s cubic-bezier(0.16, 1, 0.3, 1); + transition: margin-top 0.5s ease; } -.sidebar { - display: flex; - flex-direction: column; - gap: 2rem; +body.watch-mode .content-container { margin-top: 0; } +body.watch-mode .anime-header { margin-top: 2rem; } + +.anime-header { margin-bottom: 5rem; max-width: 900px; } +.anime-title { + font-size: clamp(2.5rem, 6vw, 4.5rem); font-weight: 900; + margin-bottom: 0.5rem; text-shadow: 0 4px 30px rgba(0,0,0,0.6); } +.hero-meta-info { + display: flex; align-items: center; gap: 0.8rem; + color: rgba(255,255,255,0.7); font-weight: 600; margin-bottom: 1.2rem; +} +.pill-local { + background: #22c55e; color: black; padding: 2px 8px; + border-radius: 4px; font-size: 0.75rem; font-weight: 900; +} +.hero-description-mini { + font-size: 1.05rem; line-height: 1.5; color: rgba(255,255,255,0.8); + margin-bottom: 1.2rem; max-width: 700px; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} +.hero-tags { + display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 2.5rem; + color: rgba(255,255,255,0.5); font-weight: 500; +} + +.action-row { display: flex; align-items: center; gap: 1rem; } +.btn-watch { + padding: 0.8rem 2.2rem; background: white; color: black; + border-radius: 8px; font-weight: 800; border: none; cursor: pointer; + display: flex; align-items: center; gap: 0.6rem; transition: 0.2s ease; +} +.btn-watch:hover { transform: scale(1.03); filter: brightness(0.9); } + +.btn-add-list { + padding: 0.8rem 1.5rem; background: rgba(255,255,255,0.1); + border: 1px solid rgba(255,255,255,0.2); color: white; + border-radius: 8px; font-weight: 700; cursor: pointer; transition: 0.2s; +} +.btn-add-list:hover { background: rgba(255,255,255,0.2); } + +.main-layout { display: grid; grid-template-columns: 300px 1fr; gap: 4rem; margin-top: 2rem; } +.content-section { margin-top: 4rem; } +h2, .subsection-title { font-size: 1.8rem; font-weight: 800; margin-bottom: 1.5rem; color: white; } .poster-card { - width: 100%; - aspect-ratio: 2/3; - border-radius: var(--radius-lg); - overflow: hidden; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8); - border: 1px solid rgba(255,255,255,0.1); + border-radius: 12px; overflow: hidden; + box-shadow: 0 30px 60px rgba(0,0,0,0.5); border: 1px solid var(--border-subtle); } +.poster-card img { width: 100%; height: auto; display: block; } -.poster-card img { - width: 100%; - height: 100%; - object-fit: cover; +.relations-horizontal { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; } +.relation-card-horizontal { + display: flex; background: var(--bg-card); border: 1px solid var(--border-subtle); + border-radius: 10px; overflow: hidden; transition: 0.2s; cursor: pointer; } - -.info-grid { - background: var(--color-bg-elevated); - border: 1px solid rgba(255,255,255,0.05); - border-radius: var(--radius-md); - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1.25rem; +.relation-card-horizontal:hover { background: rgba(255,255,255,0.08); transform: translateX(5px); } +.rel-img { width: 85px; height: 110px; object-fit: cover; } +.rel-info { padding: 1rem; display: flex; flex-direction: column; justify-content: center; } +.rel-type { + font-size: 0.7rem; color: var(--color-primary); font-weight: 800; + margin-bottom: 4px; background: rgba(139, 92, 246, 0.1); width: fit-content; padding: 2px 6px; border-radius: 4px; } +.rel-title { font-size: 0.95rem; font-weight: 700; color: #eee; } -.info-item h4 { margin: 0 0 0.25rem 0; font-size: 0.85rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.5px; } -.info-item span { font-weight: 600; font-size: 1rem; color: var(--color-text-primary); } +.characters-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; } +.character-item { display: flex; align-items: center; gap: 1rem; } +.char-avatar { width: 60px; height: 60px; border-radius: 10px; overflow: hidden; flex-shrink: 0; } +.char-avatar img { width: 100%; height: 100%; object-fit: cover; } +.char-info { display: flex; flex-direction: column; gap: 2px; } +.char-name { font-size: 1rem; font-weight: 700; color: #fff; } +.char-role { font-size: 0.8rem; color: #888; font-weight: 500; } +.btn-show-more { background: transparent; border: 1px solid var(--border-subtle); color: #aaa; padding: 10px; width: 100%; margin-top: 1rem; cursor: pointer; } -.character-list { - display: flex; - flex-direction: column; - gap: 0.75rem; +.episodes-section { margin-top: 3rem; } +.episodes-header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; } +.episode-search-input { + background: rgba(255,255,255,0.05); border: 1px solid var(--border-subtle); + color: white; padding: 8px 15px; border-radius: 8px; width: 100px; } -.character-item { display: flex; align-items: center; gap: 0.75rem; font-size: 0.95rem; } -.char-dot { width: 6px; height: 6px; background: var(--color-primary); border-radius: 50%; } - -.main-content { - display: flex; - flex-direction: column; - justify-content: flex-end; -} - -.anime-header { - margin-bottom: 2rem; -} - -.anime-title { - font-size: 4rem; - font-weight: 900; - line-height: 1; - margin: 0 0 1.5rem 0; - text-shadow: 0 4px 30px rgba(0,0,0,0.8); -} - -.meta-row { - display: flex; - align-items: center; - gap: 1rem; - margin-bottom: 1.5rem; - flex-wrap: wrap; -} - -.pill { - padding: 0.5rem 1.25rem; - background: rgba(255,255,255,0.1); - backdrop-filter: blur(10px); - border: 1px solid rgba(255,255,255,0.1); - border-radius: var(--radius-full); - font-weight: 600; - font-size: 0.95rem; -} -.pill.score { background: rgba(34, 197, 94, 0.2); color: #4ade80; border-color: rgba(34, 197, 94, 0.2); } - -.action-row { - display: flex; - gap: 1rem; - margin-top: 1rem; -} - -.btn-watch { - padding: 1rem 3rem; - background: var(--color-text-primary); - color: var(--color-bg-base); - border-radius: var(--radius-full); - font-weight: 800; - font-size: 1.1rem; - border: none; - cursor: pointer; - display: flex; - align-items: center; - gap: 0.75rem; - transition: transform 0.2s, box-shadow 0.2s; -} - -.btn-watch:hover { - transform: scale(1.05); - box-shadow: 0 0 30px rgba(255, 255, 255, 0.25); -} - -.btn-secondary { - padding: 1rem 2rem; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(10px); - color: white; - border-radius: var(--radius-full); - font-weight: 700; - font-size: 1rem; - border: 1px solid rgba(255,255,255,0.2); - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: center; - gap: 0.5rem; -} -.btn-secondary:hover { - background: rgba(255, 255, 255, 0.2); - transform: scale(1.05); -} - -.description-box { - margin-top: 3rem; - font-size: 1.15rem; - line-height: 1.8; - color: #e4e4e7; - max-width: 900px; - background: rgba(255,255,255,0.03); - padding: 2rem; - border-radius: var(--radius-md); - border: 1px solid rgba(255,255,255,0.05); -} - -.episodes-section { - margin-top: 4rem; -} -.section-title { font-size: 1.8rem; font-weight: 800; margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.8rem; } -.section-title::before { content: ''; width: 4px; height: 28px; background: var(--color-primary); border-radius: 2px; } - .episodes-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 1rem; -} + grid-template-columns: repeat(auto-fill, minmax(55px, 1fr)); + gap: 0.6rem; +} .episode-btn { - background: var(--color-bg-elevated); - border: 1px solid rgba(255,255,255,0.1); - padding: 1.25rem 1rem; - border-radius: var(--radius-md); - cursor: pointer; - transition: 0.2s; + background: rgba(255,255,255,0.03); + border: 1px solid var(--border-subtle); + padding: 0.6rem 0; + border-radius: 6px; text-align: center; + font-size: 0.9rem; font-weight: 600; - color: var(--color-text-secondary); -} - -.episode-btn:hover { - background: var(--color-bg-elevated-hover); - color: white; - transform: translateY(-3px); - border-color: var(--color-primary); -} - -@keyframes slideUp { - from { opacity: 0; transform: translateY(60px); } - to { opacity: 1; transform: translateY(0); } -} - -@media (max-width: 1024px) { - .content-container { - grid-template-columns: 1fr; - margin-top: -100px; - padding: 0 1.5rem 4rem 1.5rem; - } - .poster-card { width: 220px; margin: 0 auto; box-shadow: 0 10px 30px rgba(0,0,0,0.5); } - .main-content { text-align: center; align-items: center; } - .anime-title { font-size: 2.5rem; } - .meta-row { justify-content: center; } - .sidebar { display: none; } -} - -.read-more-btn { - background: none; - border: none; - color: #8b5cf6; cursor: pointer; - font-weight: 600; - padding: 0; - margin-top: 0.5rem; - font-size: 0.95rem; - display: inline-flex; - align-items: center; - gap: 0.25rem; + transition: all 0.2s; + color: #ccc; +} +.episode-btn:hover { + background: white; + color: black; + transform: translateY(-2px); +} +.episode-btn.active-playing { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); + box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3); } -.read-more-btn:hover { text-decoration: underline; } -.episodes-header-row { +.metadata-sidebar { + margin-top: 2rem; + background: rgba(255,255,255,0.03); + padding: 1.5rem; + border-radius: 16px; + border: 1px solid rgba(255,255,255,0.08); + display: flex; + flex-direction: column; + gap: 1.2rem; +} +.meta-item-side { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; - flex-wrap: wrap; - gap: 1rem; + border-bottom: 1px solid rgba(255,255,255,0.05); + padding-bottom: 0.8rem; } -.episodes-header-row h2 { +.meta-item-side:last-child { border-bottom: none; padding-bottom: 0; } +.meta-item-side span { + font-size: 0.8rem; + color: #888; + font-weight: 600; +} +.meta-item-side p { + font-size: 0.95rem; + font-weight: 500; + color: #fff; margin: 0; - font-size: 1.8rem; - border-left: 4px solid #8b5cf6; - padding-left: 1rem; -} -.episode-search-wrapper { - position: relative; - display: flex; - align-items: center; -} -.episode-search-input { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 99px; - padding: 0.6rem 1rem; - color: white; - width: 140px; - text-align: center; - font-family: inherit; - transition: 0.2s; - -moz-appearance: textfield; -} -.episode-search-input:focus { - border-color: #8b5cf6; - background: rgba(255, 255, 255, 0.1); - outline: none; + text-align: right; } -.episode-search-input::-webkit-outer-spin-button, -.episode-search-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +@media (max-width: 1024px) { + .content-container { margin-top: -200px; padding: 0 1.5rem; } + .main-layout { grid-template-columns: 1fr; gap: 2rem; } + .poster-section { display: flex; flex-direction: column; align-items: center; } + .poster-card { width: 220px; } + .metadata-sidebar { width: 100%; max-width: 400px; } +} + +.relation-card-horizontal.no-link { + cursor: default; + opacity: 0.7; +} +.relation-card-horizontal.no-link:hover { + transform: none; + background: var(--bg-card); +} .pagination-controls { display: flex; justify-content: center; align-items: center; - gap: 1rem; + gap: 1.5rem; margin-top: 2rem; - padding-top: 1rem; + padding-top: 1.5rem; border-top: 1px solid rgba(255, 255, 255, 0.05); } + +.page-info { + font-size: 0.9rem; + font-weight: 700; + color: #888; + min-width: 100px; + text-align: center; +} + .page-btn { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.2); color: white; - padding: 0.5rem 1rem; + padding: 0.6rem 1.2rem; border-radius: 8px; + font-size: 0.85rem; + font-weight: 600; cursor: pointer; - transition: 0.2s; - font-weight: 500; + transition: all 0.2s ease; } + .page-btn:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.15); - border-color: #8b5cf6; + background: white; + color: black; + border-color: white; } + .page-btn:disabled { opacity: 0.4; cursor: not-allowed; -} -.page-info { - color: #a1a1aa; - font-size: 0.9rem; - font-weight: 500; + border-color: rgba(255, 255, 255, 0.1); } \ No newline at end of file diff --git a/desktop/views/css/anime/player.css b/desktop/views/css/anime/player.css new file mode 100644 index 0000000..27215b6 --- /dev/null +++ b/desktop/views/css/anime/player.css @@ -0,0 +1,507 @@ +:root { + --brand-color: #8b5cf6; + --brand-color-light: #a78bfa; + --op-color: #fbbf24; + --ed-color: #38bdf8; + --overlay-gradient-top: linear-gradient(to bottom, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 50%, transparent 100%); + --overlay-gradient-bottom: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 40%, transparent 100%); +} + +body.stop-scrolling { + overflow: hidden !important; + height: 100vh; +} + +.player-wrapper { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #000; + z-index: 9999; + display: none; + overflow: hidden; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + color: #fff; +} + +.player-wrapper * { + box-sizing: border-box; +} + +.player-container { + width: 100%; + height: 100%; + position: relative; + display: flex; + flex-direction: column; + + margin: 0; + padding: 0; +} +.video-frame { + + flex: 1; + position: relative; + width: 100%; + height: 100%; + background: #000; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.video-frame video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.video-frame .plyr { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; +} + +.player-header { + position: absolute; + top: 0; + left: 0; + width: 100%; + padding: 20px 30px; + display: flex; + justify-content: space-between; + align-items: center; + z-index: 20; + background: linear-gradient(to bottom, rgba(0,0,0,0.8) 0%, transparent 100%); + pointer-events: none; /* Permite clickear el video a través del header vacío */ + transition: opacity 0.3s ease; +} + +.header-left, .header-right { + pointer-events: auto; + display: flex; + align-items: center; + gap: 15px; +} + +.btn-icon-glass { + appearance: none; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255,255,255,0.1); + color: #fff; + padding: 10px; + border-radius: 20%; + cursor: pointer; + backdrop-filter: blur(4px); + transition: all 0.2s; +} + +.btn-icon-glass:hover { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.3); + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + transform: translateY(-1px) scale(1.05); +} + +.player-container:hover .player-header, +.player-container.paused .player-header { + opacity: 1; + pointer-events: all; +} + +.player-meta { + display: flex; flex-direction: column; gap: 0.5rem; +} + +.btn-close-player { + background: transparent; + border: none; + color: rgba(255,255,255,0.7); + display: flex; align-items: center; gap: 10px; + font-size: 1.1rem; font-weight: 500; + cursor: pointer; padding: 0; + transition: color 0.2s; +} +.btn-close-player:hover { color: #fff; } +.btn-close-player svg { width: 28px; height: 28px; } + +.episode-info { + display: flex; + flex-direction: column; +} +.ep-label { + font-size: 0.75rem; + text-transform: uppercase; + color: rgba(255,255,255,0.7); + letter-spacing: 1px; +} +.ep-title { + font-size: 1.1rem; + font-weight: 700; + color: white; + text-shadow: 0 2px 4px rgba(0,0,0,0.5); +} + +.nav-capsule { + display: flex; + align-items: center; + background: rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 20px; /* Forma de pastilla */ + overflow: hidden; + backdrop-filter: blur(10px); +} + +.nav-btn { + background: transparent; + border: none; + color: white; + padding: 8px 16px; + cursor: pointer; + transition: background 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.nav-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.2); +} + +.nav-btn:disabled { + opacity: 0.3; + cursor: default; +} + +.nav-capsule .divider { + width: 1px; + height: 20px; + background: rgba(255, 255, 255, 0.2); +} + +#skip-overlay-btn { + position: absolute; + bottom: 80px; /* Encima de la barra de Plyr */ + right: 30px; + background: white; + color: black; + padding: 10px 24px; + border-radius: 6px; + font-weight: 700; + font-size: 0.95rem; + border: none; + cursor: pointer; + z-index: 2147483647 !important; /* Siempre encima de todo */ + display: flex; + align-items: center; + gap: 10px; + box-shadow: 0 4px 15px rgba(0,0,0,0.3); + + opacity: 0; + transform: translateY(20px); + transition: opacity 0.3s, transform 0.3s, background 0.2s; + pointer-events: none; + +} + +#skip-overlay-btn.visible { + opacity: 1 !important; + pointer-events: auto !important; + cursor: pointer !important; + visibility: visible !important; + display: flex !important; +} + +#skip-overlay-btn:hover { + background: #f0f0f0; + transform: translateY(-2px); +} + +#skip-overlay-btn.is-next { + background: #8b5cf6; + color: white; +} +#skip-overlay-btn.is-next:hover { + background: #7c3aed; +} + + +/* --- BOTONES DE NAVEGACIÓN LATERALES (Side Chevrons) --- */ +.side-nav-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(0, 0, 0, 0.5); + color: rgba(255, 255, 255, 0.8); + border: none; + width: 60px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 30; /* Encima del video */ + transition: all 0.3s ease; + opacity: 0; /* Invisibles por defecto */ +} + +/* Bordes redondeados según el lado */ +.side-nav-btn.left { + left: 0; + border-radius: 0 10px 10px 0; +} + +.side-nav-btn.right { + right: 0; + border-radius: 10px 0 0 10px; +} + +/* Mostrar solo cuando el mouse está sobre el reproductor */ +.player-container:hover .side-nav-btn { + opacity: 1; +} + +/* Efecto Hover sobre el botón */ +.side-nav-btn:hover:not(:disabled) { + background: rgba(139, 92, 246, 0.6); /* Tu color brand morado pero transparente */ + color: white; + width: 70px; /* Crecen un poco */ +} + +/* Estado deshabilitado (Primer/Último episodio) */ +.side-nav-btn:disabled { + cursor: default; + opacity: 0 !important; /* Totalmente oculto si no se puede usar */ + pointer-events: none; +} +#player-episode-title { + font-size: 1.5rem; font-weight: 700; + text-shadow: 0 2px 10px rgba(0,0,0,0.5); + margin-left: 2px; +} + +.player-controls-top { + display: flex; gap: 1rem; align-items: center; +} + +.glass-select { + appearance: none; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255,255,255,0.1); + color: #fff; + padding: 8px 16px; + border-radius: 4px; + font-size: 0.85rem; font-weight: 600; + cursor: pointer; + backdrop-filter: blur(4px); + transition: all 0.2s; +} +.glass-select:hover { background: rgba(255, 255, 255, 0.2); } +.glass-select option { background: #111; color: #ccc; } + +.sd-toggle { + background: rgba(255,255,255,0.1); + border-radius: 4px; + display: flex; + padding: 2px; + cursor: pointer; + border: 1px solid rgba(255,255,255,0.1); +} +.sd-option { + padding: 4px 12px; + font-size: 0.75rem; font-weight: 700; + color: rgba(255,255,255,0.5); + border-radius: 2px; + transition: all 0.2s; +} +.sd-option.active { + background: #fff; + color: #000; +} + +.btn-skip-intro { + position: absolute; + bottom: 120px; right: 40px; + background: #fff; + color: #000; + border: none; + padding: 10px 24px; + font-weight: 700; font-size: 0.9rem; + border-radius: 4px; + cursor: pointer; + z-index: 50; + display: flex; align-items: center; gap: 8px; + opacity: 0; transform: translateY(20px); + transition: all 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); + box-shadow: 0 4px 12px rgba(0,0,0,0.3); +} +.btn-skip-intro.visible { + opacity: 1; transform: translateY(0); +} +.btn-skip-intro:hover { + background: #e6e6e6; + transform: scale(1.02); +} + +:root { + --plyr-color-main: var(--brand-color); + --plyr-video-control-color: #fff; + --plyr-video-control-background-hover: transparent; + --plyr-control-icon-size: 20px; + --plyr-range-track-height: 4px; + --plyr-range-thumb-height: 14px; + --plyr-range-thumb-background: #fff; + --plyr-menu-background: rgba(20, 20, 20, 0.95); + --plyr-menu-color: #fff; + --plyr-tooltip-background: rgba(255,255,255,0.9); + --plyr-tooltip-color: #000; +} + +.plyr--video .plyr__controls { + padding-bottom: max(20px, env(safe-area-inset-bottom)); +} + +.plyr__progress__container:hover .plyr__progress input[type=range], +.plyr__progress__container:hover .plyr__progress__buffer { + height: 8px; + transition: height 0.1s ease; +} + +.plyr__progress { + position: relative; + cursor: pointer; +} + +.skip-marker { + position: absolute; + top: 50%; + transform: translateY(-50%); + height: 4px; + z-index: 3; + pointer-events: none; + transition: height 0.1s ease; + + background-color: rgba(255, 255, 255, 0.3); + + border-left: 2px solid rgba(0,0,0,0); + border-right: 2px solid rgba(0,0,0,0); + background-clip: padding-box; +} + +.plyr__progress__container:hover .skip-marker { + height: 8px; +} + +.skip-marker.op { + background-color: var(--op-color); + box-shadow: 0 0 10px rgba(251, 191, 36, 0.3); +} + +.skip-marker.ed { + background-color: var(--ed-color); +} + +.player-loading-overlay { + background: #000; +} +.spinner { + border-width: 2px; + width: 50px; height: 50px; + border-top-color: #fff; + border-right-color: rgba(255,255,255,0.1); + border-bottom-color: rgba(255,255,255,0.1); + border-left-color: rgba(255,255,255,0.1); +} + +.settings-group { + display: flex; + align-items: center; + gap: 12px; /* Espacio entre los elementos */ +} + +/* Opcional: Ajuste para pantallas móviles muy pequeñas */ +@media (max-width: 600px) { + .settings-group { + gap: 8px; + } + .glass-select { + padding: 6px 10px; + font-size: 0.75rem; + } +} + +.player-container:fullscreen, +.player-container:-webkit-full-screen { + width: 100vw; + height: 100vh; + background: #000; + position: fixed; + inset: 0; + z-index: 99999; +} + +/* Controles custom SIEMPRE visibles encima */ +.player-container:fullscreen .player-header, +.player-container:fullscreen .side-nav-btn, +.player-container:fullscreen #skip-overlay-btn, +.player-container:-webkit-full-screen .player-header, +.player-container:-webkit-full-screen .side-nav-btn, +.player-container:-webkit-full-screen #skip-overlay-btn { + z-index: 2147483647 !important; + visibility: visible; +} + +/* Posición correcta del botón Skip en fullscreen */ +.player-container:fullscreen #skip-overlay-btn, +.player-container:-webkit-full-screen #skip-overlay-btn { + bottom: 100px; + right: 50px; +} + +/* ================= UI HIDDEN (UNA SOLA FUENTE DE VERDAD) ================= */ + +.player-container.ui-hidden .player-header, +.player-container.ui-hidden .side-nav-btn { + opacity: 0; + pointer-events: none; + cursor: none; +} +#skip-overlay-btn { + opacity: 0; + pointer-events: none; +} + +#skip-overlay-btn.visible { + opacity: 1 !important; + pointer-events: auto !important; + visibility: visible !important; +} + +.player-container.ui-hidden #skip-overlay-btn.visible { + opacity: 1 !important; + pointer-events: auto !important; + cursor: pointer !important; + visibility: visible !important; +} +.player-header, +.side-nav-btn, +#skip-overlay-btn { + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.player-container:fullscreen #skip-overlay-btn, +.player-container:-webkit-full-screen #skip-overlay-btn { + z-index: 2147483647 !important; + pointer-events: auto !important; +} +.player-container:fullscreen #skip-overlay-btn.visible, +.player-container:-webkit-full-screen #skip-overlay-btn.visible { + opacity: 1 !important; + visibility: visible !important; + pointer-events: auto !important; +} \ No newline at end of file diff --git a/desktop/views/css/anime/watch.css b/desktop/views/css/anime/watch.css deleted file mode 100644 index ecd6081..0000000 --- a/desktop/views/css/anime/watch.css +++ /dev/null @@ -1,830 +0,0 @@ -.top-bar { - position: fixed; - top: 0; - left: 0; - right: 0; - padding: var(--spacing-lg) var(--spacing-xl); - background: linear-gradient( - 180deg, - rgba(0, 0, 0, 0.8) 0%, - transparent 100% - ); - z-index: 1000; - pointer-events: none; -} - -.back-btn { - pointer-events: auto; - display: inline-flex; - align-items: center; - gap: var(--spacing-sm); - padding: 0.7rem 1.5rem; - background: var(--glass-bg); - backdrop-filter: blur(16px); - border: 1px solid var(--glass-border); - border-radius: var(--radius-full); - color: var(--color-text-primary); - text-decoration: none; - font-weight: 600; - font-size: 0.9rem; - transition: all var(--transition-smooth); - box-shadow: var(--shadow-sm); -} - -.back-btn:hover { - background: rgba(255, 255, 255, 0.12); - border-color: var(--color-primary); - transform: translateY(-2px); - box-shadow: var(--shadow-glow); -} - -.watch-container { - max-width: 1600px; - margin: var(--spacing-2xl) auto; - padding: 0 var(--spacing-xl); - display: flex; - flex-direction: column; - gap: var(--spacing-xl); - align-items: center; -} - -.player-section { - width: 100%; - display: flex; - flex-direction: column; - gap: var(--spacing-lg); -} - -.player-toolbar { - display: flex; - align-items: center; - gap: var(--spacing-md); - flex-wrap: wrap; - background: var(--glass-bg); - backdrop-filter: blur(16px); - border: 1px solid var(--glass-border); - border-radius: var(--radius-lg); - padding: var(--spacing-md); - box-shadow: var(--shadow-sm); -} - -.control-group { - display: flex; - align-items: center; - gap: var(--spacing-md); -} - -.sd-toggle { - display: flex; - background: var(--color-bg-elevated); - border: var(--border-subtle); - border-radius: var(--radius-full); - padding: 4px; - position: relative; - cursor: pointer; -} - -.sd-option { - padding: 0.6rem 1.5rem; - font-size: 0.875rem; - font-weight: 700; - color: var(--color-text-muted); - z-index: 2; - transition: color var(--transition-base); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.sd-option.active { - color: var(--color-text-primary); -} - -.sd-bg { - position: absolute; - top: 4px; - left: 4px; - bottom: 4px; - width: calc(50% - 4px); - background: var(--color-primary); - border-radius: var(--radius-full); - transition: transform var(--transition-smooth); - box-shadow: 0 4px 12px var(--color-primary-glow); - z-index: 1; -} - -.sd-toggle[data-state="dub"] .sd-bg { - transform: translateX(100%); -} - -.source-select { - appearance: none; - background-color: var(--color-bg-elevated); - background-image: url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 1.2rem center; - border: var(--border-subtle); - color: var(--color-text-primary); - padding: 0.7rem 2.8rem 0.7rem 1.2rem; - border-radius: var(--radius-full); - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - min-width: 160px; - transition: all var(--transition-base); -} - -.source-select:hover { - border-color: var(--color-primary); - background-color: var(--color-bg-card); -} -.source-select:focus { - outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 3px var(--color-primary-glow); -} - -.video-container { - aspect-ratio: 16/9; - width: 100%; - background: var(--color-bg-base); - border-radius: var(--radius-xl); - overflow: hidden; - box-shadow: - var(--shadow-lg), - 0 0 0 1px var(--glass-border); - position: relative; - transition: box-shadow var(--transition-smooth); -} - -.video-container:hover { - box-shadow: - var(--shadow-lg), - 0 0 0 1px var(--color-primary), - var(--shadow-glow); -} - -#player { - width: 100%; - height: 100%; - object-fit: contain; -} - -.loading-overlay { - position: absolute; - inset: 0; - background: var(--color-bg-base); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - z-index: 20; - gap: var(--spacing-lg); -} - -.spinner { - width: 48px; - height: 48px; - border: 3px solid rgba(255, 255, 255, 0.1); - border-top-color: var(--color-primary); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.loading-overlay p { - color: var(--color-text-secondary); - font-size: 0.95rem; - font-weight: 500; -} - -.episode-controls { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: var(--spacing-lg); - background: var(--glass-bg); - backdrop-filter: blur(16px); - border: 1px solid var(--glass-border); - border-radius: var(--radius-lg); - padding: var(--spacing-lg); - box-shadow: var(--shadow-sm); -} - -.episode-info h1 { - font-size: 1.75rem; - font-weight: 800; - margin: 0 0 var(--spacing-xs); -} -.episode-info p { - color: var(--color-primary); - font-weight: 600; - font-size: 1rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.navigation-buttons { - display: flex; - gap: var(--spacing-md); -} - -.nav-btn { - display: flex; - align-items: center; - gap: var(--spacing-sm); - background: var(--color-bg-elevated); - border: var(--border-subtle); - color: var(--color-text-primary); - padding: 0.75rem 1.5rem; - border-radius: var(--radius-full); - font-weight: 600; - font-size: 0.9rem; - cursor: pointer; - transition: all var(--transition-base); -} - -.nav-btn:hover:not(:disabled) { - background: var(--color-primary); - border-color: var(--color-primary); - transform: translateY(-2px); - box-shadow: var(--shadow-glow); -} -.nav-btn:disabled { - opacity: 0.3; - cursor: not-allowed; -} - -.episode-carousel-compact { - width: 100%; - max-width: 1600px; - margin-top: var(--spacing-lg); - padding: 0; - background: transparent; - border-radius: var(--radius-lg); - overflow: hidden; -} - -.carousel-header { - margin-bottom: var(--spacing-lg); - padding: 0 var(--spacing-xl); - display: flex; - justify-content: space-between; - align-items: center; -} - -.carousel-header h2 { - font-size: 1.6rem; - font-weight: 900; - color: var(--color-text-primary); - letter-spacing: -0.04em; - border-left: 4px solid var(--color-primary); - padding-left: var(--spacing-md); -} - -.carousel-nav { - display: flex; - gap: var(--spacing-xs); -} - -.carousel-arrow-mini { - display: flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - background: var(--color-bg-elevated); - border: var(--border-subtle); - border-radius: var(--radius-full); - color: var(--color-text-secondary); - cursor: pointer; - transition: all var(--transition-fast); -} - -.carousel-arrow-mini:hover { - background: var(--color-primary); - border-color: var(--color-primary); - color: var(--color-text-primary); - box-shadow: var(--shadow-sm); -} - -.carousel-arrow-mini[style*="opacity: 0.3"] { - background: var(--color-bg-elevated); - color: var(--color-text-muted); - border-color: var(--border-subtle); - box-shadow: none; -} - -.episode-carousel-compact-list { - display: flex; - gap: var(--spacing-md); - padding: var(--spacing-sm) var(--spacing-xl); - overflow-x: auto; - scroll-snap-type: x mandatory; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - mask-image: linear-gradient( - to right, - transparent, - black var(--spacing-md), - black calc(100% - var(--spacing-md)), - transparent - ); -} - -.episode-carousel-compact-list::-webkit-scrollbar { - display: none; -} - -.carousel-item { - flex: 0 0 200px; - height: 112px; - - background: var(--color-bg-card); - border: 2px solid var(--border-subtle); - border-radius: var(--radius-md); - overflow: hidden; - position: relative; - transition: all var(--transition-base); - text-decoration: none; - display: flex; - flex-direction: column; - - scroll-snap-align: start; - box-shadow: var(--shadow-sm); -} - -.carousel-item:hover { - border-color: var(--color-primary); - transform: scale(1.02); - box-shadow: var(--shadow-md), var(--shadow-glow); -} - -.carousel-item.active-ep-carousel { - border-color: var(--color-primary); - background: rgba(139, 92, 246, 0.15); - box-shadow: - 0 0 0 2px var(--color-primary), - var(--shadow-md); - transform: scale(1.02); -} - -.carousel-item.active-ep-carousel::after { - content: "WATCHING"; - position: absolute; - top: 0; - right: 0; - background: var(--color-primary); - color: var(--color-text-primary); - padding: 2px 8px; - font-size: 0.7rem; - font-weight: 800; - border-bottom-left-radius: var(--radius-sm); - letter-spacing: 0.05em; - z-index: 10; -} - -.carousel-item-img-container { - height: 70px; - background: var(--color-bg-elevated); - overflow: hidden; - position: relative; -} - -.carousel-item-img { - width: 100%; - height: 100%; - object-fit: cover; - transition: transform var(--transition-smooth); - opacity: 0.8; -} - -.carousel-item:hover .carousel-item-img { - transform: scale(1.1); - opacity: 1; -} - -.carousel-item-info { - flex: 1; - padding: var(--spacing-xs) var(--spacing-sm); - display: flex; - align-items: center; - justify-content: flex-start; - background: var(--color-bg-elevated); -} - -.carousel-item-info p { - font-size: 1rem; - font-weight: 600; - color: var(--color-text-primary); - margin: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - letter-spacing: 0; - line-height: 1.2; -} - -.carousel-item-info p::before { - content: attr(data-episode-number); - color: var(--color-primary); - font-weight: 800; - margin-right: var(--spacing-xs); - opacity: 0.7; -} - -.carousel-item.no-thumbnail { - flex: 0 0 160px; - height: 90px; - background: var(--color-bg-elevated); - border: 2px solid var(--border-subtle); - display: flex; - align-items: center; - justify-content: center; - flex-direction: row; -} - -.carousel-item.no-thumbnail .carousel-item-info { - padding: var(--spacing-sm); - background: transparent; - justify-content: center; -} - -.carousel-item.no-thumbnail .carousel-item-info p { - color: var(--color-text-secondary); - font-size: 1.05rem; - font-weight: 700; - text-align: center; -} - -.carousel-item.no-thumbnail:hover { - background: rgba(139, 92, 246, 0.12); - border-color: var(--color-primary); -} - -.carousel-item.no-thumbnail.active-ep-carousel .carousel-item-info p { - color: var(--color-primary); -} - -.anime-details, -.anime-extra-content { - max-width: 1600px; - margin: var(--spacing-2xl) auto; -} - -.details-container { - display: flex; - flex-direction: row; - gap: var(--spacing-xl); - background: var(--glass-bg); - backdrop-filter: blur(16px); - border: 1px solid var(--glass-border); - border-radius: var(--radius-lg); - padding: var(--spacing-xl); - box-shadow: var(--shadow-md); -} - -.details-cover { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--spacing-md); - flex-shrink: 0; -} - -.details-cover h1 { - font-size: 2.5rem; - font-weight: 900; - color: var(--color-text-primary); - line-height: 1.2; - margin: 0 0 var(--spacing-md) 0; - text-align: left; -} - -.cover-image { - width: 220px; - border-radius: var(--radius-md); - box-shadow: var(--shadow-lg); -} - -.details-content h1 { - font-size: 1.5rem; - font-weight: 800; - margin-bottom: var(--spacing-md); -} - -.meta-badge { - background: rgba(139, 92, 246, 0.12); - color: var(--color-primary); - padding: 0.5rem 1rem; - border-radius: var(--radius-sm); - font-size: 0.875rem; - font-weight: 600; - border: 1px solid rgba(139, 92, 246, 0.2); -} - -.meta-badge.meta-score { - background: var(--color-primary); - color: white; -} -.details-description { - font-size: 1rem; - line-height: 1.7; - color: var(--color-text-secondary); -} - -.characters-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--spacing-xl); -} - -.characters-header h2 { - font-size: 1.75rem; - font-weight: 800; - color: var(--color-text-primary); - border-left: 5px solid var(--color-primary); - padding-left: var(--spacing-md); -} - -.expand-btn { - display: flex; - align-items: center; - gap: var(--spacing-xs); - background: transparent; - border: none; - color: var(--color-primary); - font-weight: 600; - cursor: pointer; - font-size: 1rem; - padding: var(--spacing-xs); - border-radius: var(--radius-sm); -} - -.expand-btn:hover { - background: rgba(139, 92, 246, 0.1); -} -.expand-btn svg { - transition: transform var(--transition-smooth); -} -.expand-btn[data-expanded="true"] svg { - transform: rotate(180deg); -} - -.characters-carousel { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-lg); - align-content: flex-start; - overflow: hidden; - - height: 208px; - transition: height 0.55s cubic-bezier(0.4, 0, 0.2, 1); - padding: 0 var(--spacing-sm); - - -ms-overflow-style: none; - scrollbar-width: none; -} - -.characters-carousel::-webkit-scrollbar { - display: none; -} - -.characters-carousel.expanded { - height: auto; - max-height: 3200px; - overflow-y: auto; - overflow-x: hidden; - padding: 0; - - -ms-overflow-style: auto; - scrollbar-width: thin; -} - -.characters-carousel.expanded::-webkit-scrollbar { - width: 6px; -} - -.characters-carousel.expanded::-webkit-scrollbar-thumb { - background: rgba(139, 92, 246, 0.4); - border-radius: 3px; -} - -.characters-carousel.expanded::-webkit-scrollbar-track { - background: transparent; -} - -.plyr--video { - border-radius: var(--radius-xl); -} -.plyr__controls { - background: linear-gradient( - to top, - rgba(0, 0, 0, 0.9) 0%, - rgba(0, 0, 0, 0.5) 50%, - transparent 100% - ) !important; - padding: 1rem 1.5rem 1.5rem !important; -} -.plyr--full-ui input[type="range"] { - color: var(--color-primary); -} -.plyr__control:hover { - background: rgba(255, 255, 255, 0.12) !important; -} -.plyr__menu__container { - background: var(--glass-bg) !important; - backdrop-filter: blur(16px); - border: 1px solid var(--glass-border); - box-shadow: var(--shadow-lg) !important; -} - -@media (min-width: 1024px) { - .carousel-nav { - display: flex; - } - .watch-container { - padding-top: 5rem; - } - - .details-cover { - align-items: center; - text-align: center; - } - .details-cover h1 { - text-align: center; - margin-bottom: var(--spacing-lg); - } -} - -@media (max-width: 768px) { - .watch-container { - padding: 5rem 1rem 2rem 1rem; - margin: 0; - width: 100%; - overflow-x: hidden; - } - - .player-toolbar { - flex-direction: column; - align-items: stretch; - gap: 1rem; - padding: 1rem; - } - - .control-group { - justify-content: space-between; - width: 100%; - } - - .source-select { - width: 100%; - background-position: right 1.5rem center; - } - - .episode-controls { - flex-direction: column; - align-items: flex-start; - gap: 1.5rem; - } - - .episode-info { - width: 100%; - text-align: left; - } - - .episode-info h1 { - font-size: 1.4rem; - line-height: 1.3; - } - - .navigation-buttons { - width: 100%; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.8rem; - } - - .nav-btn { - justify-content: center; - padding: 0.8rem; - width: 100%; - } - - .details-container { - flex-direction: column; - padding: 1.5rem; - gap: 2rem; - } - - .details-cover { - flex-direction: row; - align-items: flex-start; - width: 100%; - gap: 1.5rem; - } - - @media (max-width: 480px) { - .details-cover { - flex-direction: column; - align-items: center; - text-align: center; - } - - .details-cover h1 { - text-align: center; - } - } - - .cover-image { - width: 140px; - flex-shrink: 0; - margin: 0 auto; - } - - .details-content h1 { - font-size: 1.3rem; - } - - .characters-carousel { - justify-content: center; - padding-bottom: 1rem; - } - - .character-card { - width: calc(50% - 0.75rem); - flex: 0 0 calc(50% - 0.75rem); - } -} - -.plyr__progress { - position: relative; -} - -.plyr__markers { - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 100%; - pointer-events: none; - z-index: 2; -} - -.plyr__marker { - position: absolute; - bottom: 0; - width: 3px; - height: 100%; - background: rgba(255, 215, 0, 0.8); /* Color dorado para Opening */ - pointer-events: all; - cursor: pointer; - transition: all 0.2s ease; -} - -.plyr__marker[data-label*="Ending"] { - background: rgba(255, 100, 100, 0.8); /* Color rojo para Ending */ -} - -.plyr__marker:hover { - height: 120%; - width: 4px; - background: rgba(255, 215, 0, 1); -} - -.plyr__marker[data-label*="Ending"]:hover { - background: rgba(255, 100, 100, 1); -} - -/* Tooltip para mostrar el label */ -.plyr__marker::before { - content: attr(data-label); - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%) translateY(-8px); - background: rgba(0, 0, 0, 0.9); - color: white; - padding: 4px 8px; - border-radius: 4px; - font-size: 12px; - white-space: nowrap; - opacity: 0; - pointer-events: none; - transition: opacity 0.2s ease; -} - -.plyr__marker:hover::before { - opacity: 1; -} -.plyr__marker { - position: absolute; - height: 100%; - background: rgba(255, 255, 255, 0.35); - cursor: pointer; -} diff --git a/desktop/views/css/components/titlebar.css b/desktop/views/css/components/titlebar.css index e4e48f9..58dabc4 100644 --- a/desktop/views/css/components/titlebar.css +++ b/desktop/views/css/components/titlebar.css @@ -25,6 +25,11 @@ html.electron .panel-header { top: var(--titlebar-height) !important; } +html.electron .player-wrapper { + top: var(--titlebar-height); + height: calc(100% - var(--titlebar-height)); +} + html.electron .panel-content { margin-top: 2rem; } diff --git a/docker/src/api/anime/anime.service.ts b/docker/src/api/anime/anime.service.ts index 3e494bf..ce1836c 100644 --- a/docker/src/api/anime/anime.service.ts +++ b/docker/src/api/anime/anime.service.ts @@ -41,17 +41,21 @@ const MEDIA_FIELDS = ` siteUrl tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult } relations { - edges { - relationType - node { - id - title { romaji } - type - format - status + edges { + relationType + node { + id + title { romaji english } + type + format + status + coverImage { medium large color } + bannerImage + season + seasonYear + } } } - } studios { edges { isMain @@ -70,12 +74,28 @@ const MEDIA_FIELDS = ` mediaRecommendation { id title { romaji } - coverImage { medium } + coverImage { medium large} format type } } } + characters(perPage: 12, sort: [ROLE, RELEVANCE]) { + edges { + role + node { + id + name { full native } + image { medium large } + } + voiceActors { + id + name { full } + language + image { medium } + } + } + } `; export async function refreshTrendingAnime(): Promise { @@ -140,7 +160,19 @@ async function fetchAniList(query: string, variables: any) { export async function getAnimeById(id: string | number): Promise { const row = await queryOne("SELECT full_data FROM anime WHERE id = ?", [id]); - if (row) return JSON.parse(row.full_data); + if (row) { + const cached = JSON.parse(row.full_data); + + if (cached?.characters?.edges?.length) { + return cached; + } + + await queryOne( + "DELETE FROM anime WHERE id = ?", + [id] + ); + } + const query = ` query ($id: Int) { diff --git a/docker/src/scripts/anime/anime.js b/docker/src/scripts/anime/anime.js deleted file mode 100644 index ae4956c..0000000 --- a/docker/src/scripts/anime/anime.js +++ /dev/null @@ -1,326 +0,0 @@ -let animeData = null; -let extensionName = null; -let animeId = null; -let isLocal = false; - -const episodePagination = Object.create(PaginationManager); -episodePagination.init(12, renderEpisodes); - -YouTubePlayerUtils.init('player'); - -document.addEventListener('DOMContentLoaded', () => { - loadAnime(); - setupDescriptionModal(); - setupEpisodeSearch(); -}); - -function markAsLocal() { - isLocal = true; - const pill = document.getElementById('local-pill'); - if (!pill) return; - - pill.textContent = 'Local'; - pill.style.display = 'inline-flex'; - pill.style.background = 'rgba(34,197,94,.2)'; - pill.style.color = '#22c55e'; - pill.style.borderColor = 'rgba(34,197,94,.3)'; -} - -async function checkLocalLibraryEntry() { - try { - const res = await fetch(`/api/library/anime/${animeId}`); - if (!res.ok) return; - - markAsLocal(); - - } catch (e) { - } -} - -async function loadAnime() { - try { - - const urlData = URLUtils.parseEntityPath('anime'); - if (!urlData) { - showError("Invalid URL"); - return; - } - - extensionName = urlData.extensionName; - animeId = urlData.entityId; - await checkLocalLibraryEntry(); - - const fetchUrl = extensionName - ? `/api/anime/${animeId}?source=${extensionName}` - : `/api/anime/${animeId}?source=anilist`; - - const res = await fetch(fetchUrl, { headers: AuthUtils.getSimpleAuthHeaders() }); - const data = await res.json(); - - if (data.error) { - showError("Anime Not Found"); - return; - } - - animeData = data; - animeData.entry_type = 'ANIME'; - const metadata = MediaMetadataUtils.formatAnimeData(data, !!extensionName); - - updatePageTitle(metadata.title); - updateMetadata(metadata); - updateDescription(data.description || data.summary); - updateCharacters(metadata.characters); - updateExtensionPill(); - - setupWatchButton(); - - const hasTrailer = YouTubePlayerUtils.playTrailer( - metadata.trailer, - 'player', - metadata.banner - ); - - setupEpisodes(metadata.episodes); - - await setupAddToListButton(); - - } catch (err) { - console.error('Error loading anime:', err); - showError("Error loading anime"); - } -} - -function updatePageTitle(title) { - document.title = `${title} | WaifuBoard`; - document.getElementById('title').innerText = title; -} - -function updateMetadata(metadata) { - - if (metadata.poster) { - document.getElementById('poster').src = metadata.poster; - } - - document.getElementById('score').innerText = `${metadata.score}% Score`; - - document.getElementById('year').innerText = metadata.year; - - document.getElementById('genres').innerText = metadata.genres; - - document.getElementById('format').innerText = metadata.format; - - document.getElementById('status').innerText = metadata.status; - - document.getElementById('season').innerText = metadata.season; - - document.getElementById('studio').innerText = metadata.studio; - - document.getElementById('episodes').innerText = metadata.episodes; -} - -function updateDescription(rawDescription) { - const desc = MediaMetadataUtils.truncateDescription(rawDescription, 4); - - document.getElementById('description-preview').innerHTML = desc.short; - document.getElementById('full-description').innerHTML = desc.full; - - const readMoreBtn = document.getElementById('read-more-btn'); - if (desc.isTruncated) { - readMoreBtn.style.display = 'inline-flex'; - } else { - readMoreBtn.style.display = 'none'; - } -} - -function updateCharacters(characters) { - const container = document.getElementById('char-list'); - container.innerHTML = ''; - - if (characters.length > 0) { - characters.forEach(char => { - container.innerHTML += ` -
-
${char.name} -
`; - }); - } else { - container.innerHTML = ` -
- No character data available -
`; - } -} - -function updateExtensionPill() { - const pill = document.getElementById('extension-pill'); - if (!pill) return; - - if (extensionName) { - pill.textContent = extensionName.charAt(0).toUpperCase() + extensionName.slice(1).toLowerCase(); - pill.style.display = 'inline-flex'; - } else { - pill.style.display = 'none'; - } -} - -function setupWatchButton() { - const watchBtn = document.getElementById('watch-btn'); - if (watchBtn) { - watchBtn.onclick = () => { - const source = isLocal ? 'local' : (extensionName || 'anilist'); - window.location.href = URLUtils.buildWatchUrl(animeId, num, source); - }; - } -} - -async function setupAddToListButton() { - const btn = document.getElementById('add-to-list-btn'); - if (!btn || !animeData) return; - - ListModalManager.currentData = animeData; - const entryType = ListModalManager.getEntryType(animeData); - - await ListModalManager.checkIfInList(animeId, extensionName || 'anilist', entryType); - - const tempBtn = document.querySelector('.hero-buttons .btn-blur'); - if (tempBtn) { - ListModalManager.updateButton('.hero-buttons .btn-blur'); - } else { - - updateCustomAddButton(); - } - - btn.onclick = () => ListModalManager.open(animeData, extensionName || 'anilist'); -} - -function updateCustomAddButton() { - const btn = document.getElementById('add-to-list-btn'); - if (!btn) return; - - if (ListModalManager.isInList) { - btn.innerHTML = ` - - - - In Your List - `; - btn.style.background = 'rgba(34, 197, 94, 0.2)'; - btn.style.color = '#22c55e'; - btn.style.borderColor = 'rgba(34, 197, 94, 0.3)'; - } else { - btn.innerHTML = '+ Add to List'; - btn.style.background = null; - btn.style.color = null; - btn.style.borderColor = null; - } -} - -function setupEpisodes(totalEpisodes) { - - const limitedTotal = Math.min(Math.max(totalEpisodes, 1), 5000); - - episodePagination.setTotalItems(limitedTotal); - renderEpisodes(); -} - -function renderEpisodes() { - const grid = document.getElementById('episodes-grid'); - if (!grid) return; - - grid.innerHTML = ''; - - const range = episodePagination.getPageRange(); - const start = range.start + 1; - - const end = range.end; - - for (let i = start; i <= end; i++) { - createEpisodeButton(i, grid); - } - - episodePagination.renderControls( - 'pagination-controls', - 'page-info', - 'prev-page', - 'next-page' - ); -} - -function createEpisodeButton(num, container) { - const btn = document.createElement('div'); - btn.className = 'episode-btn'; - btn.innerText = `Ep ${num}`; - btn.onclick = () => { - const source = isLocal ? 'local' : (extensionName || 'anilist'); - window.location.href = URLUtils.buildWatchUrl(animeId, num, source); - }; - container.appendChild(btn); -} - -function setupDescriptionModal() { - const modal = document.getElementById('desc-modal'); - if (!modal) return; - - modal.addEventListener('click', (e) => { - if (e.target.id === 'desc-modal') { - closeDescriptionModal(); - } - }); -} - -function openDescriptionModal() { - document.getElementById('desc-modal').classList.add('active'); - document.body.style.overflow = 'hidden'; -} - -function closeDescriptionModal() { - document.getElementById('desc-modal').classList.remove('active'); - document.body.style.overflow = ''; -} - -function setupEpisodeSearch() { - const searchInput = document.getElementById('ep-search'); - if (!searchInput) return; - - searchInput.addEventListener('input', (e) => { - const val = parseInt(e.target.value); - const grid = document.getElementById('episodes-grid'); - const totalEpisodes = episodePagination.totalItems; - - if (val > 0 && val <= totalEpisodes) { - grid.innerHTML = ''; - createEpisodeButton(val, grid); - document.getElementById('pagination-controls').style.display = 'none'; - } else if (!e.target.value) { - renderEpisodes(); - } else { - grid.innerHTML = '
Episode not found
'; - document.getElementById('pagination-controls').style.display = 'none'; - } - }); -} - -function showError(message) { - document.getElementById('title').innerText = message; -} - -function saveToList() { - if (!animeId) return; - ListModalManager.save(animeId, extensionName || 'anilist'); -} - -function deleteFromList() { - if (!animeId) return; - ListModalManager.delete(animeId, extensionName || 'anilist'); -} - -function closeAddToListModal() { - ListModalManager.close(); -} - -window.openDescriptionModal = openDescriptionModal; -window.closeDescriptionModal = closeDescriptionModal; -window.changePage = (delta) => { - if (delta > 0) episodePagination.nextPage(); - else episodePagination.prevPage(); -}; \ No newline at end of file diff --git a/docker/src/scripts/anime/entry.js b/docker/src/scripts/anime/entry.js new file mode 100644 index 0000000..e9310bb --- /dev/null +++ b/docker/src/scripts/anime/entry.js @@ -0,0 +1,408 @@ +let animeData = null; +let extensionName = null; + +let animeId = null; +let isLocal = false; + +const episodePagination = Object.create(PaginationManager); +episodePagination.init(50, renderEpisodes); + +document.addEventListener('DOMContentLoaded', () => { + loadAnimeData(); + setupDescriptionModal(); + setupEpisodeSearch(); + + const urlParams = new URLSearchParams(window.location.search); + const initialEp = urlParams.get('episode'); + + if (initialEp) { + setTimeout(() => { + if (animeData) { + + const source = isLocal ? 'local' : (extensionName || 'anilist'); + + if (typeof AnimePlayer !== 'undefined') { + AnimePlayer.init(animeId, source, isLocal, animeData); + AnimePlayer.playEpisode(parseInt(initialEp)); + } + } + }, 800); + } +}); + +async function loadAnimeData() { + try { + + const pathParts = window.location.pathname.split('/'); + + const cleanParts = pathParts.filter(p => p.length > 0); + + if (cleanParts.length >= 3 && cleanParts[0] === 'anime') { + + extensionName = cleanParts[1]; + animeId = cleanParts[2]; + } else if (cleanParts.length === 2 && cleanParts[0] === 'anime') { + + extensionName = null; + + animeId = cleanParts[1]; + } else { + showError("Invalid URL Format"); + return; + } + + try { + const localRes = await fetch(`/api/library/anime/${animeId}`); + if (localRes.ok) isLocal = true; + } catch {} + + const fetchUrl = extensionName + ? `/api/anime/${animeId}?source=${extensionName}` + : `/api/anime/${animeId}?source=anilist`; + + const res = await fetch(fetchUrl, { headers: AuthUtils.getSimpleAuthHeaders() }); + const data = await res.json(); + + if (data.error) { + showError("Anime Not Found"); + return; + } + + animeData = data; + animeData.entry_type = 'ANIME'; + + const metadata = MediaMetadataUtils.formatAnimeData(data, !!extensionName); + + document.title = `${metadata.title} | WaifuBoard`; + document.getElementById('title').innerText = metadata.title; + if (metadata.poster) document.getElementById('poster').src = metadata.poster; + + document.getElementById('score').innerText = `${metadata.score}% Score`; + document.getElementById('year').innerText = metadata.year; + document.getElementById('genres').innerText = metadata.genres.split(',').join(' • '); + document.getElementById('format').innerText = metadata.format; + document.getElementById('status').innerText = metadata.status; + document.getElementById('season').innerText = metadata.season; + document.getElementById('studio').innerText = metadata.studio; + document.getElementById('episodes').innerText = `${metadata.episodes} Ep`; + + updateLocalPill(); + updateDescription(data.description || data.summary); + updateRelations(data.relations?.edges); + updateCharacters(metadata.characters); + updateRecommendations(data.recommendations?.nodes); + + const source = isLocal ? 'local' : (extensionName || 'anilist'); + + if (typeof AnimePlayer !== 'undefined') { + AnimePlayer.init(animeId, source, isLocal, animeData); + } + + YouTubePlayerUtils.init('trailer-player'); + YouTubePlayerUtils.playTrailer(metadata.trailer, 'trailer-player', metadata.banner); + + const watchBtn = document.getElementById('watch-btn'); + if (watchBtn) { + watchBtn.onclick = () => { + if (typeof AnimePlayer !== 'undefined') AnimePlayer.playEpisode(1); + }; + } + + const total = metadata.episodes || 12; + setupEpisodes(total); + + setupAddToListButton(); + + } catch (err) { + console.error('Error loading anime details:', err); + showError("Error loading anime"); + } +} + +function updateLocalPill() { + if (isLocal) { + const pill = document.getElementById('local-pill'); + if(pill) pill.style.display = 'inline-flex'; + } + if (extensionName) { + const pill = document.getElementById('extension-pill'); + if(pill) { + pill.innerText = extensionName; + pill.style.display = 'inline-flex'; + } + } +} + +function updateDescription(rawDescription) { + const desc = MediaMetadataUtils.truncateDescription(rawDescription, 3); + const previewEl = document.getElementById('description-preview'); + const fullEl = document.getElementById('full-description'); + + if (previewEl) { + previewEl.innerHTML = desc.short + + (desc.isTruncated ? ' Read more' : ''); + } + if (fullEl) fullEl.innerHTML = desc.full; +} + +function setupDescriptionModal() { + const modal = document.getElementById('desc-modal'); + if (!modal) return; + modal.addEventListener('click', (e) => { + if (e.target.id === 'desc-modal') { + closeDescriptionModal(); + } + }); +} + +function openDescriptionModal() { + const modal = document.getElementById('desc-modal'); + if(modal) modal.classList.add('active'); +} + +function closeDescriptionModal() { + const modal = document.getElementById('desc-modal'); + if(modal) modal.classList.remove('active'); +} + +function setupEpisodes(totalEpisodes) { + const limitedTotal = Math.min(Math.max(totalEpisodes, 1), 5000); + episodePagination.setTotalItems(limitedTotal); + renderEpisodes(); +} + +function renderEpisodes() { + const grid = document.getElementById('episodes-grid'); + if (!grid) return; + grid.innerHTML = ''; + + const range = episodePagination.getPageRange(); + const currentEp = (typeof AnimePlayer !== 'undefined') ? AnimePlayer.getCurrentEpisode() : 0; + + for (let i = range.start + 1; i <= range.end; i++) { + const btn = document.createElement('div'); + btn.className = 'episode-btn'; + if (currentEp === i) btn.classList.add('active-playing'); + + btn.innerText = i; + + btn.onclick = () => { + if (typeof AnimePlayer !== 'undefined') { + AnimePlayer.playEpisode(i); + renderEpisodes(); + } + }; + grid.appendChild(btn); + } + + const totalItems = episodePagination.totalItems || 0; + const itemsPerPage = episodePagination.itemsPerPage || 50; + const safeTotalPages = Math.ceil(totalItems / itemsPerPage) || 1; + + const info = document.getElementById('page-info'); + if(info) info.innerText = `Page ${episodePagination.currentPage} of ${safeTotalPages}`; + + const prevBtn = document.getElementById('prev-page'); + const nextBtn = document.getElementById('next-page'); + + if(prevBtn) { + prevBtn.disabled = episodePagination.currentPage === 1; + prevBtn.onclick = () => { episodePagination.prevPage(); }; + } + + if(nextBtn) { + + nextBtn.disabled = episodePagination.currentPage >= safeTotalPages; + nextBtn.onclick = () => { episodePagination.nextPage(); }; + } +} + +function setupEpisodeSearch() { + const searchInput = document.getElementById('ep-search'); + if (!searchInput) return; + + searchInput.addEventListener('input', (e) => { + const val = parseInt(e.target.value); + const grid = document.getElementById('episodes-grid'); + const controls = document.getElementById('pagination-controls'); + + if (val > 0) { + grid.innerHTML = ''; + const btn = document.createElement('div'); + btn.className = 'episode-btn'; + btn.innerText = `${val}`; + btn.onclick = () => { if (typeof AnimePlayer !== 'undefined') AnimePlayer.playEpisode(val); }; + grid.appendChild(btn); + if(controls) controls.style.display = 'none'; + } else if (!e.target.value) { + if(controls) controls.style.display = 'flex'; + renderEpisodes(); + } + }); +} + +function updateCharacters(characters) { + const container = document.getElementById('char-list'); + if (!container) return; + container.innerHTML = ''; + if (!characters || characters.length === 0) return; + + characters.forEach((char, index) => { + const img = char.node?.image?.large || char.image; + const name = char.node?.name?.full || char.name; + const role = char.role || 'Supporting'; + + const card = document.createElement('div'); + card.className = `character-item ${index >= 12 ? 'hidden-char' : ''}`; + card.style.display = index >= 12 ? 'none' : 'flex'; + + card.innerHTML = ` +
+
${name}
${role}
+ `; + container.appendChild(card); + }); + + const showMoreBtn = document.getElementById('show-more-chars'); + if (showMoreBtn) { + if (characters.length > 12) { + showMoreBtn.style.display = 'block'; + showMoreBtn.onclick = () => { + document.querySelectorAll('.hidden-char').forEach(el => el.style.display = 'flex'); + showMoreBtn.style.display = 'none'; + }; + } else { + showMoreBtn.style.display = 'none'; + } + } +} + +function updateRelations(relations) { + const container = document.getElementById('relations-grid'); + const section = document.getElementById('relations-section'); + + if (!container || !relations || relations.length === 0) { + if (section) section.style.display = 'none'; + return; + } + + section.style.display = 'block'; + container.innerHTML = ''; + + const priorityMap = { + 'ADAPTATION': 1, + 'PREQUEL': 2, + 'SEQUEL': 3, + 'PARENT': 4, + 'SIDE_STORY': 5, + 'SPIN_OFF': 6, + 'ALTERNATIVE': 7, + 'CHARACTER': 8, + 'OTHER': 99 + }; + + relations.sort((a, b) => { + const pA = priorityMap[a.relationType] || 50; + const pB = priorityMap[b.relationType] || 50; + return pA - pB; + }); + + relations.forEach(rel => { + const media = rel.node; + if (!media) return; + + const title = media.title?.romaji || media.title?.english || 'Unknown'; + const cover = media.coverImage?.large || media.coverImage?.medium || ''; + + const typeDisplay = rel.relationType ? rel.relationType.replace(/_/g, ' ') : 'RELATION'; + const rawType = rel.relationType; + + const el = document.createElement('div'); + el.className = 'relation-card-horizontal'; + + let targetUrl = null; + + if (rawType === 'ADAPTATION') { + targetUrl = `/book/${media.id}`; + + } else if (media.type === 'ANIME' && rawType !== 'OTHER') { + targetUrl = `/anime/${media.id}`; + + } + + if (targetUrl) { + el.onclick = () => { window.location.href = targetUrl; }; + } else { + el.classList.add('no-link'); + + el.onclick = null; + } + + el.innerHTML = ` + ${title} +
+ ${typeDisplay} + ${title} +
+ `; + container.appendChild(el); + }); +} + +function updateRecommendations(recommendations) { + const container = document.getElementById('recommendations-grid'); + if (!container || !recommendations) return; + container.innerHTML = ''; + + recommendations.forEach(rec => { + const media = rec.mediaRecommendation; + if (!media) return; + + const title = media.title?.romaji || 'Unknown'; + const cover = media.coverImage?.large || media.coverImage?.medium || ''; + + const el = document.createElement('div'); + el.className = 'card'; + + el.onclick = () => { window.location.href = `/anime/${media.id}`; }; + + el.innerHTML = ` +
+

${title}

+ `; + container.appendChild(el); + }); +} + +async function setupAddToListButton() { + const btn = document.getElementById('add-to-list-btn'); + if (!btn || !animeData) return; + + ListModalManager.currentData = animeData; + const entryType = 'ANIME'; + await ListModalManager.checkIfInList(animeId, extensionName || 'anilist', entryType); + updateCustomAddButton(); + btn.onclick = () => ListModalManager.open(animeData, extensionName || 'anilist'); +} + +function updateCustomAddButton() { + const btn = document.getElementById('add-to-list-btn'); + if (btn && ListModalManager.isInList) { + btn.innerHTML = `✓ In Your List`; + btn.style.background = 'rgba(34, 197, 94, 0.2)'; + btn.style.borderColor = '#22c55e'; + btn.style.color = '#22c55e'; + } +} + +function showError(msg) { + const el = document.getElementById('title'); + if(el) el.innerText = msg; +} + +window.openDescriptionModal = openDescriptionModal; +window.closeDescriptionModal = closeDescriptionModal; +window.scrollRecommendations = (dir) => { + const el = document.getElementById('recommendations-grid'); + if(el) el.scrollBy({ left: 240 * 3 * dir, behavior: 'smooth' }); +}; \ No newline at end of file diff --git a/docker/src/scripts/anime/player.js b/docker/src/scripts/anime/player.js index 8023dfe..20126a0 100644 --- a/docker/src/scripts/anime/player.js +++ b/docker/src/scripts/anime/player.js @@ -1,448 +1,654 @@ -const pathParts = window.location.pathname.split('/'); -const animeId = pathParts[2]; -const currentEpisode = parseInt(pathParts[3]); - -let audioMode = 'sub'; -let currentExtension = ''; -let plyrInstance; -let hlsInstance; -let totalEpisodes = 0; -let animeTitle = ""; -let aniSkipData = null; - -let isAnilist = false; -let malId = null; - -const params = new URLSearchParams(window.location.search); -const firstKey = params.keys().next().value; -let extName; -if (firstKey) extName = firstKey; - -// URL de retroceso: Si es local, volvemos a la vista de Anilist normal -const href = (extName && extName !== 'local') - ? `/anime/${extName}/${animeId}` - : `/anime/${animeId}`; - -document.getElementById('back-link').href = href; -document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`; - - -let localEntryId = null; - -async function checkLocal() { - try { - const res = await fetch(`/api/library/anime/${animeId}`); - if (!res.ok) return null; - const data = await res.json(); - return data.id; - } catch { - return null; - } -} - -async function loadAniSkip(malId, episode, duration) { - try { - const res = await fetch(`https://api.aniskip.com/v2/skip-times/${malId}/${episode}?types[]=op&types[]=ed&episodeLength=${duration}`); - if (!res.ok) return null; - const data = await res.json(); - return data.results || []; - } catch (error) { - console.error('Error loading AniSkip data:', error); - return null; - } -} - -async function loadMetadata() { - localEntryId = await checkLocal(); - try { - const sourceQuery = (extName === 'local' || !extName) ? "source=anilist" : `source=${extName}`; - const res = await fetch(`/api/anime/${animeId}?${sourceQuery}`); - const data = await res.json(); - - if (data.error) { - console.error("Error from API:", data.error); - return; - } - - const isAnilistFormat = data.title && (data.title.romaji || data.title.english); - - let title = '', description = '', coverImage = '', averageScore = '', format = '', seasonYear = '', season = ''; - - if (isAnilistFormat) { - title = data.title.romaji || data.title.english || data.title.native || 'Anime Title'; - description = data.description || 'No description available.'; - coverImage = data.coverImage?.large || data.coverImage?.medium || ''; - averageScore = data.averageScore ? `${data.averageScore}%` : '--'; - format = data.format || '--'; - season = data.season ? data.season.charAt(0) + data.season.slice(1).toLowerCase() : ''; - seasonYear = data.seasonYear || ''; - } else { - title = data.title || 'Anime Title'; - description = data.summary || 'No description available.'; - coverImage = data.image || ''; - averageScore = data.score ? `${Math.round(data.score * 10)}%` : '--'; - format = '--'; - season = data.season || ''; - seasonYear = data.year || ''; - } - - if (isAnilistFormat && data.idMal) { - isAnilist = true; - malId = data.idMal; - } else { - isAnilist = false; - malId = null; - } - - document.getElementById('anime-title-details').innerText = title; - document.getElementById('anime-title-details2').innerText = title; - animeTitle = title; - document.title = `Watching ${title} - Ep ${currentEpisode}`; - - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = description; - document.getElementById('detail-description').innerText = tempDiv.textContent || tempDiv.innerText || 'No description available.'; - - document.getElementById('detail-format').innerText = format; - document.getElementById('detail-score').innerText = averageScore; - document.getElementById('detail-season').innerText = season && seasonYear ? `${season} ${seasonYear}` : (season || seasonYear || '--'); - document.getElementById('detail-cover-image').src = coverImage || '/default-cover.jpg'; - - // Solo cargamos episodios de extensión si hay extensión real y no es local - if (extName && extName !== 'local') { - await loadExtensionEpisodes(); - } else { - if (data.nextAiringEpisode?.episode) { - totalEpisodes = data.nextAiringEpisode.episode - 1; - } else if (data.episodes) { - totalEpisodes = data.episodes; - } else { - totalEpisodes = 12; - } - const simpleEpisodes = []; - for (let i = 1; i <= totalEpisodes; i++) { - simpleEpisodes.push({ number: i, title: null, thumbnail: null, isDub: false }); - } - populateEpisodeCarousel(simpleEpisodes); - } - - if (currentEpisode >= totalEpisodes && totalEpisodes > 0) { - document.getElementById('next-btn').disabled = true; - } - - } catch (error) { - console.error('Error loading metadata:', error); - } - await loadExtensions(); -} - -async function applyAniSkip(video) { - if (!isAnilist || !malId) return; - - aniSkipData = await loadAniSkip(malId, currentEpisode, Math.floor(video.duration)); - - if (!aniSkipData || aniSkipData.length === 0) return; - - const markers = []; - aniSkipData.forEach(item => { - const { startTime, endTime } = item.interval; - markers.push({ - start: startTime, - end: endTime, - label: item.skipType === 'op' ? 'Opening' : 'Ending' - }); - }); - - if (plyrInstance && markers.length > 0) { - setTimeout(() => { - const progressContainer = document.querySelector('.plyr__progress'); - if (!progressContainer) return; - - const oldMarkers = progressContainer.querySelector('.plyr__markers'); - if (oldMarkers) oldMarkers.remove(); - - const markersContainer = document.createElement('div'); - markersContainer.className = 'plyr__markers'; - - markers.forEach(marker => { - const markerElement = document.createElement('div'); - markerElement.className = 'plyr__marker'; - markerElement.dataset.label = marker.label; - - const startPercent = (marker.start / video.duration) * 100; - const widthPercent = ((marker.end - marker.start) / video.duration) * 100; - - markerElement.style.left = `${startPercent}%`; - markerElement.style.width = `${widthPercent}%`; - - markerElement.addEventListener('click', (e) => { - e.stopPropagation(); - video.currentTime = marker.start; - }); - - markersContainer.appendChild(markerElement); - }); - progressContainer.appendChild(markersContainer); - }, 500); - } -} - -async function loadExtensionEpisodes() { - try { - const res = await fetch(`/api/anime/${animeId}/episodes?source=${extName}`); - const data = await res.json(); - totalEpisodes = Array.isArray(data) ? data.length : 0; - populateEpisodeCarousel(Array.isArray(data) ? data : []); - } catch (e) { - console.error("Error cargando episodios:", e); - } -} - -function populateEpisodeCarousel(episodesData) { - const carousel = document.getElementById('episode-carousel'); - carousel.innerHTML = ''; - - episodesData.forEach((ep, index) => { - const epNumber = ep.number || ep.episodeNumber || ep.id || (index + 1); - if (!epNumber) return; - - const extParam = (extName && extName !== 'local') ? `?${extName}` : ""; - const hasThumbnail = ep.thumbnail && ep.thumbnail.trim() !== ''; - - const link = document.createElement('a'); - link.href = `/watch/${animeId}/${epNumber}${extParam}`; - link.classList.add('carousel-item'); - if (parseInt(epNumber) === currentEpisode) link.classList.add('active-ep-carousel'); - - const imgContainer = document.createElement('div'); - imgContainer.classList.add('carousel-item-img-container'); - - if (hasThumbnail) { - const img = document.createElement('img'); - img.src = ep.thumbnail; - img.classList.add('carousel-item-img'); - imgContainer.appendChild(img); - } - - link.appendChild(imgContainer); - const info = document.createElement('div'); - info.classList.add('carousel-item-info'); - info.innerHTML = `

Ep ${epNumber}: ${ep.title || 'Untitled'}

`; - link.appendChild(info); - carousel.appendChild(link); - }); -} - -async function loadExtensions() { - try { - const res = await fetch('/api/extensions/anime'); - const data = await res.json(); - const select = document.getElementById('extension-select'); - let extensions = data.extensions || []; - - if (extName === 'local' && !extensions.includes('local')) { - extensions.push('local'); - } - - select.innerHTML = ''; - extensions.forEach(ext => { - const opt = document.createElement('option'); - opt.value = opt.innerText = ext; - select.appendChild(opt); - }); - - if (extName && extensions.includes(extName)) { - select.value = extName; - } else if (extensions.length > 0) { - select.value = extensions[0]; - } - - currentExtension = select.value; - onExtensionChange(); - } catch (error) { - console.error("Extension Error:", error); - } -} - -async function onExtensionChange() { - const select = document.getElementById('extension-select'); - currentExtension = select.value; - - if (currentExtension === 'local') { - document.getElementById('sd-toggle').style.display = 'none'; - document.getElementById('server-select').style.display = 'none'; - loadStream(); - return; - } - - setLoading("Fetching extension settings..."); - try { - const res = await fetch(`/api/extensions/${currentExtension}/settings`); - const settings = await res.json(); - - const toggle = document.getElementById('sd-toggle'); - toggle.style.display = settings.supportsDub ? 'flex' : 'none'; - setAudioMode('sub'); - - const serverSelect = document.getElementById('server-select'); - serverSelect.innerHTML = ''; - if (settings.episodeServers?.length > 0) { - settings.episodeServers.forEach(srv => { - const opt = document.createElement('option'); - opt.value = opt.innerText = srv; - serverSelect.appendChild(opt); - }); - serverSelect.style.display = 'block'; - } else { - serverSelect.style.display = 'none'; - } - loadStream(); - } catch (error) { - setLoading("Failed to load settings."); - } -} - -async function loadStream() { - if (!currentExtension) return; - - if (currentExtension === 'local') { - if (!localEntryId) { - setLoading("No existe en local"); - return; - } - - const localUrl = `/api/library/stream/anime/${localEntryId}/${currentEpisode}`; - playVideo(localUrl, []); - document.getElementById('loading-overlay').style.display = 'none'; - return; - } - - - const serverSelect = document.getElementById('server-select'); - const server = serverSelect.value || "default"; - setLoading(`Loading stream (${audioMode})...`); - - try { - const sourc = (extName && extName !== 'local') ? `&source=${extName}` : "&source=anilist"; - const url = `/api/watch/stream?animeId=${animeId}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}${sourc}`; - const res = await fetch(url); - const data = await res.json(); - - if (data.error || !data.videoSources?.length) { - setLoading(data.error || "No video sources."); - return; - } - - const source = data.videoSources.find(s => s.type === 'm3u8') || data.videoSources[0]; - const headers = data.headers || {}; - - let proxyUrl = `/api/proxy?url=${encodeURIComponent(source.url)}`; - if (headers['Referer']) proxyUrl += `&referer=${encodeURIComponent(headers['Referer'])}`; - if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`; - if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`; - - playVideo(proxyUrl, source.subtitles || data.subtitles || []); - document.getElementById('loading-overlay').style.display = 'none'; - } catch (error) { - setLoading("Stream error."); - } -} - -function playVideo(url, subtitles = []) { - const video = document.getElementById('player'); - const isLocal = url.includes('/api/library/stream/'); - - if (!isLocal && Hls.isSupported()) { - if (hlsInstance) hlsInstance.destroy(); - hlsInstance = new Hls({ xhrSetup: (xhr) => xhr.withCredentials = false }); - hlsInstance.loadSource(url); - hlsInstance.attachMedia(video); - } else { - if (hlsInstance) hlsInstance.destroy(); - video.src = url; - } - - if (plyrInstance) plyrInstance.destroy(); - while (video.textTracks.length > 0) video.removeChild(video.textTracks[0]); - - subtitles.forEach(sub => { - const track = document.createElement('track'); - track.kind = 'captions'; - track.label = sub.language || 'Unknown'; - track.srclang = (sub.language || '').slice(0, 2).toLowerCase(); - track.src = sub.url; - if (sub.default || sub.language?.toLowerCase().includes('english')) track.default = true; - video.appendChild(track); - }); - - plyrInstance = new Plyr(video, { - captions: { active: true, update: true, language: 'en' }, - controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'], - settings: ['captions', 'quality', 'speed'] - }); - - video.addEventListener('loadedmetadata', () => applyAniSkip(video)); -} - -async function sendProgress() { - const token = localStorage.getItem('token'); - if (!token) return; - const source = (extName && extName !== 'local') ? extName : "anilist"; - - const body = { - entry_id: animeId, - source: source, - entry_type: "ANIME", - status: 'CURRENT', - progress: currentEpisode +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 _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 }; - try { - await fetch('/api/list/entry', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(body) - }); - } catch (err) { - console.error('Error updating progress:', err); + function init(animeId, initialSource, isLocal, animeData) { + _animeId = animeId; + _entrySource = initialSource || 'anilist'; + _isLocal = isLocal; + _malId = animeData.idMal || null; + + // Guardar total de episodios (por defecto 1000 si no hay info) + _totalEpisodes = animeData.episodes || 1000; + + _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.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); + _skipBtn = btn; + } else { + _skipBtn = document.getElementById('skip-overlay-btn'); + } + + // 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(); } -} -// Botones y Toggle -document.getElementById('sd-toggle').onclick = () => { - audioMode = audioMode === 'sub' ? 'dub' : 'sub'; - setAudioMode(audioMode); - loadStream(); -}; + function handleOverlayClick() { + if (!_skipBtn) return; -function setAudioMode(mode) { - const toggle = document.getElementById('sd-toggle'); - toggle.setAttribute('data-state', mode); - document.getElementById('opt-sub').classList.toggle('active', mode === 'sub'); - document.getElementById('opt-dub').classList.toggle('active', mode === 'dub'); -} + // 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) { + els.video.currentTime = parseFloat(_skipBtn.dataset.seekTo); + } -function setLoading(message) { - document.getElementById('loading-text').innerText = message; - document.getElementById('loading-overlay').style.display = 'flex'; -} + // Ocultar tras click + _skipBtn.classList.remove('visible'); + } -const extParam = (extName && extName !== 'local') ? `?${extName}` : ""; -document.getElementById('prev-btn').onclick = () => { - if (currentEpisode > 1) window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`; -}; -document.getElementById('next-btn').onclick = () => { - if (currentEpisode < totalEpisodes || totalEpisodes === 0) window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`; -}; + async function getLocalEntryId() { + if (_localEntryId) return _localEntryId; + try { -if (currentEpisode <= 1) document.getElementById('prev-btn').disabled = true; + const res = await fetch(`/api/library/anime/${_animeId}`); + if (!res.ok) return null; + const data = await res.json(); + _localEntryId = data.id; -// Actualizar progreso cada 1 minuto si el video está reproduciéndose -setInterval(() => { - if (plyrInstance && !plyrInstance.paused) sendProgress(); -}, 60000); + return _localEntryId; + } catch (e) { + console.error("Error fetching local ID:", e); + return null; + } + } -loadMetadata(); + 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); + + 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":""}', '*'); + + 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 = []; + + 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..."); + + 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'); + + } catch(e) { + setLoading("Local Error: " + e.message); + console.error(e); + } + 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 { + console.log('Fetching stream:', url); + 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 || {}; + + 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)}` + })); + + initVideoPlayer(proxyUrl, source.type, subtitles); + + } catch (err) { + setLoading("Stream Error: " + err.message); + console.error(err); + } + } + + function initVideoPlayer(url, type, subtitles = []) { + const video = els.video; + Array.from(video.querySelectorAll('track')).forEach(t => t.remove()); + + if (Hls.isSupported() && (type === 'm3u8' || url.includes('.m3u8'))) { + console.log("Using HLS.js"); + hlsInstance = new Hls(); + hlsInstance.attachMedia(video); + + hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => { + hlsInstance.loadSource(url); + }); + + hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { + 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; + video.appendChild(track); + }); + + els.video.addEventListener('loadedmetadata', () => { + applyAniSkip(_malId, _currentEpisode); + }, { once: true }); + + initPlyr(); + video.addEventListener('canplay', () => { + video.play().catch(() => {}); + }, { once: true }); + if (els.loader) 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 { + console.log("Using Native Player (MP4/WebM)"); + video.src = url; + + 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; + video.appendChild(track); + }); + + initPlyr(); + + 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 initPlyr() { + if (plyrInstance) return; + + 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' // IMPORTANTE: El contenedor padre entra en fullscreen + }, + controls: [ + 'play-large', 'play', 'progress', 'current-time', + 'mute', 'volume', 'captions', 'settings', + 'fullscreen', 'airplay' + ], + settings: ['captions', 'quality', 'speed'] + }); + + // --- MAGIA NUEVA AQUÍ --- + // Sincronizar la UI personalizada con los eventos de Plyr + const container = document.querySelector('.player-container'); + + // Cuando Plyr esconde sus controles (inactividad) + plyrInstance.on('controlshidden', () => { + container.classList.add('ui-hidden'); + }); + + // Cuando Plyr muestra sus controles (movimiento de mouse) + 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; + } + }); + } + + 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; + const widthPct = endPct - startPct; + el.style.left = `${startPct}%`; + el.style.width = `${widthPct}%`; + 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 }); + + 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 + 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 + } else { + _skipBtn.classList.remove('is-next'); // Estilo blanco + _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 + }; +})(); \ No newline at end of file diff --git a/docker/src/scripts/utils/continue-watching-manager.js b/docker/src/scripts/utils/continue-watching-manager.js index dbdea0c..456d3ea 100644 --- a/docker/src/scripts/utils/continue-watching-manager.js +++ b/docker/src/scripts/utils/continue-watching-manager.js @@ -52,10 +52,9 @@ const ContinueWatchingManager = { if (entryType === 'ANIME') { url = item.source === 'anilist' - ? `/watch/${item.entry_id}/${nextProgress}` - : `/watch/${item.entry_id}/${nextProgress}?${item.source}`; + ? `/anime/${item.entry_id}?episode=${nextProgress}` + : `/anime/${item.entry_id}/${item.source}/?episode=${nextProgress}`; } else { - url = item.source === 'anilist' ? `/book/${item.entry_id}?chapter=${nextProgress}` : `/read/${item.source}/${nextProgress}/${item.entry_id}?source=${item.source}`; diff --git a/docker/src/views/views.routes.ts b/docker/src/views/views.routes.ts index 04b3b9a..529f5df 100644 --- a/docker/src/views/views.routes.ts +++ b/docker/src/views/views.routes.ts @@ -112,12 +112,6 @@ async function viewsRoutes(fastify: FastifyInstance) { reply.type('text/html').send(html); }); - fastify.get('/watch/:id/:episode', (req: FastifyRequest, reply: FastifyReply) => { - const htmlPath = path.join(__dirname, '..', '..', 'views', 'anime', 'watch.html'); - const html = fs.readFileSync(htmlPath, 'utf-8'); - reply.type('text/html').send(html); - }); - fastify.get('/book/:id', (req: FastifyRequest, reply: FastifyReply) => { const htmlPath = path.join(__dirname, '..', '..', 'views', 'books', 'book.html'); const html = fs.readFileSync(htmlPath, 'utf-8'); diff --git a/docker/views/anime/anime.html b/docker/views/anime/anime.html index 7d8af70..ee5b323 100644 --- a/docker/views/anime/anime.html +++ b/docker/views/anime/anime.html @@ -1,223 +1,168 @@ - - - - - WaifuBoard - - - - - - - - + + + + + WaifuBoard - - - - - Back to Home - + + + -
-
-
-
-
-
+ + + + + + + + + + + Back + -
- +
+
+
+
-
-
-

Loading...

+
+ +
+
+
+
+
Sub
+
Dub
+
+ + + +
+
+
+ +
+ +
+
+

Loading Stream...

+
+
+ + -