2025-07-02 14:16:25 +02:00
import { state } from './state.js' ;
2025-07-11 12:10:50 +02:00
import { fetchTMDB , fetchAllAvailableStreams } from './api.js' ;
2025-07-02 14:16:25 +02:00
import { showNotification , getRelativeTime , fetchWithTimeout , _ } from './utils.js' ;
import { getFromDB , addItemsToStore } from './db.js' ;
2025-07-25 23:57:03 +02:00
import { getAvailableProviders , renderProviders , getRegions , fetchAllProviders , resetProvidersView } from './providers.js' ;
2025-07-02 14:16:25 +02:00
let charts = { } ;
export async function loadInitialContent ( ) {
2025-07-25 23:57:03 +02:00
await Promise . all ( [ loadGenres ( ) , loadYears ( ) , loadRegions ( ) ] ) ;
2025-07-11 12:10:50 +02:00
resetView ( ) ;
2025-07-02 14:16:25 +02:00
setupScrollEffects ( ) ;
}
2025-07-25 23:57:03 +02:00
async function loadRegions ( ) {
const select = document . getElementById ( 'region-filter' ) ;
select . innerHTML = ` <option value=""> ${ _ ( 'loadingRegions' ) } </option> ` ;
try {
const data = await getRegions ( ) ;
select . innerHTML = ` <option value=""> ${ _ ( 'allRegions' ) } </option> ` ;
data . forEach ( region => {
const option = document . createElement ( 'option' ) ;
option . value = region . iso _3166 _1 ;
option . textContent = region . english _name ;
select . appendChild ( option ) ;
} ) ;
select . value = state . settings . watchRegion || "" ;
} catch ( error ) {
select . innerHTML = ` <option value=""> ${ _ ( 'errorLoadingRegions' ) } </option> ` ;
}
}
2025-07-02 14:16:25 +02:00
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 {
2025-07-11 12:10:50 +02:00
const [ movies , series , artists , photos , jfMovies , jfSeries ] = await Promise . all ( [
getFromDB ( 'movies' ) ,
getFromDB ( 'series' ) ,
getFromDB ( 'artists' ) ,
getFromDB ( 'photos' ) ,
getFromDB ( 'jellyfin_movies' ) ,
getFromDB ( 'jellyfin_series' )
] ) ;
2025-07-02 14:16:25 +02:00
state . localMovies = movies ;
state . localSeries = series ;
state . localArtists = artists ;
state . localPhotos = photos ;
2025-07-11 12:10:50 +02:00
state . jellyfinMovies = jfMovies ;
state . jellyfinSeries = jfSeries ;
2025-07-28 15:29:50 +02:00
state . localContentLookup . clear ( ) ;
const normalize = ( str ) => str ? str . toLowerCase ( ) . trim ( ) . replace ( /\s+/g , ' ' ) : '' ;
const processSource = ( source ) => {
if ( ! Array . isArray ( source ) ) return ;
source . forEach ( server => {
if ( server && Array . isArray ( server . titulos ) ) {
server . titulos . forEach ( t => {
if ( t && t . title ) {
const year = t . year ? String ( t . year ) . slice ( 0 , 4 ) : 'any' ;
const lookupKey = ` ${ normalize ( t . title ) } | ${ year } ` ;
state . localContentLookup . add ( lookupKey ) ;
}
} ) ;
}
} ) ;
} ;
processSource ( state . localMovies ) ;
processSource ( state . localSeries ) ;
processSource ( state . jellyfinMovies ) ;
processSource ( state . jellyfinSeries ) ;
2025-07-02 14:16:25 +02:00
} catch ( error ) {
showNotification ( _ ( "errorLoadingLocalContent" ) , "error" ) ;
}
}
export function resetView ( ) {
2025-07-07 17:26:02 +02:00
if ( state . isLoading ) return ;
const heroSection = document . getElementById ( 'hero-section' ) ;
2025-07-12 12:56:04 +02:00
const mainContent = document . getElementById ( 'main-content' ) ;
2025-07-07 17:26:02 +02:00
const contentSection = document . getElementById ( 'content-section' ) ;
2025-07-15 09:42:39 +02:00
const heroContent = document . querySelector ( '.hero-content' ) ;
const heroBg1 = document . querySelector ( '.hero-background-1' ) ;
const heroBg2 = document . querySelector ( '.hero-background-2' ) ;
2025-07-12 12:56:04 +02:00
if ( mainContent ) {
mainContent . style . display = 'none' ;
}
2025-07-07 17:26:02 +02:00
if ( contentSection ) {
contentSection . style . display = 'none' ;
}
2025-07-12 12:56:04 +02:00
document . getElementById ( 'stats-section' ) . style . display = 'none' ;
document . getElementById ( 'history-section' ) . style . display = 'none' ;
document . getElementById ( 'recommendations-section' ) . style . display = 'none' ;
document . getElementById ( 'photos-section' ) . style . display = 'none' ;
2025-07-25 23:57:03 +02:00
document . getElementById ( 'providers-section' ) . style . display = 'none' ;
2025-07-26 18:53:03 +02:00
document . getElementById ( 'm3u-generator-section' ) . style . display = 'none' ;
2025-07-12 12:56:04 +02:00
if ( heroSection ) {
if ( state . settings . showHero ) {
heroSection . style . display = 'flex' ;
2025-07-15 09:42:39 +02:00
if ( state . heroIntervalId ) {
clearInterval ( state . heroIntervalId ) ;
state . heroIntervalId = null ;
}
if ( heroContent ) {
heroContent . querySelector ( '.hero-title' ) . textContent = _ ( 'welcomeToCinePlex' ) ;
heroContent . querySelector ( '.hero-subtitle' ) . textContent = _ ( 'welcomeSubtitle' ) ;
heroContent . querySelector ( '#hero-rating' ) . innerHTML = '' ;
heroContent . querySelector ( '#hero-year' ) . innerHTML = '' ;
heroContent . querySelector ( '#hero-extra' ) . innerHTML = '' ;
heroContent . querySelector ( '.hero-buttons' ) . style . display = 'none' ;
}
gsap . set ( heroBg1 , { backgroundImage : 'url(img/hero-def.png)' , autoAlpha : 1 , scale : 1 } ) ;
gsap . set ( heroBg2 , { autoAlpha : 0 } ) ;
heroSection . classList . add ( 'no-overlay' ) ;
2025-07-12 12:56:04 +02:00
initializeHeroSection ( ) ;
2025-07-15 09:42:39 +02:00
2025-07-12 12:56:04 +02:00
} else {
heroSection . style . display = 'none' ;
}
}
2025-07-07 17:26:02 +02:00
state . currentView = 'home' ;
updateActiveNav ( 'home' ) ;
updateSectionTitle ( ) ;
2025-07-02 14:16:25 +02:00
}
export function switchView ( viewType ) {
if ( state . isLoading ) return ;
2025-07-25 23:57:03 +02:00
resetProvidersView ( ) ;
resetProvidersView ( ) ;
2025-07-07 17:26:02 +02:00
const heroSection = document . getElementById ( 'hero-section' ) ;
2025-07-12 12:56:04 +02:00
const mainContent = document . querySelector ( '.main-content' ) ;
2025-07-07 17:26:02 +02:00
if ( heroSection ) {
heroSection . style . display = 'none' ;
2025-07-12 12:56:04 +02:00
if ( state . heroIntervalId ) {
clearInterval ( state . heroIntervalId ) ;
state . heroIntervalId = null ;
}
}
if ( mainContent ) {
2025-07-28 15:29:50 +02:00
mainContent . style . display = 'block' ;
2025-07-07 17:26:02 +02:00
}
2025-07-02 14:16:25 +02:00
const sidebar = document . getElementById ( 'sidebar-nav' ) ;
if ( sidebar . classList . contains ( 'open' ) ) {
sidebar . classList . remove ( 'open' ) ;
document . getElementById ( 'main-container' ) . classList . remove ( 'sidebar-open' ) ;
}
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 ;
2025-07-26 18:53:03 +02:00
const allSections = [ 'content-section' , 'stats-section' , 'history-section' , 'recommendations-section' , 'photos-section' , 'providers-section' , 'm3u-generator-section' ] ;
2025-07-02 14:16:25 +02:00
allSections . forEach ( id => {
const el = document . getElementById ( id ) ;
if ( el ) el . style . display = 'none' ;
} ) ;
2025-07-25 23:57:03 +02:00
const providersSection = document . getElementById ( 'providers-section' ) ;
if ( providersSection ) {
const detailsContainer = document . getElementById ( 'provider-details-container' ) ;
if ( detailsContainer ) detailsContainer . style . display = 'none' ;
const gridContainer = document . getElementById ( 'provider-grid-container' ) ;
if ( gridContainer ) gridContainer . style . display = 'grid' ;
const sectionTitle = providersSection . querySelector ( '.section-title' ) ;
if ( sectionTitle ) sectionTitle . textContent = _ ( 'navProviders' ) ;
const backArrow = providersSection . querySelector ( '.back-arrow' ) ;
if ( backArrow ) backArrow . style . display = 'none' ;
}
const backToProvidersBtn = document . getElementById ( 'back-to-providers-btn' ) ;
if ( backToProvidersBtn ) backToProvidersBtn . style . display = 'none' ;
2025-07-02 14:16:25 +02:00
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 ;
2025-07-25 23:57:03 +02:00
case 'providers' :
document . getElementById ( 'providers-section' ) . style . display = 'block' ;
break ;
2025-07-26 18:53:03 +02:00
case 'm3u-generator' :
document . getElementById ( 'm3u-generator-section' ) . style . display = 'block' ;
break ;
2025-07-02 14:16:25 +02:00
}
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 ;
2025-07-25 23:57:03 +02:00
case 'providers' :
loadProviders ( ) ;
break ;
2025-07-26 18:53:03 +02:00
case 'm3u-generator' :
break ;
2025-07-02 14:16:25 +02:00
}
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' ) ) ;
2025-07-07 17:26:02 +02:00
if ( activeView === 'home' ) return ;
2025-07-02 14:16:25 +02:00
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 ) {
2025-07-07 17:26:02 +02:00
case 'home' :
title = _ ( 'explore' ) ;
break ;
2025-07-02 14:16:25 +02:00
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 ;
2025-07-25 23:57:03 +02:00
case 'providers' : title = _ ( 'navProviders' ) ; break ;
2025-07-02 14:16:25 +02:00
case 'search' :
title = state . currentParams . query . startsWith ( 'actor:' )
? _ ( 'contentFrom' , state . currentParams . query . split ( ':' ) [ 1 ] )
: _ ( 'searchResultsFor' , state . currentParams . query ) ;
break ;
default : title = _ ( 'explore' ) ;
}
let targetTitleElement ;
2025-07-25 23:57:03 +02:00
if ( [ 'stats' , 'history' , 'recommendations' , 'providers' ] . includes ( state . currentView ) ) {
2025-07-02 14:16:25 +02:00
targetTitleElement = document . querySelector ( ` # ${ state . currentView } -section .section-title ` ) ;
} else {
targetTitleElement = mainTitleElement ;
}
if ( targetTitleElement && state . currentView !== 'photos' ) {
targetTitleElement . textContent = title ;
2025-07-25 23:57:03 +02:00
const backArrow = targetTitleElement . parentElement . querySelector ( '.back-arrow' ) ;
if ( backArrow ) {
backArrow . style . display = 'none' ;
}
2025-07-02 14:16:25 +02:00
}
}
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' ) ;
2025-07-04 09:36:40 +02:00
select . innerHTML = ` <option value=""> ${ _ ( 'loadingGenres' ) } </option> ` ;
2025-07-02 14:16:25 +02:00
try {
const data = await fetchTMDB ( ` genre/ ${ type } /list ` ) ;
2025-07-04 09:36:40 +02:00
select . innerHTML = ` <option value=""> ${ _ ( 'allGenres' ) } </option> ` ;
2025-07-02 14:16:25 +02:00
data . genres . forEach ( genre => {
2025-07-04 09:36:40 +02:00
const option = document . createElement ( 'option' ) ;
option . value = genre . id ;
option . textContent = genre . name ;
select . appendChild ( option ) ;
2025-07-02 14:16:25 +02:00
} ) ;
select . value = state . currentParams . genre || "" ;
} catch ( error ) {
2025-07-04 09:36:40 +02:00
select . innerHTML = ` <option value=""> ${ _ ( 'errorLoadingGenres' ) } </option> ` ;
2025-07-02 14:16:25 +02:00
}
}
function loadYears ( ) {
const select = document . getElementById ( 'year-filter' ) ;
2025-07-04 09:36:40 +02:00
select . innerHTML = ` <option value=""> ${ _ ( 'allYears' ) } </option> ` ;
2025-07-02 14:16:25 +02:00
const currentYear = new Date ( ) . getFullYear ( ) ;
for ( let year = currentYear ; year >= 1900 ; year -- ) {
2025-07-04 09:36:40 +02:00
const option = document . createElement ( 'option' ) ;
option . value = year ;
option . textContent = year ;
select . appendChild ( option ) ;
2025-07-02 14:16:25 +02:00
}
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 ) {
2025-07-04 09:36:40 +02:00
grid . innerHTML = '<div class="col-12 text-center mt-5"><div class="spinner" style="position: static; margin: auto; display: block;"></div></div>' ;
2025-07-02 14:16:25 +02:00
loadMoreButton . style . display = 'none' ;
} else {
loadMoreButton . disabled = true ;
2025-07-04 09:36:40 +02:00
loadMoreButton . innerHTML = ` <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> ${ _ ( 'loading' ) } ` ;
2025-07-02 14:16:25 +02:00
}
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' ) {
2025-07-04 09:36:40 +02:00
if ( ! append ) grid . innerHTML = ` <div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p> ${ _ ( 'couldNotLoadContent' ) } </p></div> ` ;
2025-07-02 14:16:25 +02:00
}
} finally {
state . isLoading = false ;
if ( append ) {
loadMoreButton . disabled = false ;
loadMoreButton . textContent = _ ( 'loadMore' ) ;
}
}
}
2025-07-25 23:57:03 +02:00
function isContentAvailableLocally ( title , type , year ) {
2025-07-11 12:10:50 +02:00
if ( ! title || ! type ) return false ;
2025-07-25 23:57:03 +02:00
const normalize = ( str ) => str . toLowerCase ( ) . trim ( ) . replace ( /\s+/g , ' ' ) ;
const normalizedTitle = normalize ( title ) ;
2025-07-28 15:29:50 +02:00
const yearKey = year ? String ( year ) . slice ( 0 , 4 ) : 'any' ;
2025-07-25 23:57:03 +02:00
2025-07-28 15:29:50 +02:00
const lookupKey = ` ${ normalizedTitle } | ${ yearKey } ` ;
if ( state . localContentLookup . has ( lookupKey ) ) {
return true ;
}
2025-07-11 12:10:50 +02:00
2025-07-28 15:29:50 +02:00
const lookupKeyAnyYear = ` ${ normalizedTitle } |any ` ;
if ( yearKey !== 'any' && state . localContentLookup . has ( lookupKeyAnyYear ) ) {
return true ;
}
2025-07-11 12:10:50 +02:00
return false ;
2025-07-02 14:16:25 +02:00
}
2025-07-25 23:57:03 +02:00
export function renderGrid ( items , append = false , gridId = 'content-grid' ) {
const grid = document . getElementById ( gridId ) ;
2025-07-02 14:16:25 +02:00
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' ) ;
2025-07-04 09:36:40 +02:00
emptyStateTarget . innerHTML = ` <div class="empty-state"><i class="fas ${ emptyIcon } fa-3x mb-3"></i><p class="lead"> ${ emptyText } </p></div> ` ;
2025-07-02 14:16:25 +02:00
}
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' ;
2025-07-25 23:57:03 +02:00
const isAvailable = isContentAvailableLocally ( title , itemType , year ) ;
2025-07-02 14:16:25 +02:00
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' ) ;
2025-07-04 09:36:40 +02:00
const card = document . createElement ( 'div' ) ;
card . className = ` item-card ` ;
card . dataset . id = item . id ;
card . dataset . type = itemType ;
2025-07-02 14:16:25 +02:00
card . innerHTML = `
2025-07-28 15:29:50 +02:00
< img src = "${posterPath}" class = "item-poster" loading = "lazy" alt = "${title}" >
2025-07-02 14:16:25 +02:00
$ { 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 > < / b u t t o n >
< button class = "action-btn favorites-btn ${isFavorite ? 'active' : ''}" title = "${isFavorite ? _('removeFromFavorites') : _('addToFavorites')}" > < i class = "fas ${isFavorite ? 'fa-heart-broken' : 'fa-heart'} fa-lg" > < / i > < / b u t t o n >
$ { isAvailable ? `
< button class = "action-btn download-btn" data - title = "${title}" data - type = "${itemType}" title = "${_('miniplayerDownloadAlbum')}" > < i class = "fas fa-download fa-lg" > < / i > < / b u t t o n >
< button class = "action-btn play-btn" data - title = "${title}" data - type = "${itemType}" title = "${_('addStream')}" > < i class = "fas fa-plus-circle fa-lg" > < / i > < / b u t t o n >
` : ` < button class = "action-btn disabled-btn" disabled title = "${_('notAvailable')}" > < i class = "fas fa-times-circle fa-lg text-muted" > < / i > < / b u t t o n > ` }
< / d i v >
< / d i v >
< div class = "item-info" >
< h3 class = "item-title" title = "${title}" > $ { title } < / h 3 >
< div class = "item-meta" >
< span > < i class = "fas fa-calendar-alt me-1" > < / i > $ { y e a r } < / s p a n >
$ { voteAvg !== 'N/A' ? ` <span class="item-rating ms-2 ${ ratingClass } "><i class="fas fa-star me-1"></i> ${ voteAvg } </span> ` : '' }
< / d i v >
< / d i v >
` ;
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' ) ;
2025-07-04 09:36:40 +02:00
detailsContent . innerHTML = '<div class="text-center py-5"><div class="spinner" style="display: block; margin: auto; position: static;"></div></div>' ;
2025-07-02 14:16:25 +02:00
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 )
2025-07-04 09:36:40 +02:00
. map ( s => fetchTMDB ( ` ${ contentType } / ${ itemId } /season/ ${ s . season _number } ` ) . catch ( ( ) => null ) ) ;
2025-07-02 14:16:25 +02:00
const seasonsData = await Promise . all ( seasonPromises ) ;
item . seasons _with _episodes = seasonsData . filter ( s => s !== null ) ;
}
updateUserData ( item ) ;
await renderItemDetails ( item ) ;
} catch ( error ) {
2025-07-04 09:36:40 +02:00
detailsContent . innerHTML = ` <div class="alert alert-danger mx-3 my-5 text-center"><h4> ${ _ ( 'errorLoadingDetails' ) } </h4><p> ${ error . message } </p></div> ` ;
2025-07-02 14:16:25 +02:00
} 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' ) ;
2025-07-11 12:10:50 +02:00
const isAvailable = isContentAvailableLocally ( title , state . currentItemType ) ;
2025-07-02 14:16:25 +02:00
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 = `
$ { 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" > < / d i v >
< div class = "item-details-content" >
< h1 class = "item-details-title" > $ { title } < / h 1 >
$ { 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 > $ { y e a r } < / s p a n >
$ { 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> ` : '' }
< / d i v >
< div class = "item-details-genres mb-3" > $ { genres . map ( g => ` <span class="genre-badge"> ${ g . name } </span> ` ) . join ( '' ) } < / d i v >
< div class = "item-details-crew" >
$ { director ? ` <p><strong> ${ _ ( 'director' ) } </strong> ${ director . name } </p> ` : '' }
$ { writer ? ` <p><strong> ${ _ ( 'writer' ) } </strong> ${ writer . name } </p> ` : '' }
< / d i v >
$ { 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' ) } < / h 3 >
< 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 > $ { i s F a v o r i t e ? _ ( ' r e m o v e F r o m F a v o r i t e s ' ) : _ ( ' a d d T o F a v o r i t e s ' ) } < / b u t t o n >
$ { 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 > $ { _ ( ' a d d S t r e a m ' ) } < / b u t t o n >
< button class = "btn btn-info download-btn" data - title = "${title}" data - type = "${state.currentItemType}" > < i class = "fas fa-download me-1" > < / i > $ { _ ( ' m i n i p l a y e r D o w n l o a d A l b u m ' ) } < / b u t t o n >
` : ` < button class = "btn btn-secondary" disabled > < i class = "fas fa-times-circle me-1" > < / i > $ { _ ( ' n o t A v a i l a b l e ' ) } < / b u t t o n > ` }
< / d i v >
< / d i v >
< / d i v >
< div class = "item-details-section" >
$ { ( isMovie ? item . credits ? . cast ? . length > 0 : item . aggregate _credits ? . cast ? . length > 0 ) ? `
< h3 class = "section-subtitle" > $ { _ ( 'mainCast' ) } < / h 3 >
< 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 } < / d i v >
< div class = "cast-character" > $ { actor . roles ? actor . roles . map ( r => r . character ) . slice ( 0 , 2 ) . join ( ', ' ) : actor . character } < / d i v >
< / d i v >
` ).join('')}
< / d i v > ` : ' ' }
< / d i v >
$ { ! isMovie && item . seasons _with _episodes && item . seasons _with _episodes . length > 0 ? `
< div class = "item-details-section" >
< h3 class = "section-subtitle" > $ { _ ( 'seasonsAndEpisodes' ) } < / h 3 >
< 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 } < / s p a n >
< div class = "season-meta" >
< span > < i class = "fas fa-calendar-alt" > < / i > $ { s e a s o n . a i r _ d a t e ? n e w D a t e ( s e a s o n . a i r _ d a t e ) . g e t F u l l Y e a r ( ) : ' N / A ' } < / s p a n >
< span > < i class = "fas fa-list-ol" > < / i > $ { _ ( ' e p i s o d e s C o u n t ' , S t r i n g ( s e a s o n . e p i s o d e s . l e n g t h ) ) } < / s p a n >
< / d i v >
< p class = "season-overview d-none d-md-block" > $ { ( season . overview || '' ) . substring ( 0 , 120 ) } $ { season . overview && season . overview . length > 120 ? '...' : '' } < / p >
< / d i v >
< / d i v >
< / b u t t o n >
< / h 2 >
< 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 } < / s p a n >
< div class = "episode-info" >
< h5 class = "episode-title" > $ { ep . name } < / h 5 >
< div class = "episode-meta" >
< span > < i class = "far fa-calendar-alt" > < / i > $ { e p . a i r _ d a t e | | ' N / A ' } < / s p a n >
< span > < i class = "far fa-star" > < / i > $ { e p . v o t e _ a v e r a g e . t o F i x e d ( 1 ) } / 1 0 < / s p a n >
< / d i v >
< p class = "episode-overview" > $ { ep . overview || _ ( 'noSynopsis' ) } < / p >
< / d i v >
< / d i v >
` ).join('')}
< / d i v >
< / d i v >
< / d i v >
` ).join('')}
< / d i v >
< / d i v >
` : ''}
< div class = "item-details-section" >
$ { item . similar ? . results ? . length > 0 ? `
< h3 class = "section-subtitle" > $ { _ ( 'similarContent' ) } < / h 3 >
< 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 } < / d i v >
< / d i v >
< / d i v >
` ).join('')}
< / d i v > ` : ' ' }
< / d i v >
< / d i v >
` ;
}
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' ) ;
2025-07-04 09:36:40 +02:00
grid . innerHTML = '<div class="col-12 text-center mt-5"><div class="spinner" style="position: static; margin: auto; display: block;"></div></div>' ;
2025-07-02 14:16:25 +02:00
if ( state . favorites . length === 0 ) {
2025-07-04 09:36:40 +02:00
grid . innerHTML = ` <div class="empty-state"><i class="far fa-heart fa-3x mb-3"></i><p class="lead"> ${ _ ( 'noFavorites' ) } </p></div> ` ;
2025-07-02 14:16:25 +02:00
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 ) {
2025-07-04 09:36:40 +02:00
grid . innerHTML = ` <div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p> ${ _ ( 'errorLoadingFavorites' ) } </p></div> ` ;
2025-07-02 14:16:25 +02:00
}
}
export function displayHistory ( ) {
const listContainer = document . getElementById ( 'history-list' ) ;
listContainer . innerHTML = "" ;
if ( state . userHistory . length === 0 ) {
2025-07-04 09:36:40 +02:00
listContainer . innerHTML = ` <div class="empty-state"><i class="fas fa-history fa-3x mb-3"></i><p class="lead"> ${ _ ( 'historyEmpty' ) } </p><p class="text-muted"> ${ _ ( 'historyEmptySub' ) } </p></div> ` ;
2025-07-02 14:16:25 +02:00
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' ;
2025-07-11 12:10:50 +02:00
const isAvailable = isContentAvailableLocally ( item . title , item . type ) ;
2025-07-04 09:36:40 +02:00
const historyItem = document . createElement ( 'div' ) ;
historyItem . className = 'history-item' ;
historyItem . dataset . id = item . id ;
historyItem . dataset . type = item . type ;
historyItem . dataset . title = item . title ;
2025-07-02 14:16:25 +02:00
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 } < / d i v >
$ { isAvailable ? ` <span class="badge local-badge-history"> ${ _ ( 'local' ) } </span> ` : '' }
< / d i v >
< div class = "history-meta" > $ { _ ( 'viewed' ) } $ { getRelativeTime ( item . timestamp ) } < / d i v >
< / d i v >
< / d i v >
< div class = "history-actions" >
< button class = "action-btn info-btn" title = "${_('moreInfo')}" > < i class = "fas fa-info-circle" > < / i > < / b u t t o n >
< button class = "action-btn trailer-btn" title = "${_('watchTrailer')}" > < i class = "fab fa-youtube" > < / i > < / b u t t o n >
< button class = "action-btn play-btn" title = "${_('addStream')}" $ { ! isAvailable ? 'disabled' : '' } > < i class = "fas fa-plus-circle" > < / i > < / b u t t o n >
< button class = "action-btn delete-btn" title = "${_('clearHistory')}" > < i class = "fas fa-trash-alt" > < / i > < / b u t t o n >
< / d i v > ` ;
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' ) ;
2025-07-04 09:36:40 +02:00
grid . innerHTML = '<div class="col-12 text-center mt-5"><div class="spinner" style="position: static; margin: auto; display: block;"></div></div>' ;
2025-07-02 14:16:25 +02:00
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 ) {
2025-07-04 09:36:40 +02:00
grid . innerHTML = ` <div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p> ${ _ ( 'errorGeneratingRecommendations' ) } </p></div> ` ;
2025-07-02 14:16:25 +02:00
}
}
async function populateStatsTokenFilter ( ) {
const select = document . getElementById ( 'stats-token-filter' ) ;
const currentValue = select . value ;
2025-07-04 09:36:40 +02:00
select . innerHTML = ` <option value="all"> ${ _ ( 'statsAllTokens' ) } </option> ` ;
2025-07-02 14:16:25 +02:00
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 => {
2025-07-04 09:36:40 +02:00
const option = document . createElement ( 'option' ) ;
option . value = token ;
option . textContent = tokenNames [ token ] || ` Token... ${ token . slice ( - 4 ) } ` ;
select . appendChild ( option ) ;
2025-07-02 14:16:25 +02:00
} ) ;
if ( currentValue ) {
select . value = currentValue ;
}
} catch ( e ) {
2025-07-04 09:36:40 +02:00
select . innerHTML = ` <option value="all"> ${ _ ( 'errorLoadingTokens' ) } </option> ` ;
2025-07-02 14:16:25 +02:00
}
}
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 ;
2025-07-11 12:10:50 +02:00
const filteredPlexMovies = selectedToken === 'all' ? state . localMovies : state . localMovies . filter ( server => server . tokenPrincipal === selectedToken ) ;
const filteredPlexSeries = selectedToken === 'all' ? state . localSeries : state . localSeries . filter ( server => server . tokenPrincipal === selectedToken ) ;
const filteredPlexArtists = selectedToken === 'all' ? state . localArtists : state . localArtists . filter ( server => server . tokenPrincipal === selectedToken ) ;
const plexMovieItems = filteredPlexMovies . flatMap ( s => s . titulos ) ;
const plexSeriesItems = filteredPlexSeries . flatMap ( s => s . titulos ) ;
const plexArtistItems = filteredPlexArtists . flatMap ( s => s . titulos ) ;
const jellyfinMovieItems = state . jellyfinMovies . flatMap ( lib => lib . titulos ) ;
const jellyfinSeriesItems = state . jellyfinSeries . flatMap ( lib => lib . titulos ) ;
const allMovieItems = [ ... plexMovieItems , ... jellyfinMovieItems ] ;
const allSeriesItems = [ ... plexSeriesItems , ... jellyfinSeriesItems ] ;
const allArtistItems = [ ... plexArtistItems ] ;
2025-07-02 14:16:25 +02:00
const uniqueMovieTitles = new Set ( allMovieItems . map ( item => item . title ) ) ;
const uniqueSeriesTitles = new Set ( allSeriesItems . map ( item => item . title ) ) ;
2025-07-11 12:10:50 +02:00
const uniqueArtists = new Set ( allArtistItems . map ( item => item . title ) ) ;
2025-07-02 14:16:25 +02:00
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' ) ;
2025-07-27 12:56:26 +02:00
const allJellyfinConnections = await getFromDB ( 'jellyfin_settings' ) ;
2025-07-02 14:16:25 +02:00
const filteredTokens = selectedToken === 'all'
? allTokens
: allTokens . filter ( t => t . token === selectedToken ) ;
const filteredConnections = selectedToken === 'all'
? allConnections
: allConnections . filter ( c => c . tokenPrincipal === selectedToken ) ;
2025-07-27 12:56:26 +02:00
const totalServers = filteredConnections . length + ( selectedToken === 'all' ? allJellyfinConnections . length : 0 ) ;
2025-07-02 14:16:25 +02:00
animateValue ( 'total-tokens' , 0 , filteredTokens . length , 1000 ) ;
2025-07-27 12:56:26 +02:00
animateValue ( 'total-servers' , 0 , totalServers , 1000 ) ;
2025-07-02 14:16:25 +02:00
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 ) {
2025-07-04 09:36:40 +02:00
loader . innerHTML = ` <div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p> ${ _ ( 'errorGeneratingStats' ) } </p></div> ` ;
2025-07-02 14:16:25 +02:00
}
}
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 ) ;
if ( associatedServers . length === 0 ) {
2025-07-04 09:36:40 +02:00
serverList . innerHTML = ` <li> ${ _ ( 'noServersForToken' ) } </li> ` ;
2025-07-02 14:16:25 +02:00
} else {
2025-07-04 09:36:40 +02:00
serverList . innerHTML = associatedServers . map ( s =>
` <li><strong> ${ s . nombre } :</strong> <code> ${ s . token . slice ( 0 , 5 ) } ... ${ s . token . slice ( - 5 ) } </code></li> `
) . join ( '' ) ;
2025-07-02 14:16:25 +02:00
}
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' ) ;
2025-07-04 09:36:40 +02:00
grid . innerHTML = '<div class="col-12 text-center mt-5"><div class="spinner" style="display: block; margin: auto; position: static;"></div></div>' ;
2025-07-02 14:16:25 +02:00
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 ) {
2025-07-04 09:36:40 +02:00
grid . innerHTML = ` <div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p> ${ _ ( 'errorLoadingActorContent' , actorName ) } </p></div> ` ;
2025-07-02 14:16:25 +02:00
}
}
export async function initializeHeroSection ( ) {
const heroSection = document . getElementById ( 'hero-section' ) ;
2025-07-15 09:42:39 +02:00
if ( heroSection . style . display === 'none' || ! state . settings . showHero ) return ;
if ( state . heroIntervalId ) {
clearInterval ( state . heroIntervalId ) ;
state . heroIntervalId = null ;
}
if ( state . heroLoadTimeoutId ) {
clearTimeout ( state . heroLoadTimeoutId ) ;
state . heroLoadTimeoutId = null ;
}
2025-07-02 14:16:25 +02:00
2025-07-13 11:14:42 +02:00
const bg1 = document . querySelector ( '.hero-background-1' ) ;
const bg2 = document . querySelector ( '.hero-background-2' ) ;
const content = document . querySelector ( '.hero-content' ) ;
2025-07-15 09:42:39 +02:00
const heroButtons = content . querySelector ( '.hero-buttons' ) ;
2025-07-13 11:14:42 +02:00
2025-07-15 09:42:39 +02:00
content . querySelector ( '.hero-title' ) . textContent = _ ( 'heroWelcome' ) ;
content . querySelector ( '.hero-subtitle' ) . textContent = _ ( 'heroSubtitle' ) ;
content . querySelector ( '#hero-rating' ) . innerHTML = '' ;
content . querySelector ( '#hero-year' ) . innerHTML = '' ;
content . querySelector ( '#hero-extra' ) . innerHTML = '' ;
heroButtons . style . display = 'none' ;
2025-07-13 11:14:42 +02:00
heroSection . classList . add ( 'no-overlay' ) ;
gsap . set ( bg1 , { backgroundImage : ` url(img/hero-def.png) ` , autoAlpha : 1 , scale : 1 } ) ;
gsap . set ( bg2 , { autoAlpha : 0 } ) ;
2025-07-15 09:42:39 +02:00
gsap . set ( content , { autoAlpha : 1 } ) ;
2025-07-13 11:14:42 +02:00
heroSection . classList . remove ( 'loading' ) ;
2025-07-15 09:42:39 +02:00
state . heroLoadTimeoutId = setTimeout ( ( ) => {
if ( state . currentView === 'home' ) {
loadTmdbHeroContent ( ) ;
}
} , 5000 ) ;
2025-07-13 11:14:42 +02:00
async function loadTmdbHeroContent ( ) {
heroSection . classList . remove ( 'no-overlay' ) ;
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 ) {
2025-07-28 15:29:50 +02:00
return ;
2025-07-13 11:14:42 +02:00
}
2025-07-02 14:16:25 +02:00
2025-07-13 11:14:42 +02:00
let currentBg = bg1 ;
let nextBg = bg2 ;
let currentIndex = - 1 ;
function changeHeroSlide ( ) {
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' )
] ;
2025-07-02 14:16:25 +02:00
const tl = gsap . timeline ( {
onComplete : ( ) => {
const temp = currentBg ;
currentBg = nextBg ;
nextBg = temp ;
2025-07-13 11:14:42 +02:00
gsap . set ( nextBg , { autoAlpha : 0 } ) ;
2025-07-02 14:16:25 +02:00
}
} ) ;
2025-07-13 11:14:42 +02:00
tl . to ( heroElements , { autoAlpha : 0 , y : 30 , stagger : 0.08 , duration : 0.6 , ease : 'power3.in' } , 0 ) ;
gsap . set ( nextBg , { backgroundImage : ` url( ${ nextImage . src } ) ` , autoAlpha : 0 } ) ;
tl . to ( currentBg , { autoAlpha : 0 , duration : 2.5 , ease : 'power2.inOut' } , 0 ) ;
tl . to ( nextBg , { autoAlpha : 1 , duration : 2.5 , ease : 'power2.inOut' } , 0 ) ;
2025-07-02 14:16:25 +02:00
gsap . fromTo ( nextBg , { scale : 1.15 , transformOrigin : 'center center' } , { scale : 1 , duration : 12 , ease : 'none' } ) ;
2025-07-13 11:14:42 +02:00
tl . fromTo ( heroElements , { y : - 30 , autoAlpha : 0 } , { y : 0 , autoAlpha : 1 , stagger : 0.1 , duration : 1.2 , ease : 'power3.out' } , '>-0.8' ) ;
} ;
}
2025-07-02 14:16:25 +02:00
2025-07-13 11:14:42 +02:00
if ( state . heroIntervalId ) {
clearInterval ( state . heroIntervalId ) ;
}
changeHeroSlide ( ) ;
state . heroIntervalId = setInterval ( changeHeroSlide , 12000 ) ;
2025-07-02 14:16:25 +02:00
2025-07-13 11:14:42 +02:00
} catch ( error ) {
console . error ( "Error initializing hero section from TMDB:" , error ) ;
}
2025-07-02 14:16:25 +02:00
}
}
function updateHeroContent ( item ) {
2025-07-15 09:42:39 +02:00
const heroContent = document . querySelector ( '.hero-content' ) ;
const heroTitle = heroContent . querySelector ( '.hero-title' ) ;
const heroSubtitle = heroContent . querySelector ( '.hero-subtitle' ) ;
const heroRating = heroContent . querySelector ( '#hero-rating' ) ;
const heroYear = heroContent . querySelector ( '#hero-year' ) ;
const heroExtra = heroContent . querySelector ( '#hero-extra' ) ;
const heroButtons = heroContent . querySelector ( '.hero-buttons' ) ;
const heroPlayBtn = heroButtons . querySelector ( '#hero-play-btn' ) ;
const heroInfoBtn = heroButtons . querySelector ( '#hero-info-btn' ) ;
2025-07-02 14:16:25 +02:00
const type = item . title ? 'movie' : 'tv' ;
const title = item . title || item . name ;
2025-07-11 12:10:50 +02:00
const isAvailable = isContentAvailableLocally ( title , type ) ;
2025-07-02 14:16:25 +02:00
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' ) } ` ;
2025-07-15 09:42:39 +02:00
heroButtons . style . display = 'block' ;
2025-07-02 14:16:25 +02:00
if ( heroPlayBtn ) {
heroPlayBtn . onclick = ( ) => addStreamToList ( title , type , heroPlayBtn ) ;
heroPlayBtn . disabled = ! isAvailable ;
}
if ( heroInfoBtn ) {
2025-07-05 10:04:41 +02:00
heroInfoBtn . onclick = ( ) => {
state . lastClickedCardElement = heroInfoBtn ;
showItemDetails ( item . id , type ) ;
} ;
2025-07-02 14:16:25 +02:00
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 {
2025-07-11 12:10:50 +02:00
const streamData = await fetchAllAvailableStreams ( title , type ) ;
2025-07-02 14:16:25 +02:00
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 ;
}
}
}
2025-07-29 15:05:06 +02:00
export async function downloadM3U ( items , 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> ` ; } const itemsArray = Array . isArray ( items ) ? items : [ { title : items , type : arguments [ 1 ] } ] ; const collectiveTitle = itemsArray . length > 1 ? 'CinePlex_Playlist' : itemsArray [ 0 ] . title ; showNotification ( _ ( 'generatingM3U' , collectiveTitle ) , "info" ) ; try { let m3uContent = "#EXTM3U\n" ; let streamsFound = 0 ; for ( const item of itemsArray ) { try { const streamData = await fetchAllAvailableStreams ( item . title , item . type ) ; if ( streamData . success && streamData . streams . length > 0 ) { streamsFound += streamData . streams . length ; streamData . streams . forEach ( stream => { m3uContent += ` ${ stream . extinf } \n ${ stream . url } \n ` ; } ) ; } } catch ( error ) { console . warn ( ` Could not fetch streams for ${ item . title } : ` , error ) ; } } if ( streamsFound === 0 ) throw new Error ( _ ( 'noStreamsFoundForSelection' ) ) ; 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 = ` ${ collectiveTitle . replace ( /[^a-z0-9]/gi , '_' ) } .m3u ` ; document . body . appendChild ( a ) ; a . click ( ) ; document . body . removeChild ( a ) ; URL . revokeObjectURL ( url ) ; showNotification ( _ ( 'm3uDownloaded' , collectiveTitle ) , 'success' ) ; } catch ( error ) { showNotification ( _ ( 'errorGeneratingM3U' , error . message ) , "error" ) ; } finally { state . isDownloadingM3U = false ; if ( buttonElement && originalButtonContent ) { buttonElement . innerHTML = originalButtonContent ; buttonElement . disabled = false ; } } }
2025-07-02 14:16:25 +02:00
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 ;
2025-07-29 15:05:06 +02:00
document . getElementById ( 'openaiApiKey' ) . value = state . settings . openaiApiKey || '' ;
2025-07-02 14:16:25 +02:00
document . getElementById ( 'phpScriptUrl' ) . value = state . settings . phpScriptUrl || '' ;
document . getElementById ( 'lightModeToggle' ) . checked = state . settings . theme === 'light' ;
document . getElementById ( 'showHeroToggle' ) . checked = state . settings . showHero ;
2025-07-11 12:10:50 +02:00
document . getElementById ( 'jellyfinServerUrl' ) . value = state . jellyfinSettings . url || '' ;
document . getElementById ( 'jellyfinUsername' ) . value = state . jellyfinSettings . username || '' ;
document . getElementById ( 'jellyfinPassword' ) . value = state . jellyfinSettings . password || '' ;
2025-07-02 14:16:25 +02:00
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 ( ) {
2025-07-28 13:58:30 +02:00
const oldRegion = state . settings . watchRegion ;
2025-07-02 14:16:25 +02:00
const newSettings = {
id : 'user_settings' ,
apiKey : document . getElementById ( 'tmdbApiKey' ) . value . trim ( ) ,
2025-07-29 15:05:06 +02:00
openaiApiKey : document . getElementById ( 'openaiApiKey' ) . value . trim ( ) ,
2025-07-02 14:16:25 +02:00
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 ( ) ,
2025-07-25 23:57:03 +02:00
phpFileAction : document . getElementById ( 'phpFileActionAppend' ) . checked ? 'append' : 'overwrite' ,
watchRegion : document . getElementById ( 'region-filter' ) . value
2025-07-02 14:16:25 +02:00
} ;
state . settings = { ... state . settings , ... newSettings } ;
2025-07-11 12:10:50 +02:00
const newJellyfinSettings = {
id : 'jellyfin_credentials' ,
url : document . getElementById ( 'jellyfinServerUrl' ) . value . trim ( ) ,
username : document . getElementById ( 'jellyfinUsername' ) . value . trim ( ) ,
password : document . getElementById ( 'jellyfinPassword' ) . value
} ;
state . jellyfinSettings = { ... state . jellyfinSettings , ... newJellyfinSettings } ;
2025-07-02 14:16:25 +02:00
try {
2025-07-11 12:10:50 +02:00
await Promise . all ( [
addItemsToStore ( 'settings' , [ state . settings ] ) ,
addItemsToStore ( 'jellyfin_settings' , [ state . jellyfinSettings ] )
] ) ;
2025-07-02 14:16:25 +02:00
showNotification ( _ ( 'settingsSavedSuccess' ) , 'success' ) ;
applyTheme ( state . settings . theme ) ;
applyHeroVisibility ( state . settings . showHero ) ;
2025-07-28 13:58:30 +02:00
if ( newSettings . watchRegion !== oldRegion ) {
loadInitialContent ( ) ;
2025-07-02 14:16:25 +02:00
}
2025-07-28 13:58:30 +02:00
bootstrap . Modal . getInstance ( document . getElementById ( 'settingsModal' ) ) ? . hide ( ) ;
2025-07-02 14:16:25 +02:00
} 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 ;
2025-07-25 23:57:03 +02:00
let script = ` <?php \n header('Content-Type: application/json'); \n header('Access-Control-Allow-Origin: *'); \n header('Access-Control-Allow-Methods: POST, OPTIONS'); \n header('Access-Control-Allow-Headers: Content-Type, Origin, X-Secret-Key'); \n \n if ( $ _SERVER['REQUEST_METHOD'] == 'OPTIONS') { \n http_response_code(200); \n exit(0); \n } \n \n define('SAVE_DIRECTORY', ' ${ savePath . replace ( /'/g , "\\'" ) } '); \n define('FILENAME', ' ${ filename . replace ( /'/g , "\\'" ) } '); \n define('FILE_ACTION_APPEND', ${ appendToFile ? 'true' : 'false' } ); \n ${ useSecretKey ? ` define('SECRET_KEY', ' ${ secretKey . replace ( /'/g , "\\'" ) } '); ` : '' } \n \n function sendResponse( $ success, $ message, $ filename = '', $ http_code = 200) { \n if (! $ success && $ http_code === 200) { \n $ http_code = 400; \n } \n http_response_code( $ http_code); \n echo json_encode(['success' => $ success, 'message' => $ message, 'filename' => $ filename]); \n exit; \n } \n ` ;
2025-07-02 14:16:25 +02:00
if ( useSecretKey ) {
2025-07-25 23:57:03 +02:00
script += ` \n $ auth_key = isset( $ _SERVER['HTTP_X_SECRET_KEY']) ? $ _SERVER['HTTP_X_SECRET_KEY'] : ''; \n if (!defined('SECRET_KEY') || SECRET_KEY === '' || $ auth_key !== SECRET_KEY) { \n sendResponse(false, 'Acceso no autorizado. Clave secreta inválida o no proporcionada.', '', 403); \n } \n ` ;
2025-07-02 14:16:25 +02:00
}
2025-07-25 23:57:03 +02:00
script += ` \n $ json_data = file_get_contents('php://input'); \n $ data = json_decode( $ json_data, true); \n \n if (json_last_error() !== JSON_ERROR_NONE) { \n sendResponse(false, 'Error: Datos JSON inválidos.'); \n } \n \n if (!isset( $ data['streams']) || !is_array( $ data['streams']) || empty( $ data['streams'])) { \n sendResponse(false, 'Error: El JSON debe contener un array "streams" no vacío.'); \n } \n \n $ save_dir = SAVE_DIRECTORY !== '' ? rtrim(SAVE_DIRECTORY, '/ \\ ') : __DIR__; \n \n if (!is_dir( $ save_dir) || !is_writable( $ save_dir)) { \n sendResponse(false, 'Error del servidor: El directorio de destino no existe o no tiene permisos de escritura.', '', 500); \n } \n \n $ safe_filename = preg_replace('/[^ \\ w \\ s._-]/', '', basename(FILENAME)); \n $ safe_filename = preg_replace('/ \\ s+/', '_', $ safe_filename); \n $ target_path = $ save_dir . DIRECTORY_SEPARATOR . $ safe_filename; \n \n $ content_to_write = ""; \n \n if (FILE_ACTION_APPEND) { \n $ file_exists = file_exists( $ target_path); \n if (! $ file_exists) { \n $ content_to_write .= "#EXTM3U \\ n"; \n } \n foreach ( $ data['streams'] as $ stream) { \n if (isset( $ stream['extinf'], $ stream['url'])) { \n $ content_to_write .= trim( $ stream['extinf']) . " \\ n"; \n $ content_to_write .= trim( $ stream['url']) . " \\ n"; \n } \n } \n if (file_put_contents( $ target_path, $ content_to_write, FILE_APPEND | LOCK_EX) !== false) { \n sendResponse(true, 'Streams añadidos correctamente al archivo.', $ safe_filename, 200); \n } else { \n sendResponse(false, 'Error del servidor: No se pudo añadir contenido al archivo.', '', 500); \n } \n } else { // Overwrite mode \n $ content_to_write = "#EXTM3U \\ n"; \n foreach ( $ data['streams'] as $ stream) { \n if (isset( $ stream['extinf'], $ stream['url'])) { \n $ content_to_write .= trim( $ stream['extinf']) . " \\ n"; \n $ content_to_write .= trim( $ stream['url']) . " \\ n"; \n } \n } \n if (file_put_contents( $ target_path, $ content_to_write, LOCK_EX) !== false) { \n sendResponse(true, 'Archivo de streams sobrescrito correctamente.', $ safe_filename, 201); \n } else { \n sendResponse(false, 'Error del servidor: No se pudo escribir el archivo.', '', 500); \n } \n } \n ?> ` ;
2025-07-02 14:16:25 +02:00
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' ) ;
2025-07-04 09:36:40 +02:00
select . innerHTML = ` <option value=""> ${ _ ( 'loading' ) } </option> ` ;
2025-07-02 14:16:25 +02:00
const photoServers = [ ... new Map ( state . localPhotos . map ( item => [ item . tokenPrincipal , item ] ) ) . values ( ) ] ;
if ( photoServers . length === 0 ) {
2025-07-04 09:36:40 +02:00
select . innerHTML = ` <option value=""> ${ _ ( 'noPhotoServers' ) } </option> ` ;
2025-07-02 14:16:25 +02:00
document . getElementById ( 'photos-empty-state' ) . style . display = 'block' ;
document . getElementById ( 'photos-grid' ) . innerHTML = '' ;
return ;
}
document . getElementById ( 'photos-empty-state' ) . style . display = 'none' ;
2025-07-04 09:36:40 +02:00
select . innerHTML = ` <option value=""> ${ _ ( 'selectServer' ) } </option> ` ;
2025-07-02 14:16:25 +02:00
photoServers . forEach ( server => {
2025-07-04 09:36:40 +02:00
const option = document . createElement ( 'option' ) ;
option . value = server . tokenPrincipal ;
option . textContent = server . serverName || ` Servidor ${ server . tokenPrincipal . slice ( - 4 ) } ` ;
select . appendChild ( option ) ;
2025-07-02 14:16:25 +02:00
} ) ;
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 ;
2025-07-25 23:57:03 +02:00
state . currentPhotoServer = state . localPhotos . find ( s => s . tokenPrincipal === selectedToken ) ;
2025-07-02 14:16:25 +02:00
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 > < / d i v >
< div class = "album-card-title" > $ { item . title } < / d i v >
$ { 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 } < / d i v >
` ;
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 = '' ;
2025-07-04 09:36:40 +02:00
const rootItem = document . createElement ( 'li' ) ;
rootItem . className = 'breadcrumb-item' ;
const rootLink = document . createElement ( 'a' ) ;
rootLink . href = '#' ;
rootLink . innerHTML = ` <i class="fas fa-home"></i> ${ _ ( 'photosBreadcrumbHome' ) } ` ;
2025-07-02 14:16:25 +02:00
rootLink . onclick = ( e ) => {
e . preventDefault ( ) ;
handlePhotoTokenChange ( ) ;
} ;
rootItem . appendChild ( rootLink ) ;
breadcrumb . appendChild ( rootItem ) ;
state . photoStack . forEach ( ( item , index ) => {
2025-07-04 09:36:40 +02:00
const divider = document . createElement ( 'li' ) ;
divider . className = 'breadcrumb-divider' ;
divider . innerHTML = '<i class="fas fa-chevron-right"></i>' ;
2025-07-02 14:16:25 +02:00
breadcrumb . appendChild ( divider ) ;
2025-07-04 09:36:40 +02:00
const breadcrumbItem = document . createElement ( 'li' ) ;
breadcrumbItem . className = 'breadcrumb-item' ;
2025-07-02 14:16:25 +02:00
if ( index === state . photoStack . length - 1 ) {
breadcrumbItem . classList . add ( 'active' ) ;
breadcrumbItem . textContent = item . title ;
} else {
2025-07-04 09:36:40 +02:00
const link = document . createElement ( 'a' ) ;
link . href = '#' ;
link . textContent = item . title ;
2025-07-02 14:16:25 +02:00
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 ( ) ;
2025-07-25 23:57:03 +02:00
}
export async function loadProviders ( ) {
const providers = await fetchAllProviders ( state . settings . watchRegion ) ;
renderProviders ( providers ) ;
2025-07-28 15:29:50 +02:00
}