1897 lines
80 KiB
JavaScript
1897 lines
80 KiB
JavaScript
import { state } from './state.js';
|
|
import { fetchTMDB, fetchAllStreamsFromPlex } from './api.js';
|
|
import { showNotification, getRelativeTime, fetchWithTimeout, _ } from './utils.js';
|
|
import { getFromDB, addItemsToStore } from './db.js';
|
|
|
|
let charts = {};
|
|
|
|
function createElement(tag, options = {}, children = []) {
|
|
const el = document.createElement(tag);
|
|
Object.entries(options).forEach(([key, value]) => {
|
|
if (key === 'dataset') {
|
|
Object.entries(value).forEach(([dataKey, dataValue]) => {
|
|
el.dataset[dataKey] = dataValue;
|
|
});
|
|
} else {
|
|
el[key] = value;
|
|
}
|
|
});
|
|
children.forEach(child => {
|
|
if (typeof child === 'string') {
|
|
el.appendChild(document.createTextNode(child));
|
|
} else if (child) {
|
|
el.appendChild(child);
|
|
}
|
|
});
|
|
return el;
|
|
}
|
|
|
|
export async function loadInitialContent() {
|
|
await Promise.all([loadGenres(), loadYears()]);
|
|
await Promise.all([loadContent(), initializeHeroSection()]);
|
|
setupScrollEffects();
|
|
}
|
|
|
|
export function initializeUserData() {
|
|
try {
|
|
const savedHistory = localStorage.getItem('cineplex_userHistory');
|
|
state.userHistory = savedHistory ? JSON.parse(savedHistory) : [];
|
|
if (!Array.isArray(state.userHistory)) state.userHistory = [];
|
|
} catch {
|
|
state.userHistory = [];
|
|
}
|
|
try {
|
|
const savedPrefs = localStorage.getItem('cineplex_userPreferences');
|
|
state.userPreferences = savedPrefs ? JSON.parse(savedPrefs) : { genres: {}, keywords: {}, ratings: [], cast: {}, crew: {} };
|
|
} catch {
|
|
state.userPreferences = { genres: {}, keywords: {}, ratings: [], cast: {}, crew: {} };
|
|
}
|
|
}
|
|
|
|
export async function loadLocalContent() {
|
|
if (!state.db) return;
|
|
try {
|
|
const [movies, series, artists, photos] = await Promise.all([getFromDB('movies'), getFromDB('series'), getFromDB('artists'), getFromDB('photos')]);
|
|
state.localMovies = movies;
|
|
state.localSeries = series;
|
|
state.localArtists = artists;
|
|
state.localPhotos = photos;
|
|
} catch (error) {
|
|
showNotification(_("errorLoadingLocalContent"), "error");
|
|
}
|
|
}
|
|
|
|
export function resetView() {
|
|
switchView('movies');
|
|
}
|
|
|
|
export function switchView(viewType) {
|
|
if (state.isLoading) return;
|
|
|
|
const sidebar = document.getElementById('sidebar-nav');
|
|
if (sidebar.classList.contains('open')) {
|
|
sidebar.classList.remove('open');
|
|
document.getElementById('main-container').classList.remove('sidebar-open');
|
|
}
|
|
|
|
const mainContent = document.querySelector('.main-content');
|
|
const topBarHeight = document.querySelector('.top-bar')?.offsetHeight || 60;
|
|
const targetScrollTop = mainContent ? mainContent.offsetTop - topBarHeight : 0;
|
|
|
|
if (state.currentView === viewType && viewType !== 'search') {
|
|
if (window.scrollY > targetScrollTop) {
|
|
window.scrollTo({
|
|
top: targetScrollTop,
|
|
behavior: 'smooth'
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
state.currentView = viewType;
|
|
state.currentPage = 1;
|
|
state.currentParams.query = '';
|
|
document.getElementById('search-input').value = '';
|
|
state.lastScrollPosition = 0;
|
|
|
|
const allSections = ['content-section', 'stats-section', 'history-section', 'recommendations-section', 'photos-section'];
|
|
allSections.forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.style.display = 'none';
|
|
});
|
|
|
|
const filters = document.querySelector('.filters');
|
|
if (filters) filters.style.display = 'none';
|
|
|
|
switch(viewType) {
|
|
case 'movies':
|
|
case 'series':
|
|
case 'search':
|
|
document.getElementById('content-section').style.display = 'block';
|
|
filters.style.display = 'flex';
|
|
if (viewType !== 'search') {
|
|
state.currentParams.contentType = viewType === 'movies' ? 'movie' : 'tv';
|
|
resetFilters(false);
|
|
updateSortOptions();
|
|
loadGenres();
|
|
}
|
|
break;
|
|
case 'favorites':
|
|
document.getElementById('content-section').style.display = 'block';
|
|
break;
|
|
case 'history':
|
|
document.getElementById('history-section').style.display = 'block';
|
|
break;
|
|
case 'recommendations':
|
|
document.getElementById('recommendations-section').style.display = 'block';
|
|
break;
|
|
case 'stats':
|
|
document.getElementById('stats-section').style.display = 'block';
|
|
document.getElementById('stats-filters').style.display = 'flex';
|
|
break;
|
|
case 'photos':
|
|
document.getElementById('photos-section').style.display = 'block';
|
|
break;
|
|
}
|
|
|
|
updateActiveNav(viewType);
|
|
updateSectionTitle();
|
|
|
|
window.scrollTo({ top: targetScrollTop, behavior: 'auto' });
|
|
|
|
switch(viewType) {
|
|
case 'movies':
|
|
case 'series':
|
|
case 'search':
|
|
loadContent();
|
|
break;
|
|
case 'favorites':
|
|
loadFavorites();
|
|
break;
|
|
case 'history':
|
|
displayHistory();
|
|
break;
|
|
case 'recommendations':
|
|
loadRecommendations();
|
|
break;
|
|
case 'stats':
|
|
generateStatistics();
|
|
break;
|
|
case 'photos':
|
|
initPhotosView();
|
|
break;
|
|
}
|
|
|
|
if (document.getElementById('item-details-view').classList.contains('active')) {
|
|
showMainView();
|
|
}
|
|
}
|
|
|
|
function updateActiveNav(activeView) {
|
|
document.querySelectorAll('.nav-link, .footer-link').forEach(link => link.classList.remove('active'));
|
|
let navId = (activeView === 'search') ? (state.currentParams.contentType === 'movie' ? 'movies' : 'series') : activeView;
|
|
const activeLink = document.getElementById(`nav-${navId}`);
|
|
const activeFooterLink = document.getElementById(`footer-${navId}`);
|
|
if (activeLink) activeLink.classList.add('active');
|
|
if (activeFooterLink) activeFooterLink.classList.add('active');
|
|
}
|
|
|
|
export function updateSectionTitle() {
|
|
let title = "";
|
|
const mainTitleElement = document.getElementById('main-section-title');
|
|
|
|
switch (state.currentView) {
|
|
case 'movies': case 'series':
|
|
const sortMap = {
|
|
'popularity.desc': _('popularSort'),
|
|
'vote_average.desc': _('topRatedSort'),
|
|
'release_date.desc': _('recentSort'),
|
|
'first_air_date.desc': _('recentSort')
|
|
};
|
|
const typeTitle = state.currentView === 'movies' ? _('moviesSectionTitle') : _('seriesSectionTitle');
|
|
title = `${typeTitle} ${sortMap[state.currentParams.sort] || ''}`;
|
|
break;
|
|
case 'stats': title = _('statsTitle'); break;
|
|
case 'favorites': title = _('navFavorites'); break;
|
|
case 'history': title = _('historyTitle'); break;
|
|
case 'recommendations': title = _('recommendationsTitle'); break;
|
|
case 'photos': title = _('navPhotos'); break;
|
|
case 'search':
|
|
title = state.currentParams.query.startsWith('actor:')
|
|
? _('contentFrom', state.currentParams.query.split(':')[1])
|
|
: _('searchResultsFor', state.currentParams.query);
|
|
break;
|
|
default: title = _('explore');
|
|
}
|
|
|
|
let targetTitleElement;
|
|
if (['stats', 'history', 'recommendations'].includes(state.currentView)) {
|
|
targetTitleElement = document.querySelector(`#${state.currentView}-section .section-title`);
|
|
} else {
|
|
targetTitleElement = mainTitleElement;
|
|
}
|
|
|
|
if(targetTitleElement && state.currentView !== 'photos') {
|
|
targetTitleElement.textContent = title;
|
|
}
|
|
}
|
|
|
|
export function applyFilters() {
|
|
state.currentParams.genre = document.getElementById('genre-filter').value;
|
|
state.currentParams.year = document.getElementById('year-filter').value;
|
|
state.currentParams.sort = document.getElementById('sort-filter').value;
|
|
state.currentPage = 1;
|
|
loadContent();
|
|
updateSectionTitle();
|
|
}
|
|
|
|
function resetFilters(triggerLoad = true) {
|
|
state.currentParams.genre = '';
|
|
state.currentParams.year = '';
|
|
state.currentParams.sort = 'popularity.desc';
|
|
document.getElementById('genre-filter').value = '';
|
|
document.getElementById('year-filter').value = '';
|
|
document.getElementById('sort-filter').value = 'popularity.desc';
|
|
if (triggerLoad) {
|
|
state.currentPage = 1;
|
|
loadContent();
|
|
}
|
|
}
|
|
|
|
function updateSortOptions() {
|
|
const sortOption = document.getElementById('sort-release-date');
|
|
const isMovie = state.currentParams.contentType === 'movie';
|
|
sortOption.textContent = _('sortRecent');
|
|
sortOption.value = isMovie ? 'release_date.desc' : 'first_air_date.desc';
|
|
}
|
|
|
|
async function loadGenres() {
|
|
const type = state.currentParams.contentType;
|
|
const select = document.getElementById('genre-filter');
|
|
select.innerHTML = '';
|
|
select.appendChild(createElement('option', { value: '', textContent: _('loadingGenres') }));
|
|
try {
|
|
const data = await fetchTMDB(`genre/${type}/list`);
|
|
select.innerHTML = '';
|
|
select.appendChild(createElement('option', { value: '', textContent: _('allGenres') }));
|
|
data.genres.forEach(genre => {
|
|
select.appendChild(createElement('option', { value: genre.id, textContent: genre.name }));
|
|
});
|
|
select.value = state.currentParams.genre || "";
|
|
} catch (error) {
|
|
select.innerHTML = '';
|
|
select.appendChild(createElement('option', { value: '', textContent: _('errorLoadingGenres') }));
|
|
}
|
|
}
|
|
|
|
function loadYears() {
|
|
const select = document.getElementById('year-filter');
|
|
select.innerHTML = '';
|
|
select.appendChild(createElement('option', { value: '', textContent: _('allYears') }));
|
|
const currentYear = new Date().getFullYear();
|
|
for (let year = currentYear; year >= 1900; year--) {
|
|
select.appendChild(createElement('option', { value: year, textContent: year }));
|
|
}
|
|
select.value = state.currentParams.year || "";
|
|
}
|
|
|
|
export async function loadContent(append = false) {
|
|
if (state.currentContentFetchController) state.currentContentFetchController.abort();
|
|
state.currentContentFetchController = new AbortController();
|
|
const signal = state.currentContentFetchController.signal;
|
|
|
|
if (state.isLoading) return;
|
|
state.isLoading = true;
|
|
|
|
const grid = document.getElementById('content-grid');
|
|
const loadMoreButton = document.getElementById('load-more');
|
|
|
|
if (!append) {
|
|
grid.innerHTML = '';
|
|
grid.appendChild(createElement('div', { className: 'col-12 text-center mt-5' }, [createElement('div', { className: 'spinner', style: 'position: static; margin: auto; display: block;' })]));
|
|
loadMoreButton.style.display = 'none';
|
|
} else {
|
|
loadMoreButton.disabled = true;
|
|
loadMoreButton.innerHTML = '';
|
|
loadMoreButton.appendChild(createElement('span', { className: 'spinner-border spinner-border-sm', role: 'status', "aria-hidden": 'true' }));
|
|
loadMoreButton.appendChild(document.createTextNode(` ${_('loading')}`));
|
|
}
|
|
|
|
try {
|
|
let endpoint = '';
|
|
const type = state.currentParams.contentType;
|
|
if (state.currentView === 'search' && state.currentParams.query) {
|
|
endpoint = `search/${type}?query=${encodeURIComponent(state.currentParams.query)}&page=${state.currentPage}&include_adult=false`;
|
|
} else {
|
|
endpoint = `discover/${type}?page=${state.currentPage}&sort_by=${state.currentParams.sort}&include_adult=false&vote_count.gte=50`;
|
|
if (state.currentParams.genre) endpoint += `&with_genres=${state.currentParams.genre}`;
|
|
const yearParam = type === 'movie' ? 'primary_release_year' : 'first_air_date_year';
|
|
if (state.currentParams.year) endpoint += `&${yearParam}=${state.currentParams.year}`;
|
|
}
|
|
|
|
const data = await fetchTMDB(endpoint, signal);
|
|
renderGrid(data.results, append);
|
|
loadMoreButton.style.display = (data.page < data.total_pages) ? 'block' : 'none';
|
|
if (!append) setupScrollEffects();
|
|
|
|
} catch (error) {
|
|
if (error.name !== 'AbortError') {
|
|
if (!append) {
|
|
grid.innerHTML = '';
|
|
grid.appendChild(createElement('div', { className: 'empty-state' }, [
|
|
createElement('i', { className: 'fas fa-exclamation-triangle' }),
|
|
createElement('p', { textContent: _('couldNotLoadContent') })
|
|
]));
|
|
}
|
|
}
|
|
} finally {
|
|
state.isLoading = false;
|
|
if (append) {
|
|
loadMoreButton.disabled = false;
|
|
loadMoreButton.textContent = _('loadMore');
|
|
}
|
|
}
|
|
}
|
|
|
|
function buscarContenidoLocal(title, type) {
|
|
if (!title || !type) return null;
|
|
const normalizedTitle = title.toLowerCase().trim();
|
|
const source = type === 'movie' ? state.localMovies : state.localSeries;
|
|
if (!Array.isArray(source)) return null;
|
|
|
|
return source.find(server =>
|
|
server && Array.isArray(server.titulos) &&
|
|
server.titulos.some(t => t && typeof t.title === 'string' && t.title.toLowerCase().trim() === normalizedTitle)
|
|
) || null;
|
|
}
|
|
|
|
|
|
function renderGrid(items, append = false) {
|
|
const grid = document.getElementById('content-grid');
|
|
if (!append) grid.innerHTML = '';
|
|
|
|
if (!items || items.length === 0) {
|
|
if (!append) {
|
|
const emptyStateTarget = document.getElementById('recommendations-grid') || grid;
|
|
const emptyIcon = state.currentView === 'recommendations' ? 'fa-user-astronaut' : 'fa-film';
|
|
const emptyText = state.currentView === 'recommendations' ? _('noRecommendations') : _('noContentFound');
|
|
emptyStateTarget.innerHTML = '';
|
|
emptyStateTarget.appendChild(createElement('div', { className: 'empty-state' }, [
|
|
createElement('i', { className: `fas ${emptyIcon} fa-3x mb-3` }),
|
|
createElement('p', { className: 'lead', textContent: emptyText })
|
|
]));
|
|
}
|
|
document.getElementById('load-more').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
items.forEach((item, index) => {
|
|
if (!item || !item.id) return;
|
|
const isMovie = item.media_type === 'movie' || (item.hasOwnProperty('title') && !item.hasOwnProperty('name'));
|
|
const itemType = isMovie ? 'movie' : 'tv';
|
|
|
|
const title = isMovie ? item.title : item.name;
|
|
const releaseDate = isMovie ? item.release_date : item.first_air_date;
|
|
const year = releaseDate ? releaseDate.slice(0, 4) : 'N/A';
|
|
const posterPath = item.poster_path ? `https://image.tmdb.org/t/p/w500${item.poster_path}` : 'img/no-poster.png';
|
|
const isAvailable = !!buscarContenidoLocal(title, itemType);
|
|
const isFavorite = state.favorites.some(fav => fav.id === item.id && fav.type === itemType);
|
|
const voteAvg = item.vote_average ? item.vote_average.toFixed(1) : 'N/A';
|
|
const ratingClass = voteAvg >= 7.5 ? 'rating-good' : (voteAvg >= 5.0 ? 'rating-ok' : 'rating-bad');
|
|
|
|
const card = createElement('div', { className: 'item-card', dataset: { id: item.id, type: itemType } });
|
|
card.innerHTML = `
|
|
<div class="item-poster" style="background-image: url('${posterPath}')"></div>
|
|
${voteAvg >= 7.8 ? '<span class="badge top-badge">TOP</span>' : ''}
|
|
${isAvailable ? `<span class="badge available-badge"><i class="fas fa-check-circle"></i> ${_('local')}</span>` : ''}
|
|
<div class="item-overlay">
|
|
<div class="item-actions">
|
|
<button class="action-btn info-btn" title="${_('moreInfo')}"><i class="fas fa-info-circle fa-lg"></i></button>
|
|
<button class="action-btn favorites-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? _('removeFromFavorites') : _('addToFavorites')}"><i class="fas ${isFavorite ? 'fa-heart-broken' : 'fa-heart'} fa-lg"></i></button>
|
|
${isAvailable ? `
|
|
<button class="action-btn download-btn" data-title="${title}" data-type="${itemType}" title="${_('miniplayerDownloadAlbum')}"><i class="fas fa-download fa-lg"></i></button>
|
|
<button class="action-btn play-btn" data-title="${title}" data-type="${itemType}" title="${_('addStream')}"><i class="fas fa-plus-circle fa-lg"></i></button>
|
|
` : `<button class="action-btn disabled-btn" disabled title="${_('notAvailable')}"><i class="fas fa-times-circle fa-lg text-muted"></i></button>`}
|
|
</div>
|
|
</div>
|
|
<div class="item-info">
|
|
<h3 class="item-title" title="${title}">${title}</h3>
|
|
<div class="item-meta">
|
|
<span><i class="fas fa-calendar-alt me-1"></i>${year}</span>
|
|
${voteAvg !== 'N/A' ? `<span class="item-rating ms-2 ${ratingClass}"><i class="fas fa-star me-1"></i> ${voteAvg}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
fragment.appendChild(card);
|
|
});
|
|
const targetGrid = state.currentView === 'recommendations' ? document.getElementById('recommendations-grid') : grid;
|
|
if(!append) targetGrid.innerHTML = '';
|
|
targetGrid.appendChild(fragment);
|
|
if (items.length > 0) setupScrollEffects();
|
|
}
|
|
|
|
export function showMainView() {
|
|
const detailsView = document.getElementById('item-details-view');
|
|
const mainView = document.getElementById('main-view');
|
|
const detailsContent = document.getElementById('item-details-content');
|
|
|
|
if (!state.lastClickedCardElement) {
|
|
detailsView.classList.remove('active');
|
|
document.body.classList.remove('details-view-active');
|
|
mainView.style.display = 'block';
|
|
mainView.style.opacity = '1';
|
|
window.scrollTo({ top: state.lastScrollPosition, behavior: 'auto' });
|
|
state.currentItemId = null;
|
|
state.currentItemType = null;
|
|
return;
|
|
}
|
|
|
|
const targetRect = state.lastClickedCardElement.getBoundingClientRect();
|
|
mainView.style.display = 'block';
|
|
|
|
const tl = gsap.timeline({
|
|
onComplete: () => {
|
|
detailsView.classList.remove('active');
|
|
document.body.classList.remove('details-view-active');
|
|
state.currentItemId = null;
|
|
state.currentItemType = null;
|
|
state.lastClickedCardElement = null;
|
|
window.scrollTo({ top: state.lastScrollPosition, behavior: 'auto' });
|
|
gsap.set(mainView, { clearProps: 'all' });
|
|
gsap.set(detailsView, { clearProps: 'all', display: 'none' });
|
|
}
|
|
});
|
|
|
|
tl.set(detailsView, { overflow: 'hidden' })
|
|
.to(detailsContent, { opacity: 0, duration: 0.3, ease: 'power2.in' })
|
|
.to(detailsView, {
|
|
top: targetRect.top,
|
|
left: targetRect.left,
|
|
width: targetRect.width,
|
|
height: targetRect.height,
|
|
borderRadius: '18px',
|
|
duration: 0.6,
|
|
ease: 'power3.inOut'
|
|
}, 0)
|
|
.to(mainView, { opacity: 1, scale: 1, duration: 0.5, ease: 'power3.out' }, "-=0.4");
|
|
}
|
|
|
|
export async function showItemDetails(itemId, contentType) {
|
|
if (!itemId || !contentType) return;
|
|
|
|
state.currentItemId = itemId;
|
|
state.currentItemType = contentType;
|
|
|
|
const detailsView = document.getElementById('item-details-view');
|
|
const mainView = document.getElementById('main-view');
|
|
const detailsContent = document.getElementById('item-details-content');
|
|
|
|
if (mainView.style.display !== 'none') state.lastScrollPosition = window.scrollY;
|
|
|
|
document.body.classList.add('details-view-active');
|
|
detailsContent.innerHTML = '';
|
|
detailsContent.appendChild(createElement('div', { className: 'text-center py-5' }, [createElement('div', { className: 'spinner', style: 'display: block; margin: auto; position: static;' })]));
|
|
|
|
detailsView.classList.add('active');
|
|
|
|
const startRect = state.lastClickedCardElement
|
|
? state.lastClickedCardElement.getBoundingClientRect()
|
|
: { top: window.innerHeight / 2, left: window.innerWidth / 2, width: 0, height: 0 };
|
|
|
|
const tl = gsap.timeline({
|
|
onComplete: () => {
|
|
mainView.style.display = 'none';
|
|
gsap.set(detailsView, { overflowY: 'auto' });
|
|
}
|
|
});
|
|
|
|
tl.set(detailsView, {
|
|
top: startRect.top,
|
|
left: startRect.left,
|
|
width: startRect.width,
|
|
height: startRect.height,
|
|
borderRadius: '18px',
|
|
overflow: 'hidden',
|
|
display: 'block'
|
|
})
|
|
.to(detailsView, {
|
|
top: 0,
|
|
left: 0,
|
|
width: '100vw',
|
|
height: '100vh',
|
|
borderRadius: '0px',
|
|
duration: 0.7,
|
|
ease: 'power3.inOut'
|
|
})
|
|
.to(mainView, {
|
|
opacity: 0,
|
|
scale: 0.98,
|
|
duration: 0.6,
|
|
ease: 'power2.inOut'
|
|
}, 0);
|
|
|
|
gsap.fromTo(detailsContent,
|
|
{ opacity: 0 },
|
|
{ opacity: 1, duration: 0.6, ease: 'power2.out', delay: 0.5 }
|
|
);
|
|
|
|
state.isLoading = true;
|
|
try {
|
|
const isMovie = contentType === 'movie';
|
|
let appendToResponse = 'videos,credits,keywords,release_dates,external_ids,similar';
|
|
if (!isMovie) {
|
|
appendToResponse += ',content_ratings,aggregate_credits';
|
|
}
|
|
|
|
const item = await fetchTMDB(`${contentType}/${itemId}?append_to_response=${appendToResponse}`);
|
|
|
|
if (!isMovie && item.seasons && item.seasons.length > 0) {
|
|
const seasonPromises = item.seasons
|
|
.filter(s => s.season_number > 0)
|
|
.map(s => fetchTMDB(`${contentType}/${itemId}/season/${s.season_number}`).catch(()=>null));
|
|
const seasonsData = await Promise.all(seasonPromises);
|
|
item.seasons_with_episodes = seasonsData.filter(s => s !== null);
|
|
}
|
|
|
|
updateUserData(item);
|
|
await renderItemDetails(item);
|
|
|
|
} catch (error) {
|
|
detailsContent.innerHTML = '';
|
|
detailsContent.appendChild(createElement('div', { className: 'alert alert-danger mx-3 my-5 text-center' }, [
|
|
createElement('h4', { textContent: _('errorLoadingDetails') }),
|
|
createElement('p', { textContent: error.message })
|
|
]));
|
|
} finally {
|
|
state.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function renderItemDetails(item) {
|
|
const detailsContent = document.getElementById('item-details-content');
|
|
const isMovie = state.currentItemType === 'movie';
|
|
const title = isMovie ? item.title : item.name;
|
|
const backdropPath = item.backdrop_path ? `https://image.tmdb.org/t/p/original${item.backdrop_path}` : '';
|
|
const posterPath = item.poster_path ? `https://image.tmdb.org/t/p/w500${item.poster_path}` : 'img/no-poster.png';
|
|
const tagline = item.tagline || '';
|
|
const overview = item.overview || _('noSynopsis');
|
|
const releaseDate = isMovie ? item.release_date : item.first_air_date;
|
|
const year = releaseDate ? releaseDate.slice(0, 4) : 'N/A';
|
|
const voteAverage = item.vote_average ? item.vote_average.toFixed(1) : 'N/A';
|
|
const genres = item.genres || [];
|
|
const trailer = item.videos?.results?.find(v => v.site === 'YouTube' && v.type === 'Trailer');
|
|
const isAvailable = !!buscarContenidoLocal(title, state.currentItemType);
|
|
const isFavorite = state.favorites.some(fav => fav.id === item.id && fav.type === state.currentItemType);
|
|
|
|
const imdbId = item.external_ids?.imdb_id;
|
|
const crew = (isMovie ? item.credits?.crew : item.aggregate_credits?.crew) || [];
|
|
const director = crew.find(c => c.job === 'Director');
|
|
const writer = crew.find(c => c.job === 'Screenplay' || c.job === 'Writer' || c.job === 'Story');
|
|
|
|
detailsContent.innerHTML = '';
|
|
detailsContent.innerHTML = `
|
|
${backdropPath ? `<div class="details-backdrop-container"><img src="${backdropPath}" class="details-backdrop-img"><div class="details-backdrop-overlay"></div></div>` : ''}
|
|
<div class="item-details-container">
|
|
<div class="item-details-header">
|
|
<div class="item-details-poster-wrapper"><img src="${posterPath}" class="item-details-poster"></div>
|
|
<div class="item-details-content">
|
|
<h1 class="item-details-title">${title}</h1>
|
|
${tagline ? `<p class="item-details-tagline fst-italic">"${tagline}"</p>` : ''}
|
|
<div class="item-details-meta">
|
|
${voteAverage !== 'N/A' ? `<span class="item-details-meta-item"><i class="fas fa-star"></i> ${voteAverage}/10</span>` : ''}
|
|
<span class="item-details-meta-item"><i class="fas fa-calendar-alt"></i> ${year}</span>
|
|
${isMovie && item.runtime ? `<span class="item-details-meta-item"><i class="fas fa-clock"></i> ${_('runtimeMinutes', String(item.runtime))}</span>` : ''}
|
|
${!isMovie && item.number_of_seasons ? `<span class="item-details-meta-item"><i class="fas fa-tv"></i> ${_('seasonsCount', String(item.number_of_seasons))}</span>` : ''}
|
|
</div>
|
|
<div class="item-details-genres mb-3">${genres.map(g => `<span class="genre-badge">${g.name}</span>`).join('')}</div>
|
|
<div class="item-details-crew">
|
|
${director ? `<p><strong>${_('director')}</strong> ${director.name}</p>` : ''}
|
|
${writer ? `<p><strong>${_('writer')}</strong> ${writer.name}</p>` : ''}
|
|
</div>
|
|
${imdbId ? `<div class="item-details-external-links mt-3 mb-4"><a href="https://www.imdb.com/title/${imdbId}" target="_blank" rel="noopener"><i class="fab fa-imdb"></i>${_('viewOnImdb')}</a></div>` : ''}
|
|
<h3 class="section-subtitle mt-4">${_('synopsis')}</h3>
|
|
<p class="item-details-overview mb-4">${overview}</p>
|
|
<div class="item-details-actions">
|
|
${trailer ? `<button class="btn btn-outline-light trailer-btn me-2" data-trailer-key="${trailer.key}"><i class="fab fa-youtube me-1"></i> ${_('watchTrailer')}</button>` : ''}
|
|
<button class="btn ${isFavorite ? 'btn-danger' : 'btn-outline-danger'} favorites-btn me-2" data-id="${item.id}" data-type="${state.currentItemType}"><i class="fas ${isFavorite ? 'fa-heart-broken' : 'fa-heart'} me-1"></i> ${isFavorite ? _('removeFromFavorites') : _('addToFavorites')}</button>
|
|
${isAvailable ? `
|
|
<button class="btn btn-success play-btn me-2" data-title="${title}" data-type="${state.currentItemType}"><i class="fas fa-plus-circle me-1"></i> ${_('addStream')}</button>
|
|
<button class="btn btn-info download-btn" data-title="${title}" data-type="${state.currentItemType}"><i class="fas fa-download me-1"></i> ${_('miniplayerDownloadAlbum')}</button>
|
|
` : `<button class="btn btn-secondary" disabled><i class="fas fa-times-circle me-1"></i> ${_('notAvailable')}</button>`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="item-details-section">
|
|
${(isMovie ? item.credits?.cast?.length > 0 : item.aggregate_credits?.cast?.length > 0) ? `
|
|
<h3 class="section-subtitle">${_('mainCast')}</h3>
|
|
<div class="cast-grid">
|
|
${((isMovie ? item.credits.cast : item.aggregate_credits.cast) || []).slice(0, 12).map(actor => `
|
|
<div class="cast-card" data-actor-id="${actor.id}">
|
|
<img src="${actor.profile_path ? `https://image.tmdb.org/t/p/w200${actor.profile_path}` : 'img/no-profile.png'}" class="cast-photo">
|
|
<div class="cast-name">${actor.name}</div>
|
|
<div class="cast-character">${actor.roles ? actor.roles.map(r=>r.character).slice(0,2).join(', ') : actor.character}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>` : ''}
|
|
</div>
|
|
${!isMovie && item.seasons_with_episodes && item.seasons_with_episodes.length > 0 ? `
|
|
<div class="item-details-section">
|
|
<h3 class="section-subtitle">${_('seasonsAndEpisodes')}</h3>
|
|
<div class="accordion seasons-accordion" id="seasonsAccordion">
|
|
${item.seasons_with_episodes.map((season, index) => `
|
|
<div class="accordion-item">
|
|
<h2 class="accordion-header" id="heading-season-${season.id}">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-season-${season.id}" aria-expanded="false" aria-controls="collapse-season-${season.id}">
|
|
<div class="season-info">
|
|
<img src="${season.poster_path ? `https://image.tmdb.org/t/p/w200${season.poster_path}` : posterPath}" class="season-poster">
|
|
<div class="season-details">
|
|
<span class="season-title">${season.name}</span>
|
|
<div class="season-meta">
|
|
<span><i class="fas fa-calendar-alt"></i> ${season.air_date ? new Date(season.air_date).getFullYear() : 'N/A'}</span>
|
|
<span><i class="fas fa-list-ol"></i> ${_('episodesCount', String(season.episodes.length))}</span>
|
|
</div>
|
|
<p class="season-overview d-none d-md-block">${(season.overview || '').substring(0,120)}${season.overview && season.overview.length > 120 ? '...' : ''}</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</h2>
|
|
<div id="collapse-season-${season.id}" class="accordion-collapse collapse" aria-labelledby="heading-season-${season.id}" data-bs-parent="#seasonsAccordion">
|
|
<div class="accordion-body season-episodes">
|
|
${season.episodes.map(ep => `
|
|
<div class="episode-card">
|
|
<span class="episode-number">${ep.episode_number}</span>
|
|
<div class="episode-info">
|
|
<h5 class="episode-title">${ep.name}</h5>
|
|
<div class="episode-meta">
|
|
<span><i class="far fa-calendar-alt"></i> ${ep.air_date || 'N/A'}</span>
|
|
<span><i class="far fa-star"></i> ${ep.vote_average.toFixed(1)}/10</span>
|
|
</div>
|
|
<p class="episode-overview">${ep.overview || _('noSynopsis')}</p>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="item-details-section">
|
|
${item.similar?.results?.length > 0 ? `
|
|
<h3 class="section-subtitle">${_('similarContent')}</h3>
|
|
<div class="similar-items-grid">
|
|
${item.similar.results.slice(0, 8).map(similar => `
|
|
<div class="similar-item-card" data-id="${similar.id}" data-type="${similar.media_type || state.currentItemType}">
|
|
<img src="${similar.poster_path ? `https://image.tmdb.org/t/p/w300${similar.poster_path}` : 'img/no-poster.png'}" class="similar-item-poster">
|
|
<div class="similar-item-info">
|
|
<div class="similar-item-title">${similar.title || similar.name}</div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updateUserData(item) {
|
|
if (!item || !item.id || !state.currentItemType) return;
|
|
const { userHistory, userPreferences } = state;
|
|
const contentType = state.currentItemType;
|
|
|
|
const historyItem = { id: item.id, type: contentType, title: contentType === 'movie' ? item.title : item.name, poster: item.poster_path, timestamp: Date.now() };
|
|
state.userHistory = [historyItem, ...userHistory.filter(h => !(h.id === item.id && h.type === contentType))].slice(0, 100);
|
|
|
|
item.genres?.forEach(g => { userPreferences.genres[g.id] = (userPreferences.genres[g.id] || 0) + 1; });
|
|
(item.keywords?.keywords || item.keywords?.results || []).slice(0, 10).forEach(kw => { userPreferences.keywords[kw.id] = (userPreferences.keywords[kw.id] || 0) + 1; });
|
|
if (item.vote_average > 0) userPreferences.ratings.push(item.vote_average);
|
|
if (userPreferences.ratings.length > 100) userPreferences.ratings.shift();
|
|
|
|
const credits = contentType === 'tv' && item.aggregate_credits ? item.aggregate_credits : item.credits;
|
|
credits?.cast?.slice(0, 15).forEach(a => { userPreferences.cast[a.id] = (userPreferences.cast[a.id] || 0) + 1; });
|
|
credits?.crew?.slice(0, 15).forEach(c => { userPreferences.crew[c.id] = (userPreferences.crew[c.id] || 0) + 1; });
|
|
|
|
localStorage.setItem('cineplex_userHistory', JSON.stringify(state.userHistory));
|
|
localStorage.setItem('cineplex_userPreferences', JSON.stringify(userPreferences));
|
|
}
|
|
|
|
export function initializeFavorites() {
|
|
try {
|
|
const saved = localStorage.getItem('cineplex_favorites');
|
|
state.favorites = saved ? JSON.parse(saved) : [];
|
|
if (!Array.isArray(state.favorites)) state.favorites = [];
|
|
} catch {
|
|
state.favorites = [];
|
|
}
|
|
}
|
|
|
|
export function toggleFavorite(itemId, itemType) {
|
|
if (!itemId || !itemType) return;
|
|
const index = state.favorites.findIndex(fav => fav.id === itemId && fav.type === itemType);
|
|
let isFavoriteNow = false;
|
|
|
|
if (index === -1) {
|
|
state.favorites.push({ id: itemId, type: itemType });
|
|
showNotification(_('addedToFavorites'), 'success');
|
|
isFavoriteNow = true;
|
|
} else {
|
|
state.favorites.splice(index, 1);
|
|
showNotification(_('removedFromFavorites'), 'info');
|
|
}
|
|
localStorage.setItem('cineplex_favorites', JSON.stringify(state.favorites));
|
|
updateFavoriteButtonVisuals(itemId, itemType, isFavoriteNow);
|
|
if (state.currentView === 'favorites') loadFavorites();
|
|
}
|
|
|
|
function updateFavoriteButtonVisuals(itemId, itemType, isFavorite) {
|
|
document.querySelectorAll(`.item-card[data-id="${itemId}"][data-type="${itemType}"] .favorites-btn`).forEach(btn => {
|
|
btn.classList.toggle('active', isFavorite);
|
|
btn.querySelector('i').className = `fas ${isFavorite ? 'fa-heart-broken' : 'fa-heart'} fa-lg`;
|
|
});
|
|
if (document.getElementById('item-details-view').classList.contains('active') && state.currentItemId === itemId && state.currentItemType === itemType) {
|
|
const detailsBtn = document.querySelector('#item-details-content .favorites-btn');
|
|
if(detailsBtn) {
|
|
detailsBtn.classList.toggle('btn-danger', isFavorite);
|
|
detailsBtn.classList.toggle('btn-outline-danger', !isFavorite);
|
|
detailsBtn.querySelector('i').className = `fas ${isFavorite ? 'fa-heart-broken' : 'fa-heart'} me-1`;
|
|
detailsBtn.lastChild.textContent = ` ${isFavorite ? _('removeFromFavorites') : _('addToFavorites')}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function loadFavorites() {
|
|
const grid = document.getElementById('content-grid');
|
|
grid.innerHTML = '';
|
|
grid.appendChild(createElement('div', { className: 'col-12 text-center mt-5' }, [createElement('div', { className: 'spinner', style: 'position: static; margin: auto; display: block;' })]));
|
|
|
|
if (state.favorites.length === 0) {
|
|
grid.innerHTML = '';
|
|
grid.appendChild(createElement('div', { className: 'empty-state' }, [
|
|
createElement('i', { className: 'far fa-heart fa-3x mb-3' }),
|
|
createElement('p', { className: 'lead', textContent: _('noFavorites') })
|
|
]));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const favoritePromises = state.favorites.map(fav => fetchTMDB(`${fav.type}/${fav.id}`).catch(()=>null));
|
|
const favoriteItems = (await Promise.all(favoritePromises)).filter(item => item !== null);
|
|
renderGrid(favoriteItems, false);
|
|
} catch (error) {
|
|
grid.innerHTML = '';
|
|
grid.appendChild(createElement('div', { className: 'empty-state' }, [
|
|
createElement('i', { className: 'fas fa-exclamation-triangle' }),
|
|
createElement('p', { textContent: _('errorLoadingFavorites') })
|
|
]));
|
|
}
|
|
}
|
|
|
|
export function displayHistory() {
|
|
const listContainer = document.getElementById('history-list');
|
|
listContainer.innerHTML = "";
|
|
if (state.userHistory.length === 0) {
|
|
listContainer.appendChild(createElement('div', { className: 'empty-state' }, [
|
|
createElement('i', { className: 'fas fa-history fa-3x mb-3' }),
|
|
createElement('p', { className: 'lead', textContent: _('historyEmpty') }),
|
|
createElement('p', { className: 'text-muted', textContent: _('historyEmptySub') })
|
|
]));
|
|
document.getElementById('clear-history-btn').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
document.getElementById('clear-history-btn').style.display = 'inline-flex';
|
|
const fragment = document.createDocumentFragment();
|
|
[...state.userHistory].sort((a,b) => b.timestamp - a.timestamp).forEach(item => {
|
|
const posterUrl = item.poster ? `https://image.tmdb.org/t/p/w92${item.poster}` : 'img/no-poster.png';
|
|
const isAvailable = !!buscarContenidoLocal(item.title, item.type);
|
|
const historyItem = createElement('div', { className: 'history-item', dataset: { id: item.id, type: item.type, title: item.title } });
|
|
historyItem.innerHTML = `
|
|
<div class="history-main-content">
|
|
<img src="${posterUrl}" class="history-poster info-btn">
|
|
<div class="history-info info-btn">
|
|
<div class="history-title-wrapper">
|
|
<div class="history-title">${item.title}</div>
|
|
${isAvailable ? `<span class="badge local-badge-history">${_('local')}</span>` : ''}
|
|
</div>
|
|
<div class="history-meta">${_('viewed')} ${getRelativeTime(item.timestamp)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="history-actions">
|
|
<button class="action-btn info-btn" title="${_('moreInfo')}"><i class="fas fa-info-circle"></i></button>
|
|
<button class="action-btn trailer-btn" title="${_('watchTrailer')}"><i class="fab fa-youtube"></i></button>
|
|
<button class="action-btn play-btn" title="${_('addStream')}" ${!isAvailable ? 'disabled' : ''}><i class="fas fa-plus-circle"></i></button>
|
|
<button class="action-btn delete-btn" title="${_('clearHistory')}"><i class="fas fa-trash-alt"></i></button>
|
|
</div>`;
|
|
fragment.appendChild(historyItem);
|
|
});
|
|
listContainer.appendChild(fragment);
|
|
}
|
|
|
|
export function deleteHistoryItem(id, type, element) {
|
|
const numericId = Number(id);
|
|
state.userHistory = state.userHistory.filter(item => !(item.id === numericId && item.type === type));
|
|
localStorage.setItem('cineplex_userHistory', JSON.stringify(state.userHistory));
|
|
|
|
gsap.to(element, {
|
|
height: 0,
|
|
opacity: 0,
|
|
paddingTop: 0,
|
|
paddingBottom: 0,
|
|
marginTop: 0,
|
|
marginBottom: 0,
|
|
duration: 0.5,
|
|
ease: "power3.inOut",
|
|
onComplete: () => {
|
|
element.remove();
|
|
if (state.userHistory.length === 0) {
|
|
displayHistory();
|
|
}
|
|
}
|
|
});
|
|
showNotification(_('historyItemDeleted'), "info");
|
|
}
|
|
|
|
export function clearAllHistory() {
|
|
if (confirm(_('confirmClearHistory'))) {
|
|
state.userHistory = [];
|
|
localStorage.setItem('cineplex_userHistory', JSON.stringify(state.userHistory));
|
|
displayHistory();
|
|
showNotification(_('historyCleared'), "success");
|
|
}
|
|
}
|
|
|
|
export async function getTrailerKey(id, type) {
|
|
try {
|
|
const data = await fetchTMDB(`${type}/${id}/videos`);
|
|
const trailer = data.results.find(v => v.site === 'YouTube' && v.type === 'Trailer') || data.results.find(v => v.site === 'YouTube');
|
|
return trailer ? trailer.key : null;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function loadRecommendations() {
|
|
const grid = document.getElementById('recommendations-grid');
|
|
grid.innerHTML = '';
|
|
grid.appendChild(createElement('div', { className: 'col-12 text-center mt-5' }, [createElement('div', { className: 'spinner', style: 'position: static; margin: auto; display: block;' })]));
|
|
|
|
const cachedRecs = sessionStorage.getItem('cineplex_recommendations');
|
|
if (cachedRecs) {
|
|
const { timestamp, data } = JSON.parse(cachedRecs);
|
|
if (Date.now() - timestamp < 1000 * 60 * 30) {
|
|
renderGrid(data);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const seedPool = [...state.favorites, ...state.userHistory.slice(0, 10)];
|
|
if (seedPool.length === 0) {
|
|
renderGrid([]);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const seed = seedPool[Math.floor(Math.random() * seedPool.length)];
|
|
const endpoint = `${seed.type}/${seed.id}/recommendations?page=1`;
|
|
const data = await fetchTMDB(endpoint);
|
|
|
|
const seenIds = new Set(state.userHistory.map(item => item.id));
|
|
const recommendations = data.results.filter(rec => !seenIds.has(rec.id)).slice(0, 12);
|
|
|
|
if (recommendations.length > 0) {
|
|
renderGrid(recommendations);
|
|
sessionStorage.setItem('cineplex_recommendations', JSON.stringify({ timestamp: Date.now(), data: recommendations }));
|
|
} else {
|
|
renderGrid([]);
|
|
}
|
|
} catch(error) {
|
|
grid.innerHTML = '';
|
|
grid.appendChild(createElement('div', { className: 'empty-state' }, [
|
|
createElement('i', { className: 'fas fa-exclamation-triangle' }),
|
|
createElement('p', { textContent: _('errorGeneratingRecommendations') })
|
|
]));
|
|
}
|
|
}
|
|
|
|
async function populateStatsTokenFilter() {
|
|
const select = document.getElementById('stats-token-filter');
|
|
const currentValue = select.value;
|
|
select.innerHTML = '';
|
|
select.appendChild(createElement('option', { value: 'all', textContent: _('statsAllTokens') }));
|
|
|
|
try {
|
|
const tokensData = await getFromDB('tokens');
|
|
const primaryTokens = [...new Set(tokensData.map(t => t.token))];
|
|
|
|
const connections = await getFromDB('conexiones_locales');
|
|
const tokenNames = {};
|
|
connections.forEach(conn => {
|
|
if (conn.tokenPrincipal && !tokenNames[conn.tokenPrincipal]) {
|
|
tokenNames[conn.tokenPrincipal] = conn.nombre || `Token...${conn.tokenPrincipal.slice(-4)}`;
|
|
}
|
|
});
|
|
|
|
primaryTokens.forEach(token => {
|
|
select.appendChild(createElement('option', { value: token, textContent: tokenNames[token] || `Token...${token.slice(-4)}` }));
|
|
});
|
|
|
|
if (currentValue) {
|
|
select.value = currentValue;
|
|
}
|
|
|
|
} catch(e) {
|
|
select.innerHTML = '';
|
|
select.appendChild(createElement('option', { value: 'all', textContent: _('errorLoadingTokens') }));
|
|
}
|
|
}
|
|
|
|
export async function generateStatistics() {
|
|
const loader = document.getElementById('stats-loader');
|
|
const content = document.getElementById('stats-content');
|
|
loader.style.display = 'block';
|
|
content.style.display = 'none';
|
|
|
|
Object.values(charts).forEach(chart => chart.destroy());
|
|
charts = {};
|
|
|
|
await populateStatsTokenFilter();
|
|
|
|
try {
|
|
const selectedToken = document.getElementById('stats-token-filter').value;
|
|
|
|
const filterByToken = (data) => {
|
|
if (selectedToken === 'all') return data;
|
|
return data.filter(server => server.tokenPrincipal === selectedToken);
|
|
};
|
|
|
|
const filteredMovies = filterByToken(state.localMovies);
|
|
const filteredSeries = filterByToken(state.localSeries);
|
|
const filteredArtists = filterByToken(state.localArtists);
|
|
|
|
const allMovieItems = filteredMovies.flatMap(s => s.titulos);
|
|
const allSeriesItems = filteredSeries.flatMap(s => s.titulos);
|
|
|
|
const uniqueMovieTitles = new Set(allMovieItems.map(item => item.title));
|
|
const uniqueSeriesTitles = new Set(allSeriesItems.map(item => item.title));
|
|
const uniqueArtists = new Set(filteredArtists.flatMap(s => s.titulos.map(t => t.title)));
|
|
|
|
animateValue('total-movies', 0, uniqueMovieTitles.size, 1000);
|
|
animateValue('total-series', 0, uniqueSeriesTitles.size, 1000);
|
|
animateValue('total-artists', 0, uniqueArtists.size, 1000);
|
|
|
|
const allTokens = await getFromDB('tokens');
|
|
const allConnections = await getFromDB('conexiones_locales');
|
|
|
|
const filteredTokens = selectedToken === 'all'
|
|
? allTokens
|
|
: allTokens.filter(t => t.token === selectedToken);
|
|
|
|
const filteredConnections = selectedToken === 'all'
|
|
? allConnections
|
|
: allConnections.filter(c => c.tokenPrincipal === selectedToken);
|
|
|
|
animateValue('total-tokens', 0, filteredTokens.length, 1000);
|
|
animateValue('total-servers', 0, filteredConnections.length, 1000);
|
|
|
|
updateTokenDetailsCard(selectedToken, filteredConnections);
|
|
|
|
const movieGenres = processLocalGenres(allMovieItems);
|
|
const seriesGenres = processLocalGenres(allSeriesItems);
|
|
const allDecades = processLocalDecades([...allMovieItems, ...allSeriesItems]);
|
|
|
|
createGenreChart('movie-genres-chart', movieGenres, _('statsChartMoviesByGenre'));
|
|
createGenreChart('series-genres-chart', seriesGenres, _('statsChartSeriesByGenre'));
|
|
createDecadeChart('decade-chart', allDecades, _('statsChartByDecade'));
|
|
|
|
loader.style.display = 'none';
|
|
content.style.display = 'grid';
|
|
gsap.from(".stats-card, .chart-container", {
|
|
duration: 0.7,
|
|
y: 30,
|
|
opacity: 0,
|
|
stagger: 0.1,
|
|
ease: "power3.out"
|
|
});
|
|
|
|
} catch (error) {
|
|
loader.innerHTML = '';
|
|
loader.appendChild(createElement('div', { className: 'empty-state' }, [
|
|
createElement('i', { className: 'fas fa-exclamation-triangle' }),
|
|
createElement('p', { textContent: _('errorGeneratingStats') })
|
|
]));
|
|
}
|
|
}
|
|
|
|
function updateTokenDetailsCard(selectedToken, allConnections) {
|
|
const card = document.getElementById('token-details-card');
|
|
const serverList = document.getElementById('token-server-list');
|
|
|
|
if (selectedToken === 'all') {
|
|
card.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const associatedServers = allConnections.filter(c => c.tokenPrincipal === selectedToken);
|
|
|
|
serverList.innerHTML = '';
|
|
if (associatedServers.length === 0) {
|
|
serverList.appendChild(createElement('li', { textContent: _('noServersForToken') }));
|
|
} else {
|
|
associatedServers.forEach(s => {
|
|
const li = createElement('li');
|
|
li.innerHTML = `<strong>${s.nombre}:</strong> <code>${s.token.slice(0, 5)}...${s.token.slice(-5)}</code>`;
|
|
serverList.appendChild(li);
|
|
});
|
|
}
|
|
card.style.display = 'block';
|
|
}
|
|
|
|
function processLocalGenres(items) {
|
|
const genreCounts = {};
|
|
items.forEach(item => {
|
|
const genre = item.genre || _('noGenre');
|
|
genreCounts[genre] = (genreCounts[genre] || 0) + 1;
|
|
});
|
|
return genreCounts;
|
|
}
|
|
|
|
function processLocalDecades(items) {
|
|
const decadeCounts = {};
|
|
items.forEach(item => {
|
|
if (item.year && !isNaN(item.year)) {
|
|
const decade = Math.floor(parseInt(item.year, 10) / 10) * 10;
|
|
decadeCounts[decade] = (decadeCounts[decade] || 0) + 1;
|
|
}
|
|
});
|
|
return decadeCounts;
|
|
}
|
|
|
|
|
|
async function createGenreChart(canvasId, genreData, label) {
|
|
const sortedGenres = Object.entries(genreData)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.slice(0, 15);
|
|
|
|
const labels = sortedGenres.map(([name]) => name);
|
|
const data = sortedGenres.map(([, count]) => count);
|
|
|
|
const ctx = document.getElementById(canvasId).getContext('2d');
|
|
charts[canvasId] = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: label,
|
|
data: data,
|
|
backgroundColor: 'rgba(0, 224, 255, 0.6)',
|
|
borderColor: 'rgba(0, 224, 255, 1)',
|
|
borderWidth: 1,
|
|
borderRadius: 5,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
indexAxis: 'y',
|
|
scales: { y: { ticks: { color: 'rgba(240, 240, 245, 0.8)' } }, x: { ticks: { color: 'rgba(240, 240, 245, 0.8)' } } },
|
|
plugins: { legend: { display: false }, tooltip: { bodyFont: { size: 14 }, titleFont: { size: 16 } } }
|
|
}
|
|
});
|
|
}
|
|
|
|
function createDecadeChart(canvasId, decadeData, label) {
|
|
const sortedDecades = Object.entries(decadeData).sort(([a], [b]) => a - b);
|
|
const labels = sortedDecades.map(([decade]) => `${decade}s`);
|
|
const data = sortedDecades.map(([, count]) => count);
|
|
|
|
const ctx = document.getElementById(canvasId).getContext('2d');
|
|
charts[canvasId] = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: label,
|
|
data: data,
|
|
fill: true,
|
|
backgroundColor: 'rgba(0, 114, 255, 0.2)',
|
|
borderColor: 'rgba(0, 114, 255, 1)',
|
|
tension: 0.3,
|
|
pointBackgroundColor: 'rgba(0, 114, 255, 1)',
|
|
pointBorderColor: '#fff',
|
|
pointHoverRadius: 7,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: { y: { ticks: { color: 'rgba(240, 240, 245, 0.8)', beginAtZero: true } }, x: { ticks: { color: 'rgba(240, 240, 245, 0.8)' } } },
|
|
plugins: { legend: { display: false } }
|
|
}
|
|
});
|
|
}
|
|
|
|
function animateValue(id, start, end, duration) {
|
|
const obj = document.getElementById(id);
|
|
if (!obj) return;
|
|
let startTimestamp = null;
|
|
const step = (timestamp) => {
|
|
if (!startTimestamp) startTimestamp = timestamp;
|
|
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
|
|
obj.innerHTML = Math.floor(progress * (end - start) + start).toLocaleString(state.settings.language);
|
|
if (progress < 1) {
|
|
window.requestAnimationFrame(step);
|
|
}
|
|
};
|
|
window.requestAnimationFrame(step);
|
|
}
|
|
|
|
export async function searchByActor(actorId, actorName) {
|
|
if (state.isLoading) return;
|
|
showNotification(_('searchingActorContent', actorName), 'info');
|
|
|
|
if (document.getElementById('item-details-view').classList.contains('active')) {
|
|
const mainContent = document.querySelector('.main-content');
|
|
const topBarHeight = document.querySelector('.top-bar')?.offsetHeight || 60;
|
|
const targetScrollTop = mainContent ? mainContent.offsetTop - topBarHeight : 0;
|
|
state.lastScrollPosition = targetScrollTop;
|
|
showMainView();
|
|
}
|
|
|
|
state.currentView = 'search';
|
|
document.getElementById('search-input').value = ``;
|
|
state.currentParams.query = `actor:${actorName}`;
|
|
state.currentPage = 1;
|
|
updateSectionTitle();
|
|
updateActiveNav(state.currentParams.contentType);
|
|
|
|
const grid = document.getElementById('content-grid');
|
|
grid.innerHTML = '';
|
|
grid.appendChild(createElement('div', { className: 'col-12 text-center mt-5' }, [createElement('div', { className: 'spinner', style: 'display: block; margin: auto; position: static;' })]));
|
|
document.querySelector('.filters').style.display = 'none';
|
|
|
|
try {
|
|
const endpoint = `discover/${state.currentParams.contentType}?with_cast=${actorId}&sort_by=popularity.desc&page=1`;
|
|
const data = await fetchTMDB(endpoint);
|
|
renderGrid(data.results, false);
|
|
document.getElementById('load-more').style.display = (data.page < data.total_pages) ? 'block' : 'none';
|
|
} catch (error) {
|
|
grid.innerHTML = '';
|
|
grid.appendChild(createElement('div', { className: 'empty-state' }, [
|
|
createElement('i', { className: 'fas fa-exclamation-triangle' }),
|
|
createElement('p', { textContent: _('errorLoadingActorContent', actorName) })
|
|
]));
|
|
}
|
|
}
|
|
|
|
export async function initializeHeroSection() {
|
|
const heroSection = document.getElementById('hero-section');
|
|
if (heroSection.style.display === 'none') return;
|
|
|
|
try {
|
|
const type = Math.random() > 0.5 ? 'movie' : 'tv';
|
|
const data = await fetchTMDB(`${type}/popular?page=1`);
|
|
const popularItems = data.results.filter(i => i.backdrop_path && i.overview).slice(0, 8);
|
|
|
|
if (popularItems.length === 0) {
|
|
heroSection.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const bg1 = document.querySelector('.hero-background-1');
|
|
const bg2 = document.querySelector('.hero-background-2');
|
|
const content = document.querySelector('.hero-content');
|
|
let currentBg = bg1;
|
|
let nextBg = bg2;
|
|
let currentIndex = -1;
|
|
|
|
function changeHeroSlide(isFirst = false) {
|
|
currentIndex = (currentIndex + 1) % popularItems.length;
|
|
const item = popularItems[currentIndex];
|
|
const nextImage = new Image();
|
|
nextImage.src = `https://image.tmdb.org/t/p/original${item.backdrop_path}`;
|
|
|
|
nextImage.onload = () => {
|
|
updateHeroContent(item);
|
|
|
|
const heroElements = [
|
|
content.querySelector('.hero-title'),
|
|
content.querySelector('.hero-subtitle'),
|
|
...content.querySelectorAll('.hero-meta-item'),
|
|
content.querySelector('.hero-buttons')
|
|
];
|
|
|
|
if (isFirst) {
|
|
gsap.set(currentBg, { backgroundImage: `url(${nextImage.src})` });
|
|
gsap.to(currentBg, { autoAlpha: 1, duration: 1.5, ease: 'power2.out' });
|
|
gsap.to(content, { autoAlpha: 1, duration: 1, delay: 0.5 });
|
|
gsap.fromTo(currentBg, { scale: 1.15, transformOrigin: 'center center' }, { scale: 1, duration: 12, ease: 'none' });
|
|
} else {
|
|
const tl = gsap.timeline({
|
|
onComplete: () => {
|
|
gsap.set(currentBg, { autoAlpha: 0 });
|
|
const temp = currentBg;
|
|
currentBg = nextBg;
|
|
nextBg = temp;
|
|
}
|
|
});
|
|
|
|
tl.to(heroElements, {
|
|
autoAlpha: 0,
|
|
y: 30,
|
|
stagger: 0.08,
|
|
duration: 0.6,
|
|
ease: 'power3.in'
|
|
});
|
|
|
|
gsap.set(nextBg, { backgroundImage: `url(${nextImage.src})` });
|
|
tl.to(nextBg, { autoAlpha: 1, duration: 1.5, ease: 'power2.inOut' }, '-=0.5');
|
|
|
|
gsap.fromTo(nextBg, { scale: 1.15, transformOrigin: 'center center' }, { scale: 1, duration: 12, ease: 'none' });
|
|
|
|
tl.fromTo(heroElements, {
|
|
y: -30,
|
|
autoAlpha: 0
|
|
}, {
|
|
y: 0,
|
|
autoAlpha: 1,
|
|
stagger: 0.1,
|
|
duration: 0.8,
|
|
ease: 'power3.out'
|
|
}, '>-1');
|
|
}
|
|
};
|
|
}
|
|
|
|
gsap.set(content, { autoAlpha: 0 });
|
|
gsap.set([bg1, bg2], { autoAlpha: 0 });
|
|
|
|
changeHeroSlide(true);
|
|
setInterval(() => changeHeroSlide(false), 12000);
|
|
|
|
} catch (error) {
|
|
heroSection.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function updateHeroContent(item) {
|
|
const heroTitle = document.querySelector('.hero-title');
|
|
const heroSubtitle = document.querySelector('.hero-subtitle');
|
|
const heroRating = document.querySelector('#hero-rating');
|
|
const heroYear = document.querySelector('#hero-year');
|
|
const heroExtra = document.querySelector('#hero-extra');
|
|
const heroPlayBtn = document.getElementById('hero-play-btn');
|
|
const heroInfoBtn = document.getElementById('hero-info-btn');
|
|
|
|
const type = item.title ? 'movie' : 'tv';
|
|
const title = item.title || item.name;
|
|
const isAvailable = !!buscarContenidoLocal(title, type);
|
|
|
|
if (heroTitle) heroTitle.textContent = title;
|
|
if (heroSubtitle) heroSubtitle.textContent = item.overview.substring(0, 200) + (item.overview.length > 200 ? '...' : '');
|
|
if (heroRating) heroRating.innerHTML = `<i class="fas fa-star"></i> ${item.vote_average.toFixed(1)}/10`;
|
|
if (heroYear) heroYear.innerHTML = `<i class="fas fa-calendar-alt"></i> ${(item.release_date || item.first_air_date).slice(0, 4)}`;
|
|
if (heroExtra) heroExtra.innerHTML = `<i class="fas ${type === 'movie' ? 'fa-film' : 'fa-tv'}"></i> ${type === 'movie' ? _('moviesSectionTitle') : _('seriesSectionTitle')}`;
|
|
|
|
if (heroPlayBtn) {
|
|
heroPlayBtn.onclick = () => addStreamToList(title, type, heroPlayBtn);
|
|
heroPlayBtn.disabled = !isAvailable;
|
|
}
|
|
if (heroInfoBtn) {
|
|
heroInfoBtn.onclick = () => showItemDetails(item.id, type);
|
|
heroInfoBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
export async function addStreamToList(title, type, buttonElement = null) {
|
|
if (state.isAddingStream) return;
|
|
state.isAddingStream = true;
|
|
let originalButtonContent = null;
|
|
if (buttonElement) {
|
|
originalButtonContent = buttonElement.innerHTML;
|
|
buttonElement.disabled = true;
|
|
buttonElement.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>`;
|
|
}
|
|
|
|
if (!state.settings.phpScriptUrl || !state.settings.phpScriptUrl.trim().startsWith('http')) {
|
|
showNotification(_('phpUrlNotConfigured'), 'warning');
|
|
state.isAddingStream = false;
|
|
if (buttonElement && originalButtonContent) {
|
|
buttonElement.innerHTML = originalButtonContent;
|
|
buttonElement.disabled = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
showNotification(_('searchingStreams', title), 'info');
|
|
|
|
try {
|
|
const streamData = await fetchAllStreamsFromPlex(title, type);
|
|
if (!streamData.success || streamData.streams.length === 0) throw new Error(streamData.message);
|
|
|
|
showNotification(_('sendingStreams', String(streamData.streams.length)), 'info');
|
|
const payload = { streams: streamData.streams.map(s => ({ url: s.url, extinf: s.extinf.replace(/[\r\n]+/g, '') })) };
|
|
|
|
const response = await fetch(state.settings.phpScriptUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (!response.ok || !result.success) throw new Error(result.message || _('errorServerResponse'));
|
|
showNotification(result.message || _('streamAddedSuccess'), 'success');
|
|
} catch (error) {
|
|
showNotification(_('errorAddingStream', error.message), "error");
|
|
} finally {
|
|
state.isAddingStream = false;
|
|
if (buttonElement && originalButtonContent) {
|
|
buttonElement.innerHTML = originalButtonContent;
|
|
buttonElement.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function downloadM3U(title, type, buttonElement = null) {
|
|
if (state.isDownloadingM3U) return;
|
|
state.isDownloadingM3U = true;
|
|
let originalButtonContent = null;
|
|
if (buttonElement) {
|
|
originalButtonContent = buttonElement.innerHTML;
|
|
buttonElement.disabled = true;
|
|
buttonElement.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>`;
|
|
}
|
|
|
|
showNotification(_('generatingM3U', title), "info");
|
|
|
|
try {
|
|
const streamData = await fetchAllStreamsFromPlex(title, type);
|
|
if (!streamData.success || streamData.streams.length === 0) throw new Error(streamData.message);
|
|
|
|
let m3uContent = "#EXTM3U\n";
|
|
streamData.streams.forEach(stream => {
|
|
m3uContent += `${stream.extinf}\n${stream.url}\n`;
|
|
});
|
|
|
|
const blob = new Blob([m3uContent], { type: "audio/x-mpegurl;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${title.replace(/[^a-z0-9]/gi, '_')}.m3u`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
showNotification(_('m3uDownloaded', title), 'success');
|
|
} catch (error) {
|
|
showNotification(_('errorGeneratingM3U', error.message), "error");
|
|
} finally {
|
|
state.isDownloadingM3U = false;
|
|
if (buttonElement && originalButtonContent) {
|
|
buttonElement.innerHTML = originalButtonContent;
|
|
buttonElement.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function showTrailer(key) {
|
|
const lightbox = document.getElementById('video-lightbox');
|
|
const iframe = document.getElementById('video-iframe');
|
|
iframe.src = `https://www.youtube.com/embed/${key}?autoplay=1&modestbranding=1&rel=0`;
|
|
lightbox.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
export function closeTrailer() {
|
|
const lightbox = document.getElementById('video-lightbox');
|
|
const iframe = document.getElementById('video-iframe');
|
|
iframe.src = '';
|
|
lightbox.classList.remove('active');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
function setupScrollEffects() {
|
|
gsap.utils.toArray('.item-card:not(.gsap-anim), .photo-card:not(.gsap-anim), .album-card:not(.gsap-anim)').forEach((el) => {
|
|
el.classList.add('gsap-anim');
|
|
gsap.from(el, {
|
|
autoAlpha: 0,
|
|
y: 50,
|
|
duration: 0.6,
|
|
ease: "power2.out",
|
|
scrollTrigger: {
|
|
trigger: el,
|
|
start: "top 95%",
|
|
toggleActions: "play none none none",
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
export function activateSettingsTab(tabId) {
|
|
const tabButtons = document.querySelectorAll('#settingsTabs .nav-link');
|
|
const tabPanes = document.querySelectorAll('#settingsTabsContent .tab-pane');
|
|
|
|
tabButtons.forEach(button => {
|
|
if (button.id === `${tabId}-tab`) {
|
|
button.classList.add('active');
|
|
button.setAttribute('aria-selected', 'true');
|
|
} else {
|
|
button.classList.remove('active');
|
|
button.setAttribute('aria-selected', 'false');
|
|
}
|
|
});
|
|
|
|
tabPanes.forEach(pane => {
|
|
if (pane.id === tabId) {
|
|
pane.classList.add('show', 'active');
|
|
} else {
|
|
pane.classList.remove('show', 'active');
|
|
}
|
|
});
|
|
}
|
|
|
|
export function openSettingsModal() {
|
|
document.getElementById('tmdbApiKey').value = state.settings.apiKey;
|
|
document.getElementById('appLanguage').value = state.settings.language;
|
|
document.getElementById('phpScriptUrl').value = state.settings.phpScriptUrl || '';
|
|
document.getElementById('lightModeToggle').checked = state.settings.theme === 'light';
|
|
document.getElementById('showHeroToggle').checked = state.settings.showHero;
|
|
|
|
document.getElementById('phpSecretKeyCheck').checked = state.settings.phpUseSecretKey;
|
|
document.getElementById('phpSecretKey').value = state.settings.phpSecretKey || '';
|
|
document.getElementById('phpSavePath').value = state.settings.phpSavePath || '';
|
|
document.getElementById('phpFilename').value = state.settings.phpFilename || 'CinePlex_Playlist.m3u';
|
|
document.getElementById('phpFileActionAppend').checked = state.settings.phpFileAction === 'append';
|
|
document.getElementById('phpFileActionOverwrite').checked = state.settings.phpFileAction === 'overwrite';
|
|
|
|
activateSettingsTab('general');
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('settingsModal'));
|
|
modal.show();
|
|
}
|
|
|
|
export async function saveSettings() {
|
|
const oldLanguage = state.settings.language;
|
|
const newSettings = {
|
|
id: 'user_settings',
|
|
apiKey: document.getElementById('tmdbApiKey').value.trim(),
|
|
language: document.getElementById('appLanguage').value,
|
|
theme: document.getElementById('lightModeToggle').checked ? 'light' : 'dark',
|
|
showHero: document.getElementById('showHeroToggle').checked,
|
|
phpScriptUrl: document.getElementById('phpScriptUrl').value.trim(),
|
|
phpUseSecretKey: document.getElementById('phpSecretKeyCheck').checked,
|
|
phpSecretKey: document.getElementById('phpSecretKey').value.trim(),
|
|
phpSavePath: document.getElementById('phpSavePath').value.trim(),
|
|
phpFilename: document.getElementById('phpFilename').value.trim(),
|
|
phpFileAction: document.getElementById('phpFileActionAppend').checked ? 'append' : 'overwrite'
|
|
};
|
|
|
|
state.settings = { ...state.settings, ...newSettings };
|
|
|
|
try {
|
|
await addItemsToStore('settings', [state.settings]);
|
|
showNotification(_('settingsSavedSuccess'), 'success');
|
|
applyTheme(state.settings.theme);
|
|
applyHeroVisibility(state.settings.showHero);
|
|
bootstrap.Modal.getInstance(document.getElementById('settingsModal'))?.hide();
|
|
if (newSettings.language !== oldLanguage) {
|
|
showNotification(_('languageChangeReload'), 'info', 4000);
|
|
setTimeout(() => window.location.reload(), 4000);
|
|
}
|
|
} catch (error) {
|
|
showNotification(_('errorSavingSettings'), 'error');
|
|
}
|
|
}
|
|
|
|
export function applyTheme(theme) {
|
|
document.body.classList.toggle('light-theme', theme === 'light');
|
|
}
|
|
|
|
export function applyHeroVisibility(show) {
|
|
const hero = document.getElementById('hero-section');
|
|
if(hero) hero.style.display = show ? 'flex' : 'none';
|
|
}
|
|
|
|
export const phpScriptGenerator = (() => {
|
|
let dom = {};
|
|
|
|
function cacheDom() {
|
|
const settingsModal = document.getElementById('settingsModal');
|
|
if (!settingsModal) return false;
|
|
|
|
dom.secretKeyCheck = settingsModal.querySelector('#phpSecretKeyCheck');
|
|
dom.secretKey = settingsModal.querySelector('#phpSecretKey');
|
|
dom.savePath = settingsModal.querySelector('#phpSavePath');
|
|
dom.filename = settingsModal.querySelector('#phpFilename');
|
|
dom.fileActionAppendRadio = settingsModal.querySelector('#phpFileActionAppend');
|
|
dom.generatedCode = settingsModal.querySelector('#generatedPhpCode');
|
|
dom.generateBtn = settingsModal.querySelector('#generatePhpScriptBtn');
|
|
dom.copyBtn = settingsModal.querySelector('#copyPhpScriptBtn');
|
|
|
|
return dom.generateBtn && dom.copyBtn;
|
|
}
|
|
|
|
function init() {
|
|
if (!cacheDom()) {
|
|
return;
|
|
}
|
|
dom.generateBtn.addEventListener('click', generatePhpScript);
|
|
dom.copyBtn.addEventListener('click', copyScript);
|
|
}
|
|
|
|
function generatePhpScript() {
|
|
const useSecretKey = dom.secretKeyCheck.checked;
|
|
const secretKey = dom.secretKey.value.trim();
|
|
const savePath = dom.savePath.value.trim();
|
|
const filename = dom.filename.value.trim() || 'CinePlex_Playlist.m3u';
|
|
const appendToFile = dom.fileActionAppendRadio.checked;
|
|
|
|
let script = `<?php
|
|
header('Content-Type: application/json');
|
|
header('Access-Control-Allow-Origin: *');
|
|
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Content-Type, Origin, X-Secret-Key');
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
|
|
http_response_code(200);
|
|
exit(0);
|
|
}
|
|
|
|
define('SAVE_DIRECTORY', '${savePath.replace(/'/g, "\\'")}');
|
|
define('FILENAME', '${filename.replace(/'/g, "\\'")}');
|
|
define('FILE_ACTION_APPEND', ${appendToFile ? 'true' : 'false'});
|
|
${useSecretKey ? `define('SECRET_KEY', '${secretKey.replace(/'/g, "\\'")}');` : ''}
|
|
|
|
function sendResponse($success, $message, $filename = '', $http_code = 200) {
|
|
if (!$success && $http_code === 200) {
|
|
$http_code = 400;
|
|
}
|
|
http_response_code($http_code);
|
|
echo json_encode(['success' => $success, 'message' => $message, 'filename' => $filename]);
|
|
exit;
|
|
}
|
|
`;
|
|
if (useSecretKey) {
|
|
script += `
|
|
$auth_key = isset($_SERVER['HTTP_X_SECRET_KEY']) ? $_SERVER['HTTP_X_SECRET_KEY'] : '';
|
|
if (!defined('SECRET_KEY') || SECRET_KEY === '' || $auth_key !== SECRET_KEY) {
|
|
sendResponse(false, 'Acceso no autorizado. Clave secreta inválida o no proporcionada.', '', 403);
|
|
}
|
|
`;
|
|
}
|
|
script += `
|
|
$json_data = file_get_contents('php://input');
|
|
$data = json_decode($json_data, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
sendResponse(false, 'Error: Datos JSON inválidos.');
|
|
}
|
|
|
|
if (!isset($data['streams']) || !is_array($data['streams']) || empty($data['streams'])) {
|
|
sendResponse(false, 'Error: El JSON debe contener un array "streams" no vacío.');
|
|
}
|
|
|
|
$save_dir = SAVE_DIRECTORY !== '' ? rtrim(SAVE_DIRECTORY, '/\\') : __DIR__;
|
|
|
|
if (!is_dir($save_dir) || !is_writable($save_dir)) {
|
|
sendResponse(false, 'Error del servidor: El directorio de destino no existe o no tiene permisos de escritura.', '', 500);
|
|
}
|
|
|
|
$safe_filename = preg_replace('/[^\\w\\s._-]/u', '', basename(FILENAME));
|
|
$safe_filename = preg_replace('/\\s+/', '_', $safe_filename);
|
|
$target_path = $save_dir . DIRECTORY_SEPARATOR . $safe_filename;
|
|
|
|
$content_to_write = "";
|
|
|
|
if (FILE_ACTION_APPEND) {
|
|
$file_exists = file_exists($target_path);
|
|
if (!$file_exists) {
|
|
$content_to_write .= "#EXTM3U\n";
|
|
}
|
|
foreach ($data['streams'] as $stream) {
|
|
if (isset($stream['extinf'], $stream['url'])) {
|
|
$content_to_write .= trim($stream['extinf']) . "\n";
|
|
$content_to_write .= trim($stream['url']) . "\n";
|
|
}
|
|
}
|
|
if (file_put_contents($target_path, $content_to_write, FILE_APPEND | LOCK_EX) !== false) {
|
|
sendResponse(true, 'Streams añadidos correctamente al archivo.', $safe_filename, 200);
|
|
} else {
|
|
sendResponse(false, 'Error del servidor: No se pudo añadir contenido al archivo.', '', 500);
|
|
}
|
|
} else { // Overwrite mode
|
|
$content_to_write = "#EXTM3U\n";
|
|
foreach ($data['streams'] as $stream) {
|
|
if (isset($stream['extinf'], $stream['url'])) {
|
|
$content_to_write .= trim($stream['extinf']) . "\n";
|
|
$content_to_write .= trim($stream['url']) . "\n";
|
|
}
|
|
}
|
|
if (file_put_contents($target_path, $content_to_write, LOCK_EX) !== false) {
|
|
sendResponse(true, 'Archivo de streams sobrescrito correctamente.', $safe_filename, 201);
|
|
} else {
|
|
sendResponse(false, 'Error del servidor: No se pudo escribir el archivo.', '', 500);
|
|
}
|
|
}
|
|
?>`;
|
|
dom.generatedCode.value = script;
|
|
showNotification(_("scriptGenerated"), "success");
|
|
}
|
|
|
|
function copyScript() {
|
|
if (!dom.generatedCode.value || dom.generatedCode.value.trim() === '') {
|
|
showNotification(_("errorGeneratingScript"), "warning");
|
|
return;
|
|
}
|
|
navigator.clipboard.writeText(dom.generatedCode.value).then(() => {
|
|
showNotification(_("scriptCopied"), "success");
|
|
}).catch(err => {
|
|
showNotification(_("errorCopyingScript"), "error");
|
|
});
|
|
}
|
|
|
|
return { init };
|
|
})();
|
|
|
|
export function initPhotosView() {
|
|
const select = document.getElementById('photos-token-select');
|
|
select.innerHTML = '';
|
|
select.appendChild(createElement('option', { value: '', textContent: _('loading') }));
|
|
|
|
const photoServers = [...new Map(state.localPhotos.map(item => [item.tokenPrincipal, item])).values()];
|
|
|
|
if (photoServers.length === 0) {
|
|
select.innerHTML = '';
|
|
select.appendChild(createElement('option', { value: '', textContent: _('noPhotoServers') }));
|
|
document.getElementById('photos-empty-state').style.display = 'block';
|
|
document.getElementById('photos-grid').innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
document.getElementById('photos-empty-state').style.display = 'none';
|
|
|
|
select.innerHTML = '';
|
|
select.appendChild(createElement('option', { value: '', textContent: _('selectServer') }));
|
|
photoServers.forEach(server => {
|
|
select.appendChild(createElement('option', { value: server.tokenPrincipal, textContent: server.serverName || `Servidor ${server.tokenPrincipal.slice(-4)}` }));
|
|
});
|
|
|
|
if (state.currentPhotoToken && photoServers.some(s => s.tokenPrincipal === state.currentPhotoToken)) {
|
|
select.value = state.currentPhotoToken;
|
|
} else {
|
|
select.value = photoServers[0].tokenPrincipal;
|
|
}
|
|
handlePhotoTokenChange();
|
|
}
|
|
|
|
export function handlePhotoTokenChange() {
|
|
const select = document.getElementById('photos-token-select');
|
|
const selectedToken = select.value;
|
|
if (!selectedToken) {
|
|
document.getElementById('photos-grid').innerHTML = '';
|
|
document.getElementById('photos-empty-state').style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
state.currentPhotoToken = selectedToken;
|
|
state.currentPhotoServer = state.localPhotos.find(s => s.tokenPrincipal === state.currentPhotoToken);
|
|
|
|
state.photoStack = [];
|
|
renderPhotoBreadcrumb();
|
|
renderPhotoGrid(state.currentPhotoServer.titulos, 'album');
|
|
}
|
|
|
|
async function fetchPhotoAlbumContent(albumData) {
|
|
const { id, token, ip, puerto, protocolo } = albumData;
|
|
const url = `${protocolo}://${ip}:${puerto}/library/metadata/${id}/children?X-Plex-Token=${token}`;
|
|
|
|
try {
|
|
const response = await fetchWithTimeout(url, {}, 10000);
|
|
if (!response.ok) throw new Error(_('errorPlexApi', String(response.status)));
|
|
|
|
const data = await response.text();
|
|
const parser = new DOMParser();
|
|
const xmlDoc = parser.parseFromString(data, "text/xml");
|
|
if (xmlDoc.querySelector('parsererror')) throw new Error(_('errorParsingPlexXml'));
|
|
|
|
const photos = Array.from(xmlDoc.querySelectorAll('Photo')).map(el => {
|
|
const media = el.querySelector('Media Part');
|
|
if (!media) return null;
|
|
const key = media.getAttribute('key');
|
|
return {
|
|
type: 'photo',
|
|
id: el.getAttribute('ratingKey'),
|
|
title: el.getAttribute('title') || _('untitled'),
|
|
thumbUrl: `${protocolo}://${ip}:${puerto}/photo/:/transcode?width=400&height=400&minSize=1&upscale=1&url=${encodeURIComponent(key)}&X-Plex-Token=${token}`,
|
|
fullUrl: `${protocolo}://${ip}:${puerto}/photo/:/transcode?width=2000&height=2000&minSize=1&upscale=1&url=${encodeURIComponent(key)}&X-Plex-Token=${token}`
|
|
};
|
|
}).filter(p => p !== null);
|
|
|
|
const subAlbums = Array.from(xmlDoc.querySelectorAll('Directory[type="photo"]')).map(el => ({
|
|
type: 'album',
|
|
id: el.getAttribute('ratingKey'),
|
|
title: el.getAttribute('title'),
|
|
count: el.getAttribute('leafCount') || el.getAttribute('childCount'),
|
|
}));
|
|
|
|
return [...subAlbums, ...photos];
|
|
} catch (error) {
|
|
showNotification(_('errorLoadingAlbum', error.message), 'error');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function loadPhotoAlbum(albumData) {
|
|
const loader = document.getElementById('photos-loader');
|
|
const grid = document.getElementById('photos-grid');
|
|
grid.innerHTML = '';
|
|
loader.style.display = 'block';
|
|
document.getElementById('photos-empty-state').style.display = 'none';
|
|
|
|
const items = await fetchPhotoAlbumContent(albumData);
|
|
loader.style.display = 'none';
|
|
|
|
if (items.length > 0) {
|
|
renderPhotoGrid(items);
|
|
} else {
|
|
document.getElementById('photos-empty-state').style.display = 'block';
|
|
}
|
|
}
|
|
|
|
export async function handlePhotoGridClick(card) {
|
|
const { type, id, title } = card.dataset;
|
|
|
|
if (type === 'album') {
|
|
if (!state.currentPhotoServer) {
|
|
showNotification(_('noPhotoServerSelected'), "error");
|
|
return;
|
|
}
|
|
|
|
const albumData = {
|
|
...state.currentPhotoServer,
|
|
id: id,
|
|
title: title
|
|
};
|
|
|
|
state.photoStack.push({ id, title });
|
|
renderPhotoBreadcrumb();
|
|
await loadPhotoAlbum(albumData);
|
|
|
|
} else if (type === 'photo') {
|
|
const photoIndex = state.currentPhotoItems.findIndex(p => p.id === id);
|
|
if (photoIndex > -1) {
|
|
openPhotoLightbox(photoIndex);
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderPhotoGrid(items, forceType = null) {
|
|
const grid = document.getElementById('photos-grid');
|
|
grid.innerHTML = '';
|
|
document.getElementById('photos-empty-state').style.display = 'none';
|
|
state.currentPhotoItems = [];
|
|
|
|
if (!items || items.length === 0) {
|
|
document.getElementById('photos-empty-state').style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
items.forEach(item => {
|
|
const type = forceType || item.type;
|
|
const card = document.createElement('div');
|
|
|
|
if (type === 'album') {
|
|
card.className = 'album-card';
|
|
card.innerHTML = `
|
|
<div class="album-card-icon"><i class="fas fa-folder"></i></div>
|
|
<div class="album-card-title">${item.title}</div>
|
|
${item.count ? `<div class="album-card-meta">${_('itemCount', String(item.count))}</div>` : ''}
|
|
`;
|
|
card.dataset.type = 'album';
|
|
} else if (type === 'photo') {
|
|
state.currentPhotoItems.push(item);
|
|
card.className = 'photo-card';
|
|
card.innerHTML = `
|
|
<img src="${item.thumbUrl}" alt="${item.title}" class="photo-card-img" loading="lazy">
|
|
<div class="photo-card-caption">${item.title}</div>
|
|
`;
|
|
card.dataset.type = 'photo';
|
|
}
|
|
|
|
card.dataset.id = item.id;
|
|
card.dataset.title = item.title;
|
|
fragment.appendChild(card);
|
|
});
|
|
grid.appendChild(fragment);
|
|
setupScrollEffects();
|
|
}
|
|
|
|
function renderPhotoBreadcrumb() {
|
|
const breadcrumb = document.getElementById('photos-breadcrumb');
|
|
breadcrumb.innerHTML = '';
|
|
|
|
const rootItem = createElement('li', { className: 'breadcrumb-item' });
|
|
const rootLink = createElement('a', { href: '#', innerHTML: `<i class="fas fa-home"></i> ${_('photosBreadcrumbHome')}` });
|
|
rootLink.onclick = (e) => {
|
|
e.preventDefault();
|
|
handlePhotoTokenChange();
|
|
};
|
|
rootItem.appendChild(rootLink);
|
|
breadcrumb.appendChild(rootItem);
|
|
|
|
state.photoStack.forEach((item, index) => {
|
|
const divider = createElement('li', { className: 'breadcrumb-divider', innerHTML: '<i class="fas fa-chevron-right"></i>' });
|
|
breadcrumb.appendChild(divider);
|
|
|
|
const breadcrumbItem = createElement('li', { className: 'breadcrumb-item' });
|
|
if (index === state.photoStack.length - 1) {
|
|
breadcrumbItem.classList.add('active');
|
|
breadcrumbItem.textContent = item.title;
|
|
} else {
|
|
const link = createElement('a', { href: '#', textContent: item.title });
|
|
link.onclick = async (e) => {
|
|
e.preventDefault();
|
|
state.photoStack = state.photoStack.slice(0, index + 1);
|
|
renderPhotoBreadcrumb();
|
|
|
|
if (!state.currentPhotoServer) return;
|
|
|
|
const albumData = { ...state.currentPhotoServer, id: item.id, title: item.title };
|
|
await loadPhotoAlbum(albumData);
|
|
};
|
|
breadcrumbItem.appendChild(link);
|
|
}
|
|
breadcrumb.appendChild(breadcrumbItem);
|
|
});
|
|
}
|
|
|
|
function openPhotoLightbox(startIndex) {
|
|
if (state.currentPhotoItems.length === 0 || startIndex < 0) return;
|
|
state.currentPhotoLightboxIndex = startIndex;
|
|
|
|
const lightbox = document.getElementById('photo-lightbox');
|
|
document.body.style.overflow = 'hidden';
|
|
updatePhotoLightbox();
|
|
|
|
gsap.fromTo(lightbox, { autoAlpha: 0 }, { display: 'flex', autoAlpha: 1, duration: 0.3 });
|
|
}
|
|
|
|
export function closePhotoLightbox() {
|
|
const lightbox = document.getElementById('photo-lightbox');
|
|
document.body.style.overflow = '';
|
|
gsap.to(lightbox, {
|
|
autoAlpha: 0,
|
|
duration: 0.3,
|
|
onComplete: () => lightbox.style.display = 'none'
|
|
});
|
|
}
|
|
|
|
function updatePhotoLightbox() {
|
|
const photo = state.currentPhotoItems[state.currentPhotoLightboxIndex];
|
|
if (!photo) return;
|
|
|
|
const img = document.getElementById('photo-lightbox-img');
|
|
const caption = document.getElementById('photo-lightbox-caption');
|
|
|
|
gsap.to([img, caption], {
|
|
autoAlpha: 0,
|
|
duration: 0.15,
|
|
onComplete: () => {
|
|
img.src = photo.fullUrl;
|
|
caption.textContent = photo.title;
|
|
gsap.to([img, caption], { autoAlpha: 1, duration: 0.15 });
|
|
}
|
|
});
|
|
}
|
|
|
|
export function showNextPhoto() {
|
|
state.currentPhotoLightboxIndex = (state.currentPhotoLightboxIndex + 1) % state.currentPhotoItems.length;
|
|
updatePhotoLightbox();
|
|
}
|
|
|
|
export function showPrevPhoto() {
|
|
state.currentPhotoLightboxIndex = (state.currentPhotoLightboxIndex - 1 + state.currentPhotoItems.length) % state.currentPhotoItems.length;
|
|
updatePhotoLightbox();
|
|
} |