/**
* js/main.js
* Main application logic for routing, filtering, searching, and initializing the app.
* @module main
*/
// Import data loading functionality
import { fetchAllData } from './dataLoader.js';
// Import display and rendering functions
import { setupHero, renderHorizontalRow, renderGrid, renderNotifs, openDetails, closeDetails, playCurrentMedia, renderCollections, renderActorsList, closeActorDetails, renderActorsListSearch, refreshActiveSeriesDetails, openActorDetails } from './display.js';
// Import utility functions for video player and UI interactions
import { closeVideo, toggleNotifs, toggleSettings, toggleMobileMenu, toggleMobileSearch, showLoader, hideLoader, hardenPlayerControls, initPlayerPersistence, downloadProgressBackup, openProgressImport, importProgressFromFile } from './utils.js';
// Global application state
let appData = { films: {}, series: {}, collections: {}, notifs: {}, actors: {} };
let currentView = 'home';
const ROUTES = new Set(['home', 'films', 'series', 'actors', 'collections']);
let activeDetailRoute = null;
let activeActorRoute = null;
// Expose functions to global window scope for inline HTML event handlers
window.router = router;
window.toggleNotifs = toggleNotifs;
window.toggleSettings = toggleSettings;
window.closeDetails = closeDetailsAndRoute;
window.playCurrentMedia = playCurrentMedia;
window.closeVideo = closeVideo;
window.applyFilters = applyFilters;
window.resetFilters = resetFilters;
window.toggleMobileMenu = toggleMobileMenu;
window.downloadProgressBackup = downloadProgressBackup;
window.openProgressImport = openProgressImport;
window.closeActorDetails = closeActorDetailsAndRoute;
window.openMediaDetails = openMediaDetails;
window.openActorDetailsRoute = openActorDetailsRoute;
window.refreshActiveSeriesDetails = refreshActiveSeriesDetails;
// Initialize application when DOM is fully loaded
document.addEventListener('DOMContentLoaded', async () => {
showLoader();
window.STREAMIT_BASE = getBasePath(window.location.pathname || '/');
// Set up video player security and persistence
hardenPlayerControls();
initPlayerPersistence();
// Attach mobile menu and search event listeners
document.getElementById('mobileMenuBtn').addEventListener('click', toggleMobileMenu);
document.getElementById('mobileSearchBtn').addEventListener('click', toggleMobileSearch);
// Set up progress import file input handler
const importInput = document.getElementById('progressImportInput');
if (importInput) {
importInput.addEventListener('change', async (event) => {
// Get the selected file
const file = event.target.files && event.target.files[0];
if (!file) return;
try {
// Import progress data from file
const result = await importProgressFromFile(file);
alert(`Progression importée (${result.filmsCount} films, ${result.seriesCount} séries).`);
window.location.reload();
} catch (err) {
console.error('Import progression échouée', err);
alert(err.message || "Erreur lors de l'import de la progression.");
} finally {
// Reset input and close settings dropdown
event.target.value = '';
const settings = document.getElementById('settingsDropdown');
if (settings) settings.classList.remove('active');
}
});
}
// Load all application data (films, series, collections, notifications, actors)
const data = await fetchAllData();
appData = data;
// Render notifications badge and populate UI
renderNotifs(data.notifs);
// Set up hero section and filters, then navigate to the current URL route
initHero();
populateFilters();
const { view, detail, fromQuery } = parseLocationRoute();
router(view, { pushState: false, detail });
if (fromQuery) {
updateRoute(view, detail, true);
}
hideLoader();
});
// Handle browser back/forward navigation
window.addEventListener('popstate', () => {
const { view, detail } = parseLocationRoute();
router(view, { pushState: false, detail });
});
/**
* Builds a URL-friendly slug from a title string.
* @param {string} text - Source text.
* @returns {string} Slug.
*/
function slugify(text) {
if (!text) return '';
return String(text)
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
/**
* Determines the base path (useful for deployments in subfolders).
* @param {string} pathname - Current location pathname.
* @returns {string} Normalized base path ending with '/'.
*/
function getBasePath(pathname) {
let path = pathname || '/';
if (!path.endsWith('/')) {
const parts = path.split('/');
const last = parts[parts.length - 1];
const prev = parts[parts.length - 2];
if (ROUTES.has(last) || last === 'index.html') {
parts.pop();
} else if (ROUTES.has(prev)) {
parts.pop();
parts.pop();
}
path = parts.join('/') + '/';
}
return path === '' ? '/' : path;
}
/**
* Resolves the view name from the current URL.
* @returns {string} View name.
*/
function parseLocationRoute() {
const url = new URL(window.location.href);
const routeParam = url.searchParams.get('route');
const rawPath = routeParam || url.pathname || '/';
const pathname = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
const basePath = getBasePath(pathname);
const raw = pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname;
const parts = raw.split('/').filter(Boolean);
if (parts.length === 0 || parts[0] === 'index.html') {
return { view: 'home', detail: null, fromQuery: Boolean(routeParam) };
}
const view = ROUTES.has(parts[0]) ? parts[0] : 'home';
if (view === 'home') {
return { view: 'home', detail: null, fromQuery: Boolean(routeParam) };
}
const slug = parts[1] || '';
const detail = slug ? { type: view, slug } : null;
return { view, detail, fromQuery: Boolean(routeParam) };
}
/**
* Finds a media item by slug within a media collection.
* @param {string} type - 'films' or 'series'.
* @param {string} slug - Media slug.
* @returns {Object|null} Media item or null.
*/
function findMediaBySlug(type, slug) {
if (!slug) return null;
const source = type === 'films' ? Object.values(appData.films) : Object.values(appData.series);
return source.find(item => slugify(item.title) === slug) || null;
}
/**
* Finds an actor by slug.
* @param {string} slug - Actor slug.
* @returns {Object|null} Actor object or null.
*/
function findActorBySlug(slug) {
if (!slug) return null;
const actors = Object.values(appData.actors);
return actors.find(actor => slugify(actor.name) === slug) || null;
}
/**
* Finds a collection by slug.
* @param {string} slug - Collection slug.
* @returns {Object|null} Collection object or null.
*/
function findCollectionBySlug(slug) {
if (!slug) return null;
const entries = Object.entries(appData.collections || {});
for (const [key, collection] of entries) {
const keySlug = slugify(key);
const nameSlug = slugify(collection?.name || '');
if (slug === keySlug || slug === nameSlug) {
return collection;
}
}
return null;
}
/**
* Builds media items array for a collection.
* @param {Object} collection - Collection object.
* @returns {Array} Ordered media items.
*/
function getCollectionItems(collection) {
const items = [];
if (!collection) return items;
if (Array.isArray(collection.films)) {
collection.films.forEach(title => {
const foundFilm = Object.values(appData.films).find(f => f.title === title);
if (foundFilm) items.push(foundFilm);
});
}
if (Array.isArray(collection.series)) {
collection.series.forEach(title => {
const foundSerie = Object.values(appData.series).find(s => s.title === title);
if (foundSerie) items.push(foundSerie);
});
}
return items;
}
/**
* Updates the URL path for the current view and optional detail.
* @param {string} view - Base view.
* @param {Object|null} detail - Optional detail route.
*/
function updateRoute(view, detail = null, replace = false) {
const basePath = getBasePath(window.location.pathname || '/');
let targetPath = basePath;
if (view && view !== 'home') {
targetPath = `${basePath}${view}`;
}
if (detail && detail.slug) {
targetPath = `${targetPath}/${detail.slug}`;
}
if (window.location.pathname !== targetPath) {
if (replace) {
window.history.replaceState({ view, detail }, '', targetPath);
} else {
window.history.pushState({ view, detail }, '', targetPath);
}
}
}
/**
* Opens a media details overlay and syncs the route.
* @param {Object} item - Media item.
*/
function openMediaDetails(item) {
if (!item) return;
const isSerie = item.type === 'serie' || item.seasons !== undefined;
const view = isSerie ? 'series' : 'films';
const slug = slugify(item.title);
activeDetailRoute = { type: view, slug };
activeActorRoute = null;
router(view, { detail: { type: view, slug, item }, pushState: true });
}
/**
* Opens an actor details overlay and syncs the route.
* @param {Object} actor - Actor item.
* @param {Object} filmsData - Films data.
* @param {Object} seriesData - Series data.
*/
function openActorDetailsRoute(actor, filmsData, seriesData) {
if (!actor) return;
const slug = slugify(actor.name);
activeActorRoute = { type: 'actors', slug };
activeDetailRoute = null;
router('actors', { detail: { type: 'actors', slug, actor, filmsData, seriesData }, pushState: true });
}
/**
* Closes media details overlay and restores the base view route.
*/
function closeDetailsAndRoute() {
closeDetails();
activeDetailRoute = null;
updateRoute(currentView, null);
}
/**
* Closes actor details overlay and restores the base view route.
*/
function closeActorDetailsAndRoute() {
closeActorDetails();
activeActorRoute = null;
updateRoute(currentView, null);
}
/**
* Sets navigation link colors to default (removes highlight).
* @param {HTMLElement} navHome - Navigation link for home
* @param {HTMLElement} navSeries - Navigation link for series
* @param {HTMLElement} navFilms - Navigation link for films
* @param {HTMLElement} navCollections - Navigation link for collections
* @param {HTMLElement} navActors - Navigation link for actors
*/
function textWhite(navHome, navSeries, navFilms, navCollections, navActors) {
// Remove all active and highlight classes from navigation links
[navHome, navSeries, navFilms, navCollections, navActors].forEach(el => {
if (el) el.classList.remove('text-white', 'text-red-500');
});
}
/**
* Routes to the specified view and updates the UI accordingly.
* @param {string} view - The view to route to ('home', 'series', 'films', 'collections', 'actors').
*/
function router(view, options = {}) {
const { pushState = true } = options;
const detail = options.detail || null;
const safeView = ROUTES.has(view) ? view : 'home';
currentView = safeView;
// Clear any active search when navigating
clearSearch();
// Close mobile menu and search panels
document.getElementById('mobileMenuPanel').classList.remove('active');
document.getElementById('mobileSearchPanel').classList.remove('active');
// Get references to all page sections and navigation elements
const hero = document.getElementById('heroSection');
const filters = document.getElementById('filterSection');
const homeContent = document.getElementById('homePageContent');
const collectionsContent = document.getElementById('collectionsContent');
const genericGrid = document.getElementById('genericGridContainer');
const actorsContent = document.getElementById('actorsContent');
const title = document.getElementById('titleText');
const navHome = document.getElementById('nav-home');
const navSeries = document.getElementById('nav-series');
const navFilms = document.getElementById('nav-films');
const navCollections = document.getElementById('nav-collections');
const navActors = document.getElementById('nav-actors');
const sectionTitle = document.getElementById('sectionTitle');
const existingBackBtn = document.getElementById('collectionBackBtn');
if (existingBackBtn) existingBackBtn.remove();
// Reset all navigation link styles
textWhite(navHome, navSeries, navFilms, navCollections, navActors);
// Scroll to top of page smoothly
window.scrollTo({ top: 0, behavior: 'smooth' });
// Hide all sections initially
hero.classList.add('hidden');
filters.classList.add('hidden');
homeContent.classList.add('hidden');
collectionsContent.classList.add('hidden');
actorsContent.classList.add('hidden');
genericGrid.classList.add('hidden');
document.getElementById('contentGrid').innerHTML = '';
// Prepare data collections for current view
const allFilms = Object.values(appData.films);
const allSeries = Object.values(appData.series);
// Render content based on selected view
if (safeView === 'home') {
if (navHome) navHome.classList.add('text-white');
hero.classList.remove('hidden');
homeContent.classList.remove('hidden');
// Get latest films and series for home page
const latestFilms = [...allFilms].sort((a, b) => b.year - a.year).slice(0, 5);
const latestSeries = [...allSeries].sort((a, b) => b.year - a.year).slice(0, 5);
renderHorizontalRow('homeFilmsRow', latestFilms);
renderHorizontalRow('homeSeriesRow', latestSeries);
// Enable horizontal mouse wheel scrolling for rows
enableHorizontalWheelScroll();
}
else if (safeView === 'series') {
// Show series view with filters
if (navSeries) navSeries.classList.add('text-white');
filters.classList.remove('hidden');
genericGrid.classList.remove('hidden');
title.innerText = "Toutes les Séries TV";
populateFilters();
applyFilters();
}
else if (safeView === 'films') {
// Show films view with filters
if (navFilms) navFilms.classList.add('text-white');
filters.classList.remove('hidden');
genericGrid.classList.remove('hidden');
title.innerText = "Tous les Films";
populateFilters();
applyFilters();
}
else if (safeView === 'collections') {
// Show collections list or a selected collection detail view
if (navCollections) navCollections.classList.add('text-white');
const selectedCollection = detail && detail.type === 'collections'
? (detail.collection || findCollectionBySlug(detail.slug))
: null;
if (selectedCollection) {
genericGrid.classList.remove('hidden');
title.innerText = selectedCollection.name || 'Collection';
if (sectionTitle) {
const backBtn = document.createElement('button');
backBtn.id = 'collectionBackBtn';
backBtn.className = 'ml-auto text-sm font-bold text-gray-300 hover:text-white transition-colors flex items-center gap-2';
backBtn.innerHTML = '<i class="fas fa-arrow-left text-xs"></i><span>Retour aux collections</span>';
backBtn.onclick = () => router('collections');
sectionTitle.appendChild(backBtn);
}
renderGrid(getCollectionItems(selectedCollection), { showStatusBadges: false });
} else {
collectionsContent.classList.remove('hidden');
renderCollections(appData.collections, appData);
enableHorizontalWheelScroll();
}
}
else if (safeView === 'actors') {
// Show actors view
if (navActors) navActors.classList.add('text-white');
actorsContent.classList.remove('hidden');
renderActorsList(appData.actors, appData.films, appData.series);
}
if (detail && detail.type === 'actors') {
const actor = detail.actor || findActorBySlug(detail.slug);
if (actor) {
openActorDetails(actor, detail.filmsData || appData.films, detail.seriesData || appData.series);
activeActorRoute = { type: 'actors', slug: detail.slug };
}
} else if (detail && (detail.type === 'films' || detail.type === 'series')) {
const item = detail.item || findMediaBySlug(detail.type, detail.slug);
if (item) {
openDetails(item);
activeDetailRoute = { type: detail.type, slug: detail.slug };
}
} else {
closeDetails();
closeActorDetails();
activeDetailRoute = null;
activeActorRoute = null;
}
if (pushState) {
updateRoute(safeView, detail && detail.slug ? detail : null);
}
}
/**
* Initializes the hero section with a featured item or the latest item.
*/
function initHero() {
const all = [...Object.values(appData.films), ...Object.values(appData.series)];
if (all.length === 0) return;
// Try to find a featured item first
const featuredItem = all.find(item => item.featured === true);
if (featuredItem) {
setupHero(featuredItem);
} else {
// Fallback to most recent item if no featured item
all.sort((a, b) => b.year - a.year);
setupHero(all[0]);
}
}
/**
* Populates filter dropdowns based on available data.
*/
function populateFilters() {
const source = currentView === 'films' ? Object.values(appData.films) : Object.values(appData.series);
// Extract unique values for filter dropdowns
const genres = new Set();
const years = new Set();
const directors = new Set();
// Collect all unique genres, years, and directors/creators
source.forEach(item => {
item.genres?.forEach(g => genres.add(g));
if (item.year) years.add(item.year);
const people = item.directors || item.creators || [];
people.forEach(p => directors.add(p));
});
// Populate genre dropdown with sorted values
const genreSel = document.getElementById('filterGenre');
genreSel.innerHTML = '<option value="">Tous les genres</option>';
Array.from(genres).sort().forEach(g => genreSel.add(new Option(g, g)));
// Populate year dropdown with sorted values (descending)
const yearSel = document.getElementById('filterYear');
yearSel.innerHTML = '<option value="">Toutes</option>';
Array.from(years).sort((a, b) => b - a).forEach(y => yearSel.add(new Option(y, y)));
// Populate director/creator dropdown with sorted values
const directorSel = document.getElementById('filterDirector');
directorSel.innerHTML = '<option value="">Tous</option>';
Array.from(directors).sort().forEach(d => directorSel.add(new Option(d, d)));
}
/**
* Applies selected filters and sorting to the displayed items.
*/
function applyFilters() {
// Get current filter and sort values
const genre = document.getElementById('filterGenre').value;
const year = document.getElementById('filterYear').value;
const imdb = parseFloat(document.getElementById('filterImdb').value) || 0;
const director = document.getElementById('filterDirector').value;
const sortBy = document.getElementById('sortBy').value;
const source = currentView === 'films' ? Object.values(appData.films) : Object.values(appData.series);
// Filter items based on selected criteria
let filtered = source.filter(item => {
// Check if item matches all selected filters
const gMatch = !genre || item.genres?.includes(genre);
const yMatch = !year || item.year == year;
const iMatch = (item.IMDb || 0) >= imdb;
const people = item.directors || item.creators || [];
const dMatch = !director || people.includes(director);
return gMatch && yMatch && iMatch && dMatch;
});
// Sort filtered results based on selected option
filtered.sort((a, b) => {
switch (sortBy) {
case 'date_desc': return b.year - a.year;
case 'date_asc': return a.year - b.year;
case 'rating_desc': return (b.IMDb || 0) - (a.IMDb || 0);
case 'alpha_desc': return b.title.localeCompare(a.title);
case 'alpha_asc':
default: return a.title.localeCompare(b.title);
}
});
// Render the filtered and sorted results
renderGrid(filtered);
}
/**
* Resets all filters to default values and reapplies them.
*/
function resetFilters() {
// Reset all filter dropdowns to default values
document.getElementById('filterGenre').value = "";
document.getElementById('filterYear').value = "";
document.getElementById('filterImdb').value = "";
document.getElementById('filterDirector').value = "";
document.getElementById('sortBy').value = "alpha_asc";
applyFilters();
}
/**
* Handles search input and updates the displayed content accordingly.
* @param {Event} e - The input event.
*/
function handleSearch(e) {
// Get search query and normalize to lowercase
const q = e.target.value.toLowerCase();
// Sync desktop and mobile search inputs
if (e.target.id === 'searchInput') document.getElementById('mobileSearchInput').value = q;
else document.getElementById('searchInput').value = q;
// Get references to all page sections
const hero = document.getElementById('heroSection');
const filters = document.getElementById('filterSection');
const homeContent = document.getElementById('homePageContent');
const collectionsContent = document.getElementById('collectionsContent');
const genericGrid = document.getElementById('genericGridContainer');
const actorsContent = document.getElementById('actorsContent');
// If search is empty, restore current view
if (!q) {
if (currentView === 'home') {
hero.classList.remove('hidden'); homeContent.classList.remove('hidden'); genericGrid.classList.add('hidden');
} else if (currentView === 'collections') {
const { detail } = parseLocationRoute();
if (detail && detail.type === 'collections') {
router('collections', { pushState: false, detail });
} else {
collectionsContent.classList.remove('hidden'); genericGrid.classList.add('hidden');
}
} else if (currentView === 'actors') {
actorsContent.classList.remove('hidden'); genericGrid.classList.add('hidden');
renderActorsList(appData.actors, appData.films, appData.series);
} else {
filters.classList.remove('hidden'); applyFilters();
}
return;
}
// Hide all sections to show only search results
hero.classList.add('hidden');
filters.classList.add('hidden');
homeContent.classList.add('hidden');
collectionsContent.classList.add('hidden');
actorsContent.classList.add('hidden');
genericGrid.classList.remove('hidden');
// Search across all films, series, and actors
const all = [...Object.values(appData.films), ...Object.values(appData.series)];
// Filter media and actors by search query
const resMedia = all.filter(i => i.title.toLowerCase().includes(q));
const resActors = Object.values(appData.actors).filter(i => i.name.toLowerCase().includes(q));
// Clear all navigation highlights during search
const navHome = document.getElementById('nav-home');
const navSeries = document.getElementById('nav-series');
const navFilms = document.getElementById('nav-films');
const navCollections = document.getElementById('nav-collections');
const navActors = document.getElementById('nav-actors');
textWhite(navHome, navSeries, navFilms, navCollections, navActors);
// Update page title with search query and result count
const titleEl = document.getElementById('titleText');
const totalResults = resMedia.length + resActors.length;
if (titleEl) {
titleEl.textContent = `Résultats pour "${q}" `;
let countSpan = titleEl.querySelector('.text-gray-500.text-sm.ml-2');
if (!countSpan) {
countSpan = document.createElement('span');
countSpan.className = 'text-gray-500 text-sm ml-2';
titleEl.appendChild(countSpan);
}
countSpan.textContent = `(${totalResults})`;
} else {
const sectionTitle = document.getElementById('sectionTitle');
if (sectionTitle) {
while (sectionTitle.firstChild) {
sectionTitle.removeChild(sectionTitle.firstChild);
}
const accentSpan = document.createElement('span');
accentSpan.className = 'w-1 h-8 bg-red-600 rounded-full shadow-[0_0_15px_#dc2626]';
sectionTitle.appendChild(accentSpan);
const titleTextSpan = document.createElement('span');
titleTextSpan.id = 'titleText';
titleTextSpan.className = 'tracking-tight';
titleTextSpan.textContent = `Résultats pour "${q}" (${totalResults})`;
sectionTitle.appendChild(titleTextSpan);
}
}
// Render media search results (films and series)
renderGrid(resMedia);
// Render actor search results if any found
if (resActors.length > 0) {
renderActorsListSearch(resActors, appData.films, appData.series);
}
}
/**
* Clears search input fields.
*/
function clearSearch() {
document.getElementById('searchInput').value = '';
document.getElementById('mobileSearchInput').value = '';
}
/**
* Enables horizontal scrolling for elements with the 'scroll-row' class using the mouse wheel.
* @param {Document|HTMLElement} [root=document] - The root element to search within.
*/
function enableHorizontalWheelScroll(root = document) {
// Find all scrollable rows and attach wheel event listeners
root.querySelectorAll('.scroll-row').forEach(el => {
// Skip if already attached to prevent duplicate listeners
if (el.__wheelAttached) return;
el.__wheelAttached = true;
// Convert vertical scroll to horizontal scroll
el.addEventListener('wheel', (e) => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault();
el.scrollLeft += e.deltaY;
}
}, { passive: false });
});
}
// Attach search input handlers for desktop and mobile
document.getElementById('searchInput').addEventListener('input', handleSearch);
document.getElementById('mobileSearchInput').addEventListener('input', handleSearch);