/** * KV-Netflix - Main Application Entry Point * Initializes the video streaming application */ import { api } from './api.js'; import { createVideoCard } from './components/VideoCard.js'; import { initPlayer, destroyPlayer } from './components/VideoPlayer.js'; import { initSearch } from './components/SearchBar.js'; import { showToast } from './components/Toast.js'; import { createInfoModal } from './components/InfoModal.js'; import { renderNewAndHotView } from './components/NewAndHot.js'; import { KeyboardNavigation } from './keyboard-nav.js'; import { hapticLight, hapticMedium, hapticSuccess } from './haptics.js'; import { StatusBar, Style } from '../js/capacitor-mock.js'; /** * SplashScreen Controller * Manages loading progress and cinematic transition */ const SplashScreen = { elements: { overlay: document.getElementById('splash-screen'), bar: document.getElementById('loading-bar'), text: document.getElementById('loading-text') }, progress: 0, isFinished: false, update(percent, message) { if (this.isFinished) return; this.progress = Math.min(percent, 100); if (this.elements.bar) this.elements.bar.style.width = `${this.progress}%`; if (this.elements.text && message) this.elements.text.textContent = message; if (this.progress >= 100) { this.finish(); } }, finish() { if (this.isFinished) return; this.isFinished = true; setTimeout(() => { if (this.elements.overlay) { this.elements.overlay.classList.add('fade-out'); // Remove from DOM after transition to free up resources setTimeout(() => this.elements.overlay.remove(), 1000); } }, 500); } }; // Drag scroll removed per user request // Application state const state = { videos: [], currentCategory: 'all', currentVideo: null, isLoading: false, featuredVideo: null, heroMovies: [], currentHeroIndex: 0, heroInterval: null, page: 1, hasMore: true }; // DOM elements const elements = { // Use videoGrid if exists, otherwise fall back to mainContent (Tailwind CSS design) videoGrid: document.getElementById('videoGrid') || document.getElementById('mainContent'), mainContent: document.getElementById('mainContent'), loading: document.getElementById('loading'), emptyState: document.getElementById('emptyState'), categories: document.getElementById('categories'), // Netflix-style navigation elements mainHeader: document.getElementById('mainHeader'), searchWrapper: document.getElementById('searchWrapper'), searchToggle: document.getElementById('searchToggle'), searchInput: document.getElementById('searchInput'), searchResults: document.getElementById('searchResults'), navLinks: document.querySelectorAll('.header__nav-link'), playerModal: document.getElementById('playerModal'), playerContainer: document.getElementById('playerContainer'), playerTitle: document.getElementById('playerTitle'), playerMeta: document.getElementById('playerMeta'), closePlayer: document.getElementById('closePlayer'), modalBackdrop: document.getElementById('modalBackdrop'), mobileNavItems: document.querySelectorAll('.mobile-nav__item, .sidebar__nav-item'), mobileBottomNavButtons: document.querySelectorAll('#mobileBottomNav .nav-item') }; /** * Set the active state of mobile bottom navigation * @param {string} viewName - 'home', 'cinema', 'mylist', or 'search' */ function setMobileNavActive(viewName) { const navButtons = document.querySelectorAll('#mobileBottomNav .nav-item'); navButtons.forEach(btn => { const isActive = btn.dataset.view === viewName; btn.classList.toggle('active', isActive); btn.classList.toggle('text-white', isActive); btn.classList.toggle('text-gray-400', !isActive); const icon = btn.querySelector('.material-symbols-outlined'); if (icon) { icon.style.fontVariationSettings = isActive ? "'FILL' 1" : "'FILL' 0"; } }); } /** * Initialize the application */ async function init() { SplashScreen.update(10, 'Initializing services...'); // Initialize search initSearch(elements.searchInput, elements.searchResults, handleVideoPlay); SplashScreen.update(20, 'Setting up navigation...'); // Initialize Mobile Bottom Nav if (elements.mobileBottomNavButtons) { // ... (existing button logic) elements.mobileBottomNavButtons.forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); const view = btn.dataset.view; if (!view) return; // Update active state elements.mobileBottomNavButtons.forEach(b => b.classList.remove('active')); btn.classList.add('active'); // Native Haptic hapticLight(); // Handle routing if (view === 'home') { renderHome(); // Scroll to top for home window.scrollTo({ top: 0, behavior: 'smooth' }); } else if (view === 'search') { // Mobile Search View - don't scroll to top if (window.innerWidth < 768) { try { renderMobileSearch(); } catch (e) { console.error('Search render failed', e); } } else { elements.searchWrapper.classList.add('active'); elements.searchInput.focus(); } } else if (view === 'mylist') { // My List View - don't scroll to top if (window.innerWidth < 768) { renderMobileMyList(); } else { renderHistoryView('mylist'); } } else if (view === 'downloads') { showToast('Downloads feature coming soon!', 'info'); } else if (view === 'profile') { renderProfileView(); } else if (view === 'cinema') { setMobileNavActive('cinema'); renderCategoryView('cinema'); // Scroll to top for category views window.scrollTo({ top: 0, behavior: 'smooth' }); } else { renderCategoryView(view); // Scroll to top for category views window.scrollTo({ top: 0, behavior: 'smooth' }); } }); }); } // Set up event listeners setupEventListeners(); SplashScreen.update(40, 'Fetching movie catalog...'); // Load home view with organized sections try { await renderCategoryView('home'); } catch (e) { console.error('Home render failed', e); } SplashScreen.update(70, 'Preparing featured content...'); // Render hero with featured content try { await renderHero(); } catch (e) { console.error('Hero render failed', e); } SplashScreen.update(90, 'Applying final touches...'); // Handle view parameter from URL (e.g. for redirects from watch page) const urlParams = new URLSearchParams(window.location.search); const viewParam = urlParams.get('view'); if (viewParam && window.innerWidth < 768) { if (viewParam === 'search') renderMobileSearch(); else if (viewParam === 'mylist') renderMobileMyList(); else if (viewParam === 'cinema') renderCategoryView('cinema'); } // Initialize TV-Style Keyboard Navigation const nav = new KeyboardNavigation(); nav.init(); // Register PWA Service Worker if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js') }); } SplashScreen.update(100, 'Welcome to StreamFlix'); // Initialize Native Status Bar try { await StatusBar.setStyle({ style: Style.Dark }); await StatusBar.setBackgroundColor({ color: '#141414' }); } catch (e) { // Fail silently } } /** * Render hero section with featured movie * @param {Object} video - Optional video object to render (defaults to state.featuredVideo) */ function renderHero(video = null) { const heroTitle = document.getElementById('heroTitle'); const heroDescription = document.getElementById('heroDescription'); const heroBg = document.getElementById('heroBg'); const heroTag = document.getElementById('heroTag'); const heroTagContainer = document.getElementById('heroTagContainer'); const heroPlayBtn = document.getElementById('heroPlayBtn'); const heroInfoBtn = document.getElementById('heroInfoBtn'); const heroContent = document.getElementById('heroContent'); // Get featured video (param, or state.featuredVideo, or first video) const featured = video || state.featuredVideo || state.videos[0]; if (!featured) { return; } // Add fade out effect if (heroBg) heroBg.style.opacity = '0.5'; if (heroContent) heroContent.style.opacity = '0'; setTimeout(() => { // Update hero content if (heroTitle) heroTitle.textContent = featured.name || featured.title || 'Featured Movie'; if (heroDescription) heroDescription.textContent = featured.description || featured.content || 'Watch now on StreamFlix'; // Set background const backdrop = featured.backdrop || featured.poster_url || featured.thumb_url || featured.thumbnail || ''; if (heroBg && backdrop) { heroBg.style.backgroundImage = `url('${backdrop}')`; } // Set category tag if (heroTag && heroTagContainer) { const genres = featured.genres || featured.category; // Unhide container heroTagContainer.classList.remove('hidden'); if (genres && Array.isArray(genres) && genres.length > 0) { heroTag.textContent = genres[0]; } else if (typeof genres === 'string') { heroTag.textContent = genres; } else { heroTag.textContent = '#1 in Movies Today'; } } // Play button // Remove old listeners to prevent stacking if (heroPlayBtn && heroPlayBtn.parentNode) { const newPlayBtn = heroPlayBtn.cloneNode(true); heroPlayBtn.parentNode.replaceChild(newPlayBtn, heroPlayBtn); newPlayBtn.addEventListener('click', () => { hapticMedium(); handleVideoPlay(featured); }); } // Info button if (heroInfoBtn && heroInfoBtn.parentNode) { const newInfoBtn = heroInfoBtn.cloneNode(true); heroInfoBtn.parentNode.replaceChild(newInfoBtn, heroInfoBtn); newInfoBtn.addEventListener('click', () => handleShowInfo(featured)); } // Fade in if (heroBg) heroBg.style.opacity = '1'; if (heroContent) heroContent.style.opacity = '1'; }, 300); state.featuredVideo = featured; } /** * Start Hero Carousel */ function startHeroCarousel() { if (state.heroInterval) clearInterval(state.heroInterval); // Only start if we have multiple movies if (!state.heroMovies || state.heroMovies.length <= 1) return; state.heroInterval = setInterval(() => { state.currentHeroIndex++; if (state.currentHeroIndex >= state.heroMovies.length) { state.currentHeroIndex = 0; } renderHero(state.heroMovies[state.currentHeroIndex]); }, 8000); // 8 seconds } /** * Stop Hero Carousel */ function stopHeroCarousel() { if (state.heroInterval) { clearInterval(state.heroInterval); state.heroInterval = null; } } /** * Set up event listeners */ function setupEventListeners() { // Header Scroll Effect - Master Instruction Logic const backToTopBtn = document.getElementById('backToTop'); const handleScroll = () => { const scrollY = window.scrollY; // Header background change if (elements.mainHeader) { if (scrollY > 100) { elements.mainHeader.classList.add('scrolled'); elements.mainHeader.style.backgroundColor = '#141414'; // Strict Netflix Black } else { elements.mainHeader.classList.remove('scrolled'); elements.mainHeader.style.backgroundColor = 'transparent'; // Gradient handled by CSS } } // Back to top button visibility if (backToTopBtn) { if (scrollY > 500) { backToTopBtn.classList.add('visible'); } else { backToTopBtn.classList.remove('visible'); } } }; window.addEventListener('scroll', handleScroll, { passive: true }); // Initial check handleScroll(); // Back to Top Button Click Handler if (backToTopBtn) { backToTopBtn.addEventListener('click', () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }); } // Expandable Search Logic - REMOVED (Unifying with Modal) // Category Navigation elements.navLinks?.forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); const category = link.dataset.category; // Update active state elements.navLinks.forEach(l => l.classList.remove('active')); link.classList.add('active'); // Load content for category state.currentCategory = category; loadVideos(category, true); // true = reset pagination }); }); // Mobile & Sidebar Navigation elements.mobileNavItems?.forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); const view = item.dataset.view; // Update active state elements.mobileNavItems.forEach(i => i.classList.remove('active')); // If it's a sidebar item, we might need to activate all matching items (desktop + mobile logic if split) // For now just activate clicked item.classList.add('active'); // Sync other items with same view (e.g. if both mobile nav and sidebar exist) elements.mobileNavItems.forEach(i => { if (i.dataset.view === view) i.classList.add('active'); }); if (view === 'home') { elements.videoGrid.style.display = 'block'; const newHot = document.getElementById('newHotContainer'); if (newHot) newHot.style.display = 'none'; state.currentCategory = 'all'; loadVideos('all', true); } else if (['movies', 'series', 'animation', 'cinema'].includes(view)) { // Category Views elements.videoGrid.style.display = 'block'; const newHot = document.getElementById('newHotContainer'); if (newHot) newHot.style.display = 'none'; state.currentCategory = view; loadVideos(view, true); // loadVideos handles the API call with category param } else if (view === 'history') { // History & My List View (SPA) elements.videoGrid.style.display = 'block'; const newHot = document.getElementById('newHotContainer'); if (newHot) newHot.style.display = 'none'; renderHistoryView(); } else if (view === 'search') { // Trigger search modal instead of legacy view const searchBtn = document.getElementById('headerSearchBtn'); if (searchBtn) searchBtn.click(); } // Roll back to hero banner (scroll to top) window.scrollTo({ top: 0, behavior: 'smooth' }); }); }); // Netflix Header Navigation (Desktop Top Nav) const netflixNavLinks = document.querySelectorAll('.netflix-header__nav-link'); netflixNavLinks.forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); const view = link.dataset.view; // Update active state on Netflix header netflixNavLinks.forEach(l => l.classList.remove('active')); link.classList.add('active'); // Sync sidebar/mobile items elements.mobileNavItems.forEach(i => { i.classList.remove('active'); if (i.dataset.view === view) i.classList.add('active'); }); // Handle view switching elements.videoGrid.style.display = 'block'; const newHot = document.getElementById('newHotContainer'); if (newHot) newHot.style.display = 'none'; if (view === 'home') { state.currentCategory = 'all'; loadVideos('all', true); } else if (['movies', 'series', 'animation', 'cinema'].includes(view)) { state.currentCategory = view; loadVideos(view, true); } else if (view === 'history') { renderHistoryView(); } // Roll back to hero banner (scroll to top) window.scrollTo({ top: 0, behavior: 'smooth' }); }); }); // Netflix Header Search Button const headerSearchBtn = document.getElementById('headerSearchBtn'); if (headerSearchBtn) { headerSearchBtn.addEventListener('click', (e) => { e.preventDefault(); const searchModal = document.getElementById('searchModal'); const searchInput = document.getElementById('searchInput'); if (searchModal) { searchModal.classList.add('active'); if (searchInput) setTimeout(() => searchInput.focus(), 100); } }); } // Mobile Search Button const mobileSearchBtn = document.getElementById('mobileSearchBtn'); if (mobileSearchBtn) { mobileSearchBtn.addEventListener('click', (e) => { e.preventDefault(); const searchModal = document.getElementById('searchModal'); const searchInput = document.getElementById('searchInput'); if (searchModal) { hapticLight(); searchModal.classList.add('active'); if (searchInput) setTimeout(() => searchInput.focus(), 100); } }); } // Close Search Modal const closeSearch = document.getElementById('closeSearch'); if (closeSearch) { closeSearch.addEventListener('click', () => { const searchModal = document.getElementById('searchModal'); if (searchModal) searchModal.classList.remove('active'); }); } // Modal Player Back Button const modalPlayerBackButton = document.getElementById('modalPlayerBackButton'); if (modalPlayerBackButton) { modalPlayerBackButton.addEventListener('click', () => { hapticLight(); if (window.history.state?.playerOpen) { window.history.back(); } else { closePlayerModal(); } }); } // Global Popstate for Modal Player window.addEventListener('popstate', (event) => { if (elements.playerModal?.classList.contains('active') && !event.state?.playerOpen) { closePlayerModal(false); } }); // StreamFlix Nav Links (Tailwind design) const streamflixNavLinks = document.querySelectorAll('.nav-link'); streamflixNavLinks.forEach(link => { link.addEventListener('click', (e) => { // Allow links with real href (not '#') to navigate normally const href = link.getAttribute('href'); if (href && href !== '#' && !href.startsWith('#')) { // This is a real link (like Install App), let it navigate return; } e.preventDefault(); const view = link.dataset.view; // Update active state streamflixNavLinks.forEach(l => { l.classList.remove('active', 'text-white'); l.classList.add('text-gray-300'); }); link.classList.add('active', 'text-white'); link.classList.remove('text-gray-300'); // Handle view switching with organized category sections if (view === 'home') { state.currentCategory = 'all'; renderCategoryView('home'); } else if (view === 'series') { state.currentCategory = 'series'; renderCategoryView('series'); } else if (view === 'movies') { state.currentCategory = 'movies'; renderCategoryView('movies'); } else if (view === 'cinema') { state.currentCategory = 'cinema'; renderCategoryView('cinema'); } else if (view === 'history') { renderHistoryView(); } // Roll back to hero banner (scroll to top) window.scrollTo({ top: 0, behavior: 'smooth' }); }); }); // Modal close events elements.closePlayer?.addEventListener('click', closePlayerModal); elements.modalBackdrop?.addEventListener('click', closePlayerModal); // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (elements.playerModal?.classList.contains('active')) { if (window.history.state?.playerOpen) { window.history.back(); } else { closePlayerModal(); } } if (elements.searchWrapper?.classList.contains('active')) { elements.searchWrapper.classList.remove('active'); } // Close search modal const searchModal = document.getElementById('searchModal'); if (searchModal?.classList.contains('active')) { searchModal.classList.remove('active'); } } }); } /** * Load videos from API - tries RoPhim first, then database, then demo * @param {string} category - Optional category filter * @param {boolean} reset - Whether to reset pagination (e.g. category change) */ async function loadVideos(category = 'all', reset = false) { if (state.isLoading) return; if (reset) { state.page = 1; state.hasMore = true; state.videos = []; elements.videoGrid.innerHTML = ''; } if (!state.hasMore) return; state.isLoading = true; showLoading(state.page === 1); // Only show full loader on first page // Helper function to add timeout to fetch const fetchWithTimeout = (promise, timeout = 12000) => { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeout) ) ]); }; // Top Search Button const topSearchBtn = document.getElementById('topSearchBtn'); if (topSearchBtn) { topSearchBtn.addEventListener('click', (e) => { e.preventDefault(); const searchModal = document.getElementById('searchModal'); const searchInput = document.getElementById('searchInput'); if (searchModal) { searchModal.classList.add('active'); if (searchInput) setTimeout(() => searchInput.focus(), 100); } }); } try { let apiResponse = null; let isSectionMode = false; // Section Mode disabled to force Responsive Grid Layout per user request // Fallback: Flat Catalog if (!apiResponse) { apiResponse = await fetchWithTimeout( api.getRophimCatalog({ category: category !== 'all' ? category : null, page: state.page, limit: 24 }), 12000 ); } if (apiResponse && apiResponse.movies && apiResponse.movies.length > 0) { // Map API data to Video objects const newVideos = apiResponse.movies.map(m => ({ id: m.id || `api_${Date.now()}_${Math.random()}`, title: m.title || 'Unknown Title', thumbnail: m.thumbnail || 'https://via.placeholder.com/300x450?text=No+Image', backdrop: m.backdrop || m.thumbnail || 'https://via.placeholder.com/1920x1080?text=No+Backdrop', preview_url: m.preview_url || '', duration: m.duration || 0, resolution: m.quality || 'HD', category: m.category || 'movies', year: m.year || new Date().getFullYear(), description: m.description || '', matchScore: Math.floor(Math.random() * 15) + 85, // Random high match score source_url: m.source_url, slug: m.slug, // Rich Metadata cast: m.cast || [], director: m.director, country: m.country, episodes: m.episodes || [] })); // Append new videos // Deduplicate based on ID or slug const existingIds = new Set(state.videos.map(v => v.id)); const uniqueNewVideos = newVideos.filter(v => !existingIds.has(v.id)); state.videos = [...state.videos, ...uniqueNewVideos]; state.page += 1; // Increment page for next fetch // Detect if we reached end of content? (Usually API returns empty list, but here we got items) if (newVideos.length < 24) { // state.hasMore = false; // Optional optimization } // Force Responsive Grid Layout for ALL categories const isFirstBatch = state.page === 2; // Page bumped after fetch if (isFirstBatch) { renderVideoGrid(state.videos, false); } else { renderVideoGrid(uniqueNewVideos, true); } // Preload featured video for Hero only on first load // Setup/Update Infinite Scroll trigger setupInfiniteScrollTrigger(); // Hide loading state on sentinel if (scrollSentinel) scrollSentinel.classList.remove('loading'); state.isLoading = false; showLoading(false); return; } else { state.hasMore = false; // Hide sentinel when no more content if (scrollSentinel) { scrollSentinel.classList.remove('loading'); scrollSentinel.style.display = 'none'; } state.isLoading = false; showLoading(false); } } catch (error) { console.warn('API load failed:', error); // Only fallback to demo on first page load if (state.page === 1) { showToast('Using offline mode', 'info'); const demoVideos = getDemoContent(); state.videos = demoVideos; state.featuredVideo = demoVideos[0]; renderVideoGrid(demoVideos); } state.isLoading = false; showLoading(false); } } /** * Render content as horizontal sliders - PhimMoi Style * @param {Array} videos - Videos to group */ /** * Render content as horizontal sliders - Apple TV+ Style * Enhanced with smart categorization and genre-based sections * @param {Array} videos - Videos to group */ /** * Render content as horizontal sliders - Apple TV+ Style * Enhanced with smart categorization and genre-based sections * @param {Array} videos - Videos to group */ function renderBackendSection(title, movies, isTop10, container = elements.videoGrid, usedIds = null) { if (!movies || movies.length === 0) return; // Deduplicate if set provided let uniqueMovies = movies; if (usedIds) { uniqueMovies = movies.filter(m => !usedIds.has(m.id || m.slug)); if (uniqueMovies.length === 0) return; uniqueMovies.forEach(m => usedIds.add(m.id || m.slug)); } // Normalize const normalizedVideo = uniqueMovies.map(m => ({ id: m.id || m.slug, title: m.title, thumbnail: m.thumbnail, backdrop: m.backdrop || m.thumbnail, slug: m.slug, year: m.year, badge: m.badge, ranking: m.ranking })); const section = isTop10 ? createTop10Section(title, normalizedVideo) : createSliderSection(title, normalizedVideo); container.appendChild(section); } async function renderSliders(videos) { elements.videoGrid.innerHTML = ''; // Use Tailwind CSS layout classes elements.videoGrid.className = 'space-y-12'; if (elements.emptyState) elements.emptyState.style.display = 'none'; // 1. DISABLED: Curated sections API was overriding sectionConfigs. // Now using only sectionConfigs for full control over categories. /* try { const curatedResponse = await api.getCuratedSections(); if (curatedResponse && curatedResponse.sections && curatedResponse.sections.length > 0) { curatedResponse.sections.forEach(section => { if (section.movies && section.movies.length > 0) { // Normalize movies const normalizedMovies = section.movies.map(m => ({ id: m.id || m.slug, title: m.title, thumbnail: m.thumbnail, backdrop: m.poster_url || m.thumbnail, slug: m.slug, year: m.year, quality: m.quality || 'HD', resolution: m.quality || 'HD', rating: m.rating, tmdb_rating: m.tmdb_rating, genres: m.genres, category: m.category })); // Determine if this is a "Top Rated" section const isTopRated = section.title.includes('Top Rated') || section.title.includes('🏆'); const sectionEl = isTopRated ? createTop10Section(section.title, normalizedMovies) : createSliderSection(section.title, normalizedMovies); elements.videoGrid.appendChild(sectionEl); } }); if (elements.videoGrid.children.length > 0) { return; } } } catch (e) { console.warn('Curated sections failed, trying backend categories...', e); } */ // 2. DISABLED: Backend structured categories were also overriding sectionConfigs. // All rendering now happens in renderCategoryView() using sectionConfigs. /* try { let backendCategories = null; // Try categorySystem first, then direct API call if (window.categorySystem) { backendCategories = await window.categorySystem.loadCategories(); } // Fallback: fetch directly from API if (!backendCategories) { const response = await fetch('/api/rophim/categories/all'); const data = await response.json(); backendCategories = data.categories; } if (backendCategories) { // Defined section order and titles const sectionConfig = [ { key: 'hot', title: '🔥 Phim Hot (Movies)', isTop10: false }, { key: 'top10', title: '🏆 Top 10 Phim Lẻ', isTop10: true }, { key: 'series', title: '📺 Phim Bộ Mới (Series)', isTop10: false }, { key: 'cinema', title: '🍿 Phim Chiếu Rạp', isTop10: false }, { key: 'animated', title: '🎌 Hoạt Hình & Anime', isTop10: false }, { key: 'vietnamese', title: '🇻🇳 Phim Việt Nam', isTop10: false }, { key: 'tv_shows', title: '🎬 TV Shows', isTop10: false }, { key: 'action', title: '💥 Action Movies', isTop10: false }, { key: 'new_releases', title: '✨ Mới Cập Nhật', isTop10: false } ]; // Track used videos to prevent duplicates across sections const globalUsedIds = new Set(); // Render sections in order sectionConfig.forEach(config => { if (backendCategories[config.key] && backendCategories[config.key].length > 0) { // Skip deduplication for Top 10 - it's a ranked section and should always show const skipDedup = config.isTop10; renderBackendSection( config.title, backendCategories[config.key], config.isTop10, elements.videoGrid, skipDedup ? null : globalUsedIds ); } }); // If we successfully rendered backend categories, return here if (elements.videoGrid.children.length > 0) { return; } } } catch (e) { console.warn('Failed to load backend categories, falling back to local logic', e); } */ // --- FALLBACK / ORIGINAL LOGIC --- // Sort videos by year descending (newest first) videos.sort((a, b) => (b.year || 0) - (a.year || 0)); // Track videos already added to sections to prevent duplicates const usedVideoIds = new Set(); /** * Helper: Add videos to a section and track them */ function addSection(title, videos, isTop10 = false) { if (!videos || videos.length === 0) return; // Filter out already-used videos const availableVideos = videos.filter(v => !usedVideoIds.has(v.id)); if (availableVideos.length === 0) return; // Take up to 12 videos (or 10 for Top10) const limit = isTop10 ? 10 : 12; const sectionVideos = availableVideos.slice(0, limit); // Mark videos as used sectionVideos.forEach(v => usedVideoIds.add(v.id)); // Create and append section const section = isTop10 ? createTop10Section(title, sectionVideos) : createSliderSection(title, sectionVideos); elements.videoGrid.appendChild(section); } /** * Helper: Extract unique genres from videos */ function extractGenres(videos) { const genreCounts = {}; videos.forEach(v => { if (v.category && typeof v.category === 'string') { // Normalize category names const normalized = v.category.toLowerCase(); const genreMap = { 'phim-le': 'Movies', 'phim-bo': 'Series', 'hoat-hinh': 'Animation', 'tv-shows': 'TV Shows' }; const genre = genreMap[normalized] || v.category; genreCounts[genre] = (genreCounts[genre] || 0) + 1; } }); return genreCounts; } // ========================================== // PRIORITY SECTION 1: Featured/Top Content // ========================================== addSection('Top Charts: Movies', videos, true); // ========================================== // PRIORITY SECTION 2: Year-Based (Newest First) // ========================================== const currentYear = new Date().getFullYear(); addSection('2024 New Releases', videos.filter(v => v.year === currentYear)); addSection('2023 Hits', videos.filter(v => v.year === currentYear - 1)); // ========================================== // PRIORITY SECTION 3: Quality-Based // ========================================== addSection('4K Ultra HD', videos.filter(v => v.resolution === '4K' || v.quality === '4K')); // ========================================== // PRIORITY SECTION 4: Category-Based // ========================================== addSection('Must-Watch Series', videos.filter(v => v.category === 'series' || v.category === 'phim-bo' || v.category === 'tv-shows')); addSection('Anime & Animation', videos.filter(v => v.category === 'anime' || v.category === 'hoat-hinh')); addSection('Action & Blockbusters', videos.filter(v => v.category === 'movies' || v.category === 'theater' || v.category === 'phim-le')); // ========================================== // PRIORITY SECTION 5: Country/Region-Based // ========================================== addSection('Korean Cinema', videos.filter(v => v.country && (v.country.includes('Korea') || v.country.includes('Hàn Quốc')))); addSection('Japanese Films', videos.filter(v => v.country && (v.country.includes('Japan') || v.country.includes('Nhật Bản')))); addSection('Hollywood Blockbusters', videos.filter(v => v.country && (v.country.includes('US') || v.country.includes('USA') || v.country.includes('Mỹ')))); addSection('European Collection', videos.filter(v => v.country && ( v.country.includes('UK') || v.country.includes('France') || v.country.includes('Germany') || v.country.includes('Spain') || v.country.includes('Anh') || v.country.includes('Pháp') || v.country.includes('Đức') ))); addSection('Asian Cinema', videos.filter(v => v.country && ( v.country.includes('China') || v.country.includes('Thailand') || v.country.includes('Hong Kong') || v.country.includes('Taiwan') || v.country.includes('Trung Quốc') || v.country.includes('Thái Lan') || v.country.includes('Hồng Kông') ))); // ========================================== // PRIORITY SECTION 6: Time-Period Based // ========================================== addSection('Recent Favorites (2020-2022)', videos.filter(v => v.year && v.year >= 2020 && v.year <= 2022)); addSection('Modern Classics (2015-2019)', videos.filter(v => v.year && v.year >= 2015 && v.year < 2020)); addSection('Timeless Classics', videos.filter(v => v.year && v.year < 2015)); // ========================================== // PRIORITY SECTION 7: Dynamic Genre Sections // ========================================== // Get remaining unused videos const unusedVideos = videos.filter(v => !usedVideoIds.has(v.id)); if (unusedVideos.length > 0) { const genreCounts = extractGenres(unusedVideos); // Sort genres by count and create sections for top genres const sortedGenres = Object.entries(genreCounts) .sort((a, b) => b[1] - a[1]) .slice(0, 5); sortedGenres.forEach(([genre, count]) => { if (count >= 6) { addSection(`${genre} Collection`, unusedVideos.filter(v => v.category === genre || v.category?.toLowerCase().includes(genre.toLowerCase()))); } }); } // ========================================== // FINAL FALLBACK: Hidden Gems (minimal) // ========================================== const stillUnused = videos.filter(v => !usedVideoIds.has(v.id)); if (stillUnused.length >= 6) { addSection('Hidden Gems', stillUnused); } // Log categorization summary } /** * Create a Top 10 Section with numbered rankings - PhimMoi Style */ function createTop10Section(title, videos) { const section = document.createElement('section'); section.className = 'slider-section top10-section'; section.innerHTML = `

${title}

`; const track = section.querySelector('.slider-track'); videos.slice(0, 10).forEach((video, index) => { const card = createRankedCard(video, index + 1); track.appendChild(card); }); // Mouse drag scrolling removed per user request // Slider Logic const btnLeft = section.querySelector('.slider-btn--left'); const btnRight = section.querySelector('.slider-btn--right'); btnRight.addEventListener('click', () => { track.scrollBy({ left: window.innerWidth * 0.6, behavior: 'smooth' }); }); btnLeft.addEventListener('click', () => { track.scrollBy({ left: -window.innerWidth * 0.6, behavior: 'smooth' }); }); return section; } /** * Create a ranked card with number - PhimMoi Top 10 style */ function createRankedCard(video, rank) { const card = document.createElement('div'); card.className = 'ranked-card'; card.innerHTML = ` ${rank}
${video.title} ${video.resolution || 'HD'}
${video.title}
${video.year || ''} • ${video.country || ''}
`; return card; } /** * Create a Slider Section - Netflix-style Horizontal Scroll (Tailwind CSS) */ /** * Create a Horizontal Slider Section with scroll arrows */ function createSliderSection(title, videos, cardType = 'poster') { const section = document.createElement('section'); section.className = 'flex flex-col gap-4 mb-12 relative'; // Section Header const header = document.createElement('h2'); header.className = 'text-xl md:text-2xl font-bold text-white hover:text-primary cursor-pointer transition-colors flex items-center gap-2 group px-4 md:px-12'; header.innerHTML = ` ${title} arrow_forward_ios `; section.appendChild(header); // Slider wrapper (for positioning arrows) const sliderWrapper = document.createElement('div'); sliderWrapper.className = 'relative group/slider'; // Left Arrow Button const leftBtn = document.createElement('button'); leftBtn.className = 'absolute left-0 top-1/2 -translate-y-1/2 z-20 w-12 h-full bg-gradient-to-r from-black/80 to-transparent opacity-0 group-hover/slider:opacity-100 transition-opacity flex items-center justify-start pl-2'; leftBtn.innerHTML = 'chevron_left'; // Right Arrow Button const rightBtn = document.createElement('button'); rightBtn.className = 'absolute right-0 top-1/2 -translate-y-1/2 z-20 w-12 h-full bg-gradient-to-l from-black/80 to-transparent opacity-0 group-hover/slider:opacity-100 transition-opacity flex items-center justify-end pr-2'; rightBtn.innerHTML = 'chevron_right'; // Horizontal Scroll Container - bigger cards const container = document.createElement('div'); container.className = 'flex gap-3 overflow-x-auto scroll-smooth no-scrollbar px-4 md:px-12 pb-4'; videos.forEach((video, index) => { let card; if (cardType === 'landscape') { card = createContinueWatchingCard(video); } else { // All cards use horizontal orientation with larger size card = createTailwindCard(video, false, 0, 'horizontal'); } // Apply larger fixed width for cards in slider (bigger cards) card.className = card.className.replace('w-full', ''); card.style.minWidth = '280px'; card.style.maxWidth = '380px'; card.style.flex = '0 0 auto'; container.appendChild(card); }); // Scroll functionality const scrollAmount = 600; leftBtn.addEventListener('click', () => { container.scrollBy({ left: -scrollAmount, behavior: 'smooth' }); }); rightBtn.addEventListener('click', () => { container.scrollBy({ left: scrollAmount, behavior: 'smooth' }); }); sliderWrapper.appendChild(leftBtn); sliderWrapper.appendChild(container); sliderWrapper.appendChild(rightBtn); section.appendChild(sliderWrapper); return section; } /** * Create a movie/poster card with Tailwind CSS (Netflix style) */ /** * Create a movie/poster card with Tailwind CSS (Netflix strict style) */ /** * Create a movie/poster card with Tailwind CSS (Netflix strict style) */ /** * Create a movie/poster card with Tailwind CSS (Netflix strict style) * @param {Object} video - Video object * @param {boolean} showRank - Show ranking number * @param {number} rank - Rank number * @param {string} orientation - 'vertical' or 'horizontal' */ function createTailwindCard(video, showRank = false, rank = 0, orientation = 'vertical') { const card = document.createElement('div'); // Let grid control width; aspect ratio for sizing const aspectClass = orientation === 'horizontal' ? 'aspect-video' : 'aspect-[2/3]'; // Use w-full to fill grid cell, no fixed width card.className = `w-full cursor-pointer snap-start group relative transition-all duration-300 ease-in-out hover:z-30 hover:scale-105`; // Prioritize backdrop for horizontal cards let image = video.poster_url || video.thumb_url || video.thumbnail || ''; if (orientation === 'horizontal' && video.backdrop) { image = video.backdrop; } // PERFORMANCE: Use image proxy with optimized sizes (quality vs speed balance) const isMobile = window.innerWidth < 768; const imageWidth = isMobile ? 180 : (orientation === 'horizontal' ? 400 : 200); const proxiedImage = image ? api.getProxyUrl(image, imageWidth) : ''; const title = video.name || video.title || 'Untitled'; const year = video.year || ''; const quality = video.quality || 'HD'; const slug = video.slug || video.id || ''; // Random match score for visual fidelity (90-99%) const matchScore = video.matchScore || Math.floor(Math.random() * (99 - 90 + 1) + 90); // Simulate Rotten Tomatoes (random 80-98%) const rtScore = Math.floor(Math.random() * (98 - 80 + 1) + 80); card.innerHTML = `
${!showRank && year === new Date().getFullYear().toString() ? `NEW` : ''} ${video.quality ? `${video.quality.replace('FHD', 'HD')}` : ''} ${video.current_episode ? `EP ${video.current_episode}` : ''}
${showRank ? `${rank}` : ''}
${matchScore}% Match ${quality} ${year}
local_pizza ${rtScore}%
${video.genres && video.genres.length > 0 ? `${video.genres[0]}` : ''}

${title}

`; // Click handler for play (background click) card.addEventListener('click', (e) => { if (!e.target.closest('button')) { handleVideoPlay(video); } }); // Button Handlers const playBtn = card.querySelector('.btn-play'); if (playBtn) playBtn.addEventListener('click', (e) => { e.stopPropagation(); handleVideoPlay(video); }); const addBtn = card.querySelector('.btn-add-list'); if (addBtn) addBtn.addEventListener('click', (e) => { e.stopPropagation(); if (window.historyService) { const added = window.historyService.toggleFavorite(video); // Visual toggle const icon = addBtn.querySelector('span'); if (added) { icon.textContent = 'check'; showToast('Added to My List', 'success'); } else { icon.textContent = 'add'; showToast('Removed from My List', 'info'); } } }); const infoBtn = card.querySelector('.btn-info'); if (infoBtn) infoBtn.addEventListener('click', (e) => { e.stopPropagation(); // Updated to use the new navigation logical as per previous request handleShowInfo(video); }); return card; } /** * Create a Continue Watching card (landscape with progress bar) */ /** * Create a Continue Watching card (strict fit per preset) */ function createContinueWatchingCard(video) { const card = document.createElement('div'); card.className = 'flex-none w-[280px] group/card cursor-pointer snap-start'; const poster = video.backdrop || video.thumb_url || video.thumbnail || ''; const title = video.name || video.title || 'Untitled'; const progress = video.progress?.percentage || 0; const episode = video.progress?.episode ? `S${video.season || 1}:E${video.progress.episode}` : ''; card.innerHTML = `
play_arrow
${title} ${episode ? `${episode}` : ''}
`; card.addEventListener('click', () => handleVideoPlay(video)); return card; } /** * Render video grid (standard grid for search/categories) * @param {Array} videos - Array of video objects * @param {boolean} append - Whether to append to existing grid */ function renderVideoGrid(videos, append = false) { // If not appending, clear the grid if (!append) { elements.videoGrid.innerHTML = ''; elements.videoGrid.innerHTML = ''; elements.videoGrid.className = 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-x-4 gap-y-10'; } if (videos.length === 0 && !append) { if (elements.emptyState) elements.emptyState.style.display = 'flex'; return; } if (elements.emptyState) elements.emptyState.style.display = 'none'; videos.forEach(video => { const card = createVideoCard(video, handleVideoPlay, handleShowInfo); elements.videoGrid.appendChild(card); }); } function renderInfiniteGrid(videos) { if (!videos || videos.length === 0) { console.warn('No videos to render in infinite grid'); return; } let infiniteContainer = document.getElementById('infinite-scroll-container'); if (!infiniteContainer) { infiniteContainer = document.createElement('div'); infiniteContainer.id = 'infinite-scroll-container'; // Reduce top margin as the "Khám Phá Thêm" header from renderSliders already provides spacing infiniteContainer.style.marginTop = '1vw'; elements.videoGrid.appendChild(infiniteContainer); // Removed redundant 'More to Explore' header here as it duplicates the one from renderSliders } // Group videos by year const currentYear = new Date().getFullYear(); const moviesByYear = {}; videos.forEach(video => { const year = video.year || currentYear; if (!moviesByYear[year]) { moviesByYear[year] = []; } moviesByYear[year].push(video); }); // Sort years descending (newest first) const years = Object.keys(moviesByYear).sort((a, b) => b - a); // Create slider sections for each year let cardsAdded = 0; years.forEach(year => { const movies = moviesByYear[year]; if (movies.length > 0) { const yearLabel = year == currentYear ? `${year} New Releases` : year == currentYear - 1 ? `${year} Hits` : `${year} Movies`; const section = createSliderSection(yearLabel, movies); infiniteContainer.appendChild(section); cardsAdded += movies.length; } }); } let scrollObserver; let scrollSentinel = null; let lastScrollTrigger = 0; // Debounce timer function setupInfiniteScrollTrigger() { // If no more content, hide sentinel and don't set up observer if (!state.hasMore) { if (scrollSentinel) { scrollSentinel.classList.remove('loading'); scrollSentinel.style.display = 'none'; } if (scrollObserver) scrollObserver.disconnect(); return; } if (scrollObserver) scrollObserver.disconnect(); // Remove any existing sentinels first to prevent duplicates document.querySelectorAll('.scroll-sentinel').forEach(el => el.remove()); scrollSentinel = null; const options = { root: null, rootMargin: '50px', // Reduced from 200px to prevent early triggering threshold: 0.0 // Trigger when any part is visible }; scrollObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { // Debounce: require at least 1.5 seconds between triggers const now = Date.now(); if (now - lastScrollTrigger < 1500) { return; } if (entry.isIntersecting && !state.isLoading && state.hasMore) { lastScrollTrigger = now; // Show loading state on sentinel if (scrollSentinel) scrollSentinel.classList.add('loading'); loadVideos(state.currentCategory); } }); }, options); // Create single sentinel element scrollSentinel = document.createElement('div'); scrollSentinel.className = 'scroll-sentinel'; scrollSentinel.id = 'scrollSentinel'; // Place sentinel at the proper location - after infinite container or at end of videoGrid const infiniteContainer = document.getElementById('infinite-scroll-container'); if (infiniteContainer) { // Insert after the infinite container for proper positioning infiniteContainer.parentNode.insertBefore(scrollSentinel, infiniteContainer.nextSibling); } else { elements.videoGrid.appendChild(scrollSentinel); } scrollObserver.observe(scrollSentinel); } function handleShowInfo(video) { navigateToWatch(video); } /** * Render History View - Shows user's watch history and saved content * @param {string} tab - 'history' or 'mylist' */ function renderHistoryView(tab = 'history') { if (elements.mainHeader) elements.mainHeader.style.display = ''; if (!window.historyService) { console.error('HistoryService not initialized'); return; } // Clear the grid elements.videoGrid.innerHTML = ''; if (elements.emptyState) elements.emptyState.style.display = 'none'; // Remove any existing history tabs const existingTabs = document.querySelector('.view-tabs'); if (existingTabs) existingTabs.remove(); // Create tabs for switching between History and My List const tabsContainer = document.createElement('div'); tabsContainer.className = 'view-tabs'; tabsContainer.innerHTML = ` `; elements.videoGrid.before(tabsContainer); // Tab click listeners tabsContainer.querySelectorAll('.view-tab').forEach(btn => { btn.addEventListener('click', () => { tabsContainer.remove(); renderHistoryView(btn.dataset.tab); }); }); let items = []; if (tab === 'history') { items = window.historyService.getHistory(); } else { items = window.historyService.getFavorites(); } if (items.length === 0) { if (elements.emptyState) { elements.emptyState.style.display = 'flex'; const emptyTitle = elements.emptyState.querySelector('h2'); const emptyDesc = elements.emptyState.querySelector('p'); if (tab === 'history') { if (emptyTitle) emptyTitle.textContent = 'No history yet'; if (emptyDesc) emptyDesc.textContent = 'Movies you watch will appear here.'; } else { if (emptyTitle) emptyTitle.textContent = 'My List is empty'; if (emptyDesc) emptyDesc.textContent = 'Add movies to your list to watch later.'; } } return; } // 1. Sort by Latest (Year/Date) items.sort((a, b) => { const dateA = a.timestamp || a.year || 0; const dateB = b.timestamp || b.year || 0; return dateB - dateA; }); // Normalize items with horizontal orientation for slider const normalizedItems = items.map((item, index) => { return { ...item, id: item.id || item.slug, orientation: 'horizontal' }; }); // Ensure header is shown if (elements.mainHeader) elements.mainHeader.style.display = 'block'; // Use horizontal slider layout (same as home page) const title = tab === 'history' ? 'Continue Watching' : 'My List'; const sliderSection = createSliderSection(title, normalizedItems, 'poster'); elements.videoGrid.appendChild(sliderSection); } /** * Render Library View - Legacy fallback for history/favorites */ function renderLibraryView() { renderHistoryView('mylist'); } /** * Render Movies View - Shows movies in horizontal sliders organized by year */ async function renderMoviesView() { // Show loading state showLoading(true); // Clear the grid elements.videoGrid.innerHTML = ''; if (elements.emptyState) elements.emptyState.style.display = 'none'; try { // Load movies if not already loaded if (state.videos.length === 0 || state.currentCategory !== 'movies') { state.currentCategory = 'movies'; state.page = 1; state.hasMore = true; const apiResponse = await api.getRophimCatalog({ category: 'phim-le', // movies category page: 1, limit: 48 // Load more movies for better categorization }); if (apiResponse && apiResponse.movies && apiResponse.movies.length > 0) { state.videos = apiResponse.movies.map(m => ({ id: m.id || `api_${Date.now()}_${Math.random()}`, title: m.title || 'Unknown Title', thumbnail: m.thumbnail || 'https://via.placeholder.com/300x450?text=No+Image', backdrop: m.backdrop || m.thumbnail || 'https://via.placeholder.com/1920x1080?text=No+Backdrop', preview_url: m.preview_url || '', duration: m.duration || 0, resolution: m.quality || 'HD', category: m.category || 'movies', year: m.year || new Date().getFullYear(), description: m.description || '', matchScore: Math.floor(Math.random() * 15) + 85, source_url: m.source_url, slug: m.slug, cast: m.cast || [], director: m.director, country: m.country, episodes: m.episodes || [] })); } } // Group movies by year const moviesByYear = {}; const currentYear = new Date().getFullYear(); state.videos.forEach(video => { const year = video.year || currentYear; if (!moviesByYear[year]) { moviesByYear[year] = []; } moviesByYear[year].push(video); }); // Sort years descending (newest first) const years = Object.keys(moviesByYear).sort((a, b) => b - a); // Create slider sections for each year years.forEach(year => { const movies = moviesByYear[year]; if (movies.length > 0) { const yearLabel = year == currentYear ? `${year} New Releases` : year == currentYear - 1 ? `${year} Hits` : `${year} Movies`; const section = createSliderSection(yearLabel, movies); elements.videoGrid.appendChild(section); } }); showLoading(false); } catch (error) { console.error('Error loading movies:', error); showLoading(false); if (elements.emptyState) elements.emptyState.style.display = 'flex'; } } /** * Render demo content when API is not available */ function renderDemoContent() { // Using CORS-friendly sample videos that work in browsers const SAMPLE_VIDEO = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; // Big Buck Bunny HLS const SAMPLE_MP4 = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'; const demoVideos = [ { id: 1, title: 'Venom: The Last Dance', thumbnail: 'https://image.tmdb.org/t/p/w500/aosm8NMQ3UyoBVpSxyimorCQykC.jpg', backdrop: 'https://image.tmdb.org/t/p/original/3V4kLQg0kSqPLctI5ziYWabAZYF.jpg', duration: 7200, resolution: '4K', category: 'movies', year: 2024, description: 'Eddie và Venom đang chạy trốn. Bị cả hai hai thế giới truy đuổi, họ buộc phải đưa ra quyết định khốc liệt...', } ]; // Fuzzy match title const isBanner = bannerCategories.some(cat => title.includes(cat)); // Attempt to find a video with a real backdrop first (landscape) // to avoid stretching portrait thumbnails const bannerVideo = videos.find(v => v.backdrop && v.backdrop !== v.thumbnail) || videos[0] || {}; const backdrop = bannerVideo.backdrop || bannerVideo.thumbnail || ''; // If we are using a thumbnail (likely portrait), apply blur const isPortrait = backdrop === bannerVideo.thumbnail; const bgStyle = isPortrait ? `background-image: url('${backdrop}'); filter: blur(20px) brightness(0.7); transform: scale(1.2);` : `background-image: url('${backdrop}');`; if (isBanner && backdrop) { // Create Banner Header with separate BG for zoom effects const bannerHeader = document.createElement('div'); bannerHeader.className = 'section-banner group'; bannerHeader.innerHTML = `

${title}

Explore Collection
`; section.appendChild(bannerHeader); } else { // Standard Header const header = document.createElement('h2'); header.className = 'section-title-apple'; header.textContent = title; section.appendChild(header); } // Split videos into chunks (Rows) const rowSize = 21; const createRow = (rowVideos) => { const container = document.createElement('div'); container.className = 'slider-container'; // Add vertical spacing between rows container.style.marginBottom = '1.5rem'; container.innerHTML = `
`; const track = container.querySelector('.slider-track'); rowVideos.forEach(video => { const card = createVideoCard(video, handleVideoPlay, handleShowInfo); track.appendChild(card); }); // Slider Logic const btnLeft = container.querySelector('.slider-btn--left'); const btnRight = container.querySelector('.slider-btn--right'); btnRight.addEventListener('click', () => { track.scrollBy({ left: window.innerWidth * 0.7, behavior: 'smooth' }); }); btnLeft.addEventListener('click', () => { track.scrollBy({ left: -window.innerWidth * 0.7, behavior: 'smooth' }); }); return container; }; // Create rows for (let i = 0; i < videos.length; i += rowSize) { const chunk = videos.slice(i, i + rowSize); // Avoid creating a tiny final row if it has very few items compared to rowSize, // unless it's the only row. if (i > 0 && chunk.length < 5) break; section.appendChild(createRow(chunk)); } return section; function setupInfiniteScrollTrigger() { // If no more content, hide sentinel and don't set up observer if (!state.hasMore) { if (scrollSentinel) { scrollSentinel.classList.remove('loading'); scrollSentinel.style.display = 'none'; } if (scrollObserver) scrollObserver.disconnect(); return; } if (scrollObserver) scrollObserver.disconnect(); // Remove any existing sentinels first to prevent duplicates document.querySelectorAll('.scroll-sentinel').forEach(el => el.remove()); scrollSentinel = null; const options = { root: null, rootMargin: '50px', // Reduced from 200px to prevent early triggering threshold: 0.0 // Trigger when any part is visible }; scrollObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { // Debounce: require at least 1.5 seconds between triggers const now = Date.now(); if (now - lastScrollTrigger < 1500) { return; } if (entry.isIntersecting && !state.isLoading && state.hasMore) { lastScrollTrigger = now; // Show loading state on sentinel if (scrollSentinel) scrollSentinel.classList.add('loading'); loadVideos(state.currentCategory); } }); }, options); // Create single sentinel element scrollSentinel = document.createElement('div'); scrollSentinel.className = 'scroll-sentinel'; scrollSentinel.id = 'scrollSentinel'; // Place sentinel at the proper location - after infinite container or at end of videoGrid const infiniteContainer = document.getElementById('infinite-scroll-container'); if (infiniteContainer) { // Insert after the infinite container for proper positioning infiniteContainer.parentNode.insertBefore(scrollSentinel, infiniteContainer.nextSibling); } else { elements.videoGrid.appendChild(scrollSentinel); } scrollObserver.observe(scrollSentinel); } function handleShowInfo(video) { // Smart Recommendations: Filter by category/genre let recommendations = state.videos.filter(v => v.id !== video.id && (v.category === video.category || v.resolution === video.resolution) ); // Fallback if not enough matches if (recommendations.length < 6) { const remaining = state.videos.filter(v => v.id !== video.id && !recommendations.includes(v)); recommendations = [...recommendations, ...remaining]; } // Shuffle and slice recommendations = recommendations.sort(() => Math.random() - 0.5).slice(0, 6); const modal = createInfoModal(video, (modalEl) => { modalEl.classList.remove('active'); setTimeout(() => modalEl.remove(), 400); }, handleVideoPlay, recommendations); } } /** * Render hero section with featured content */ /** * Handle video play action - Navigate to dedicated watch page * @param {Object} video - Video object */ function handleVideoPlay(video) { // Store video data in sessionStorage for the watch page sessionStorage.setItem('currentVideo', JSON.stringify(video)); // Store all videos for recommendations sessionStorage.setItem('allVideos', JSON.stringify(state.videos)); // Navigate directly to the watch page (no pushState needed for full page navigation) navigateToWatch(video); } function navigateToWatch(video) { window.location.href = `/watch.html?slug=${video.slug}`; } /** * Load specific episode with server */ async function loadEpisode(video, episode, server) { try { let streamUrl = null; let poster = video.thumbnail; // Check if this is a PhimMoiChill movie const isPhimMoiChill = video.source_url && ( video.source_url.includes('royalcanalbikehire') || video.source_url.includes('phimmoichill') || video.source_url.includes('/phim/') || video.slug ); if (isPhimMoiChill) { showToast('Loading stream...', 'info'); try { const streamData = await api.getRophimStreamByUrl(video.source_url, video.slug, episode, server); if (streamData && streamData.stream_url) { streamUrl = streamData.stream_url; } } catch (phimmoiError) { console.warn('PhimMoiChill stream extraction failed:', phimmoiError.message); } } // Fallback: try yt-dlp extraction if (!streamUrl && video.source_url) { try { const extraction = await api.extractVideo(video.source_url); if (extraction && extraction.stream_url) { streamUrl = extraction.stream_url; poster = extraction.thumbnail || poster; } } catch (extractError) { console.warn('Extraction failed:', extractError.message); } } // Final fallback: use source_url directly if (!streamUrl && video.source_url) { if (video.source_url.match(/\.(mp4|m3u8|webm)(\?|$)/i)) { streamUrl = video.source_url; } } if (streamUrl) { const isEmbedUrl = streamUrl.includes('goatembed') || streamUrl.includes('/embed/') || streamUrl.includes('player.') || (streamUrl.includes('embed') && !streamUrl.match(/\.(mp4|m3u8|webm)/i)); if (isEmbedUrl) { elements.playerContainer.innerHTML = `
`; } else { const art = initPlayer(elements.playerContainer, { url: streamUrl, poster: poster, title: video.title, autoplay: true }); // Push state for back navigation if (!window.history.state?.playerOpen) { window.history.pushState({ playerOpen: true }, '', window.location.href); } if (art && window.historyService) { art.on('video:timeupdate', () => { const currentTime = art.currentTime; const duration = art.duration; if (currentTime > 0 && duration > 0 && Math.floor(currentTime) % 5 === 0) { window.historyService.addToHistory(video, { currentTime, duration, percentage: (currentTime / duration) * 100, episode: 1 // Default to 1 for modal player }); } }); } } showToast('Playing...', 'success'); } else { throw new Error('Không tìm thấy nguồn phát phim'); } } catch (error) { console.error('Video playback failed:', error); showToast(`Lỗi: ${error.message}`, 'error'); elements.playerContainer.innerHTML = `

Cannot load video

${video.title}

`; } } /** * Close player modal * @param {boolean} shouldUpdateHistory - Whether to update history (defaults to true) */ function closePlayerModal(shouldUpdateHistory = true) { if (elements.playerModal) { elements.playerModal.classList.add('hidden'); elements.playerModal.classList.remove('active'); elements.playerModal.style.display = 'none'; // Destroy player destroyPlayer(); } // If we're closing and the state still thinks it's open, and we didn't come from popstate if (shouldUpdateHistory && window.history.state?.playerOpen) { // Handled via history.back() usually } elements.playerContainer.innerHTML = ''; state.currentVideo = null; } /** * Close add video modal */ function closeAddModal() { elements.addVideoModal.classList.remove('active'); elements.addVideoForm.reset(); } /** * Handle add video form submission * @param {Event} e - Form submit event */ async function handleAddVideo(e) { e.preventDefault(); const url = document.getElementById('videoUrl').value; const title = document.getElementById('videoTitle').value; const category = document.getElementById('videoCategory').value; try { showToast('Extracting video info...', 'info'); // First extract to get metadata const extraction = await api.extractVideo(url); // Add to library await api.addVideo({ title: title || extraction.title, source_url: url, thumbnail: extraction.thumbnail, category: category || null }); showToast('Video added successfully!', 'success'); closeAddModal(); // Reload videos await loadVideos(state.currentCategory); } catch (error) { console.error('Failed to add video:', error); showToast(`Failed to add video: ${error.message}`, 'error'); } } /** * Set active category tab * @param {string} category - Category to activate */ function setActiveCategory(category) { state.currentCategory = category; elements.categories.querySelectorAll('.category').forEach(btn => { btn.classList.toggle('category--active', btn.dataset.category === category); }); } /** * Show/hide loading indicator * @param {boolean} show - Whether to show loading */ function showLoading(show) { if (elements.loading) { elements.loading.style.display = show ? 'flex' : 'none'; } // Support both old videoGrid and new mainContent layouts if (elements.videoGrid) { elements.videoGrid.style.display = show ? 'none' : 'block'; } } /** * Render organized category view based on view type * Netflix-style: Multiple horizontal slider sections per view * @param {string} viewType - 'home', 'series', 'movies', or 'cinema' */ async function renderCategoryView(viewType) { // Cleanup History Tabs if they exist const historyTabs = document.querySelector('.view-tabs'); if (historyTabs) historyTabs.remove(); if (elements.mainHeader) elements.mainHeader.style.display = ''; showLoading(true); elements.videoGrid.innerHTML = ''; elements.videoGrid.className = 'space-y-12'; // Section configurations per view type (2 rows per category) const sectionConfigs = { home: [ { title: 'Continue Watching', type: 'history', limit: 12, cardType: 'landscape' }, { title: 'Cinema Releases', category: 'phim-chieu-rap', limit: 12, isHeroSource: true }, { title: 'Top Rated', category: 'phim-le', sort: 'rating', limit: 12 }, { title: 'Action & Adventure', category: 'hanh-dong', limit: 12 }, { title: 'Animation', category: 'hoat-hinh', limit: 12 }, { title: 'Korean Hits', category: 'han-quoc', limit: 12 }, { title: 'Horror & Thriller', category: 'kinh-di', limit: 12 }, { title: 'Romance', category: 'tinh-cam', limit: 12 }, ], series: [ { title: 'Popular TV Shows', category: 'phim-bo', limit: 12, isHeroSource: true }, { title: 'Korean Dramas', category: 'korean', limit: 12 }, { title: 'Chinese Dramas', category: 'china', limit: 12 }, { title: 'Anime Series', category: 'hoat-hinh', limit: 12 }, { title: 'Documentaries', category: 'tai-lieu', limit: 12 }, ], movies: [ { title: 'Blockbuster Movies', category: 'phim-le', sort: 'year', limit: 12, isHeroSource: true }, { title: 'Action & Adventure', category: 'action', limit: 12 }, { title: 'Comedy Films', category: 'comedy', limit: 12 }, { title: 'Cinema Releases', category: 'phim-chieu-rap', limit: 12 }, { title: 'Horror Movies', category: 'kinh-di', limit: 12 }, { title: 'Sci-Fi & Fantasy', category: 'vien-tuong', limit: 12 }, ], cinema: [ { title: 'Now Showing', category: 'phim-chieu-rap', limit: 12, isHeroSource: true }, { title: 'New Releases', category: 'phim-le', sort: 'year', limit: 12 }, { title: 'Top Rated', category: 'phim-le', sort: 'rating', limit: 12 }, { title: 'Action Blockbusters', category: 'action', limit: 12 }, { title: 'Animated Features', category: 'hoat-hinh', limit: 12 }, ] }; const sections = sectionConfigs[viewType] || sectionConfigs.home; // DISABLED: Session cache restoration breaks event listeners // Event handlers are set up via JavaScript and lost when HTML is restored // TODO: Implement event delegation to fix this properly /* if (viewType === 'home' || viewType === 'cinema') { const cachedHTML = sessionStorage.getItem(`view_cache_${viewType}`); if (cachedHTML) { elements.videoGrid.innerHTML = cachedHTML; showLoading(false); if (elements.heroContainer) elements.heroContainer.style.display = ''; if (elements.videoGrid.children.length > 0) return; } } */ // Lazy loading configuration const EAGER_LOAD_COUNT = 3; // Load first 3 sections immediately try { let firstAvailableMovies = null; // Render eager sections immediately for (let i = 0; i < Math.min(EAGER_LOAD_COUNT, sections.length); i++) { const sectionConfig = sections[i]; const movies = await fetchSectionMovies(sectionConfig); if (movies && movies.length > 0) { if (!firstAvailableMovies) { firstAvailableMovies = movies; } // Set featured video for hero banner from first valid section if (sectionConfig.isHeroSource && (!state.heroMovies || state.heroMovies.length === 0) && movies.length > 0) { state.heroMovies = movies.slice(0, 10); state.featuredVideo = movies[0]; state.videos = movies; state.currentHeroIndex = 0; renderHero(state.heroMovies[0]); startHeroCarousel(); } const sliderSection = createSliderSection(sectionConfig.title, movies, sectionConfig.cardType || 'poster'); elements.videoGrid.appendChild(sliderSection); } } // Cache the eager sections if (viewType === 'home' || viewType === 'cinema') { sessionStorage.setItem(`view_cache_${viewType}`, elements.videoGrid.innerHTML); } // Create lazy-load placeholders for remaining sections const lazyObserver = new IntersectionObserver(async (entries, observer) => { for (const entry of entries) { if (entry.isIntersecting) { const placeholder = entry.target; const configIndex = parseInt(placeholder.dataset.configIndex); const sectionConfig = sections[configIndex]; observer.unobserve(placeholder); // Show loading indicator placeholder.innerHTML = '
'; const movies = await fetchSectionMovies(sectionConfig); if (movies && movies.length > 0) { const sliderSection = createSliderSection(sectionConfig.title, movies, sectionConfig.cardType || 'poster'); placeholder.replaceWith(sliderSection); // Update cache as we load more if (viewType === 'home' || viewType === 'cinema') { sessionStorage.setItem(`view_cache_${viewType}`, elements.videoGrid.innerHTML); } } else { placeholder.remove(); } } } }, { rootMargin: '800px' }); // Add placeholders for lazy sections for (let i = EAGER_LOAD_COUNT; i < sections.length; i++) { const placeholder = document.createElement('div'); placeholder.className = 'lazy-section-placeholder h-32 mb-12'; placeholder.dataset.configIndex = i; placeholder.innerHTML = `

${sections[i].title}

`; elements.videoGrid.appendChild(placeholder); lazyObserver.observe(placeholder); } // Fallback: If hero is still empty, use first available content if (!state.featuredVideo) { if (firstAvailableMovies && firstAvailableMovies.length > 0) { state.featuredVideo = firstAvailableMovies[0]; state.videos = firstAvailableMovies; renderHero(); } else { // Absolute final fallback: Demo content to prevent broken UI try { const demo = getDemoContent(); if (demo && demo.length > 0) { state.featuredVideo = demo[0]; state.videos = demo; renderHero(); } } catch (e) { console.warn('Demo content fallback failed', e); } } } // If no sections were rendered, show a message if (elements.videoGrid.children.length === 0) { elements.videoGrid.innerHTML = `
movie

No content available for this category

`; } } catch (error) { console.error('Error rendering category view:', error); showToast('Connection failed: ' + error.message, 'error'); elements.videoGrid.innerHTML = `
error

Failed to load content. Please try again.

`; } showLoading(false); } /** * Fetch movies for a specific section configuration * @param {Object} config - Section configuration * @returns {Array} Array of movies */ async function fetchSectionMovies(config) { try { // Handle history section (Continue Watching) if (config.type === 'history') { if (window.historyService) { const history = window.historyService.getHistory(); return history.slice(0, config.limit).map(m => ({ id: m.slug || m.id, title: m.title, thumbnail: m.thumbnail || m.poster_url, slug: m.slug, year: m.year, quality: m.quality || 'HD', view_progress: m.view_progress || 0 // Ensure progress })); } return []; } // Build Base API request parameters const baseParams = { category: config.category || null, limit: config.limit || 40, sort: config.sort || 'year' }; if (config.country) baseParams.country = config.country; if (config.genre) baseParams.genre = config.genre; // Strategy: Aggressive Fetching (Pages 1-8) // Some categories with specific sorts (like year) might have broken pagination or limited data. // We fetch many pages to maximize chance of filling the grid. const fetchPages = async (params) => { const promises = [1, 2, 3, 4, 5, 6, 7, 8].map(page => api.getRophimCatalog({ ...params, page }) .catch(e => ({ movies: [] })) ); const res = await Promise.all(promises); return res.flatMap(r => r.movies || []); }; let rawMovies = await fetchPages(baseParams); // Fallback Strategy: If specific sort yielded too few results (< 20), // try fetching with default sort ('modified') to fill the grid. if (rawMovies.length < 20 && config.sort && config.sort !== 'modified') { const fallbackMovies = await fetchPages({ ...baseParams, sort: 'modified' }); rawMovies = [...rawMovies, ...fallbackMovies]; } // Deduplicate and Format const allMovies = []; const seenIds = new Set(); for (const m of rawMovies) { if (!m) continue; const id = m.slug || m.id; if (!seenIds.has(id)) { seenIds.add(id); allMovies.push({ id: m.id || m.slug, title: m.title, thumbnail: m.thumbnail, poster_url: m.poster_url || m.thumbnail, backdrop: m.backdrop || m.poster_url || m.thumbnail, slug: m.slug, year: m.year, quality: m.quality || 'HD', rating: m.rating, category: m.category }); } } // Return up to limit (ensure we don't return too many if we over-fetched) // But also return enough to fill 6 rows if possible! const limit = Math.max(config.limit || 40, 48); return allMovies.slice(0, limit); } catch (error) { console.error(`Error fetching section "${config.title}":`, error); return []; } } // Initialize app when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } /** * Get high-fidelity demo content for Netflix 2025 layout */ /** * Get high-fidelity demo content for Netflix 2025 layout */ /** * Get high-fidelity demo content for Netflix 2025 layout */ /** * Get high-fidelity demo content for Netflix 2025 layout */ function getDemoContent() { const SAMPLE_MP4 = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'; // Unsplash Thematic Placeholders for Offline Mode const IMAGES = { VENOM: 'https://image.tmdb.org/t/p/w500/aosm8NMQ3UyoBVpSxyimorCQykC.jpg', // TMDB Verified SQUID: 'https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=800&auto=format&fit=crop', // Red/Triangles ARCANE: 'https://images.unsplash.com/photo-1542751371-adc38448a05e?w=800&auto=format&fit=crop', // Neon/Cyberpunk PENGUIN: 'https://images.unsplash.com/photo-1478720568477-152d9b164e63?w=800&auto=format&fit=crop', // Rainy City GLADIATOR: 'https://images.unsplash.com/photo-1565060416-522204c35613?w=800&auto=format&fit=crop', // Colosseum MOANA: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=800&auto=format&fit=crop', // Ocean WICKED: 'https://images.unsplash.com/photo-1518709268805-4e9042af9f23?w=800&auto=format&fit=crop', // Green/Magic DBZ: 'https://images.unsplash.com/photo-1578632767115-351597cf2477?w=800&auto=format&fit=crop' // Anime/Fire }; return [ { id: 'd1', title: 'Venom: The Last Dance', thumbnail: IMAGES.VENOM, backdrop: 'https://image.tmdb.org/t/p/original/3V4kLQg0kSqPLctI5ziYWabAZYF.jpg', preview_url: SAMPLE_MP4, duration: 7200, resolution: '4K', category: 'action', year: 2024, matchScore: 98, director: 'Kelly Marcel', country: 'USA', cast: ['Tom Hardy', 'Chiwetel Ejiofor', 'Juno Temple'], description: 'Eddie and Venom are on the run. Hunted by both of their worlds and with the net closing in, the duo are forced into a devastating decision.', episodes: [] }, { id: 'd2', title: 'Squid Game Season 2', thumbnail: IMAGES.SQUID, backdrop: IMAGES.SQUID, preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', duration: 3600, resolution: 'HD', category: 'series', year: 2024, matchScore: 99, director: 'Hwang Dong-hyuk', country: 'Korea', cast: ['Lee Jung-jae', 'Lee Byung-hun', 'Wi Ha-jun'], description: 'Gi-hun returns to the death games after three years with a new resolution: to find the people behind and to put an end to the sport.', episodes: [ { number: 1, title: 'Red Light, Green Light', url: SAMPLE_MP4 }, { number: 2, title: 'The Man with the Umbrella', url: SAMPLE_MP4 }, { number: 3, title: 'Stick to the Team', url: SAMPLE_MP4 } ] }, { id: 'd3', title: 'Arcane Season 2', thumbnail: IMAGES.ARCANE, backdrop: IMAGES.ARCANE, // Use high-res version normally preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4', duration: 2400, resolution: '4K', category: 'anime', year: 2024, matchScore: 97, director: 'Christian Linke', country: 'USA, France', cast: ['Hailee Steinfeld', 'Ella Purnell', 'Katie Leung'], description: 'As conflict between Piltover and Zaun reaches a boiling point, Jinx and Vi must decide what kind of future they are fighting for.', episodes: [ { number: 1, title: 'Heavy Is the Crown', url: SAMPLE_MP4 }, { number: 2, title: 'Watch It All Burn', url: SAMPLE_MP4 }, { number: 3, title: 'Finally Got It Right', url: SAMPLE_MP4 } ] }, { id: 'd4', title: 'The Penguin', thumbnail: IMAGES.PENGUIN, backdrop: IMAGES.PENGUIN, preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4', duration: 3600, resolution: 'HD', category: 'series', year: 2024, matchScore: 95, director: 'Craig Zobel', country: 'USA', cast: ['Colin Farrell', 'Cristin Milioti', 'Rhenzy Feliz'], description: 'Following the events of The Batman, Oz Cobb makes a play for power in the underworld of Gotham City.', episodes: [] }, { id: 'd5', title: 'Gladiator II', thumbnail: IMAGES.GLADIATOR, backdrop: IMAGES.GLADIATOR, preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4', duration: 8400, resolution: '4K', category: 'action', year: 2024, matchScore: 96, director: 'Ridley Scott', country: 'USA, UK', cast: ['Paul Mescal', 'Pedro Pascal', 'Denzel Washington'], description: 'Years after witnessing the death of the revered hero Maximus at the hands of his uncle, Lucius is forced to enter the Colosseum.', episodes: [] }, { id: 'd6', title: 'Moana 2', thumbnail: IMAGES.MOANA, backdrop: IMAGES.MOANA, preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4', duration: 6000, resolution: 'HD', category: 'theater', year: 2024, matchScore: 94, director: 'David G. Derrick Jr.', country: 'USA', cast: ['Auliʻi Cravalho', 'Dwayne Johnson', 'Alan Tudyk'], description: 'After receiving an unexpected call from her wayfinding ancestors, Moana must journey to the far seas of Oceania.', episodes: [] }, { id: 'd7', title: 'Wicked', thumbnail: IMAGES.WICKED, backdrop: IMAGES.WICKED, preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4', duration: 9000, resolution: '4K', category: 'theater', year: 2024, matchScore: 93, director: 'Jon M. Chu', country: 'USA', cast: ['Cynthia Erivo', 'Ariana Grande', 'Jeff Goldblum'], description: 'Elphaba, a misunderstood young woman with green skin, and Glinda, a popular blonde, forge an unlikely friendship.', episodes: [] }, { id: 'd8', title: 'Dragon Ball Daima', thumbnail: IMAGES.DBZ, backdrop: IMAGES.DBZ, preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4', duration: 1440, resolution: 'HD', category: 'anime', year: 2024, matchScore: 98, director: 'Yoshitaka Yashima', country: 'Japan', cast: ['Masako Nozawa', 'Ryō Horikawa'], description: 'Goku and his friends are turned small due to a conspiracy. To fix things, they head off to a new world.', episodes: [ { number: 1, title: 'Conspiracy', url: SAMPLE_MP4 } ] } ]; } /** * Render Category Shortcuts (Horizontal Slider of Cards) */ function renderCategoryShortcuts() { const shortcuts = [ { title: 'Phim Hot', sub: '(Movies)', tag: 'Phim Hot' }, { title: 'Phim Bộ Mới', sub: '(Series)', tag: 'Phim Bộ Mới' }, { title: 'Hoạt Hình & Anime', sub: '(Animation)', tag: 'Hoạt Hình' }, { title: 'Phim Việt Nam', sub: '(Local)', tag: 'Phim Việt Nam' } ]; const section = document.createElement('section'); section.className = 'category-shortcuts-section scrollbar-hide'; // Style handled in CSS const track = document.createElement('div'); track.className = 'category-shortcuts-track'; shortcuts.forEach(item => { const card = document.createElement('div'); card.className = 'shortcut-card'; card.innerHTML = `

${item.title}

${item.sub}
`; card.addEventListener('click', () => { // Scroll to section logic const titles = Array.from(document.querySelectorAll('.section-title-apple, .section-banner__title')); const target = titles.find(t => t.textContent.includes(item.tag)); if (target) { target.closest('section')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } else { console.warn("Section not found:", item.tag); } }); track.appendChild(card); }); section.appendChild(track); return section; } /** * Render Profile View - Mobile first profile screen */ function renderProfileView() { // Show standard header and hero section if (elements.mainHeader) elements.mainHeader.style.display = ''; const heroContainer = document.getElementById('heroContainer'); if (heroContainer) { heroContainer.style.display = ''; renderHero(); } // Update bottom nav active state (profile is not in nav, so none will be active) setMobileNavActive('profile'); // Clear content elements.videoGrid.innerHTML = ''; elements.videoGrid.className = 'profile-view pb-24 bg-background-light dark:bg-background-dark min-h-screen'; // HTML Structure based on user example const profileHTML = `

Profile

edit

Isabella Hall

42

Movies

128h

Streamed

15

Reviews

checklist
My List
chevron_right
settings
App Settings
chevron_right
person
Account
chevron_right
help
Help
chevron_right

Version 4.12.0

`; elements.videoGrid.innerHTML = profileHTML; // Inject history if (window.historyService) { const historyItems = window.historyService.getHistory().slice(0, 10); if (historyItems.length > 0) { const historyContainer = document.getElementById('profileHistoryContainer'); // Re-use createSliderSection. Note: it has padding baked in from previous task. // We might want to ensure it looks good here. const slider = createSliderSection('Continue Watching', historyItems, 'landscape'); historyContainer.appendChild(slider); } } } /** * Render Home View Wrapper */ async function renderHome() { if (elements.mainHeader) elements.mainHeader.style.display = ''; // Show hero section const heroContainer = document.getElementById('heroContainer'); if (heroContainer) heroContainer.style.display = ''; // Update bottom nav setMobileNavActive('home'); // Hide footer on mobile if (window.innerWidth < 768) { document.querySelectorAll('footer').forEach(f => f.style.display = 'none'); const searchModal = document.getElementById('searchModal'); if (searchModal) searchModal.classList.remove('active'); } else { document.querySelectorAll('footer').forEach(f => f.style.display = ''); } await renderCategoryView('home'); } /** * Render Mobile Search View */ async function renderMobileSearch() { // Show standard header and hero section if (elements.mainHeader) elements.mainHeader.style.display = ''; const heroContainer = document.getElementById('heroContainer'); if (heroContainer) { heroContainer.style.display = ''; renderHero(); // Ensure hero is populated } // Hide all footers on mobile document.querySelectorAll('footer').forEach(f => f.style.display = 'none'); // Explicitly hide search modal/popup if it somehow triggered const searchModal = document.getElementById('searchModal'); if (searchModal) searchModal.classList.remove('active'); // Update bottom nav setMobileNavActive('search'); // Clear content - leave room for fixed bottom nav (80px = nav height + safe area) elements.videoGrid.innerHTML = ''; elements.videoGrid.className = 'mobile-search-view bg-background-light dark:bg-background-dark'; // HTML Structure based on user example const searchHTML = `
search
mic

Top Searches

Recommended for You

`; elements.videoGrid.innerHTML = searchHTML; // Wire up mobile search input with proper API search const mobileInput = document.getElementById('mobileSearchInput'); const resultsContainer = document.getElementById('mobileSearchResults'); let searchTimeout = null; if (mobileInput && resultsContainer) { mobileInput.addEventListener('input', (e) => { clearTimeout(searchTimeout); const query = e.target.value.trim(); searchTimeout = setTimeout(async () => { if (query.length < 2) { // Show default content (top searches, recommended) return; } // Show loading resultsContainer.innerHTML = '
'; try { const response = await api.searchRophim(query); if (response && response.movies && response.movies.length > 0) { resultsContainer.innerHTML = `

Results for "${query}"

`; const grid = resultsContainer.querySelector('.grid'); response.movies.forEach(movie => { const card = document.createElement('div'); card.className = 'relative group aspect-[2/3] overflow-hidden rounded-lg cursor-pointer'; card.innerHTML = `

${movie.title}

`; card.addEventListener('click', () => handleVideoPlay(movie)); grid.appendChild(card); }); } else { resultsContainer.innerHTML = `
search_off

No results for "${query}"

`; } } catch (error) { console.error('Mobile search failed:', error); resultsContainer.innerHTML = '
Search failed. Try again.
'; } }, 300); }); // Focus input automatically mobileInput.focus(); } // Cancel button clears search and restores default content const cancelBtn = document.getElementById('mobileSearchCancel'); if (cancelBtn) { cancelBtn.addEventListener('click', () => { const input = document.getElementById('mobileSearchInput'); if (input) { input.value = ''; input.focus(); } // Re-render the mobile search view to restore default content renderMobileSearch(); }); } // Populate Top Searches (Trending) try { const trending = await api.getRophimCatalog({ category: 'trending', limit: 5 }); if (trending && trending.movies) { const container = document.getElementById('topSearchesList'); trending.movies.forEach(movie => { const el = document.createElement('div'); el.className = 'group flex items-center gap-3 px-4 py-2 hover:bg-gray-100 dark:hover:bg-white/5 cursor-pointer transition-colors'; el.innerHTML = `

${movie.title}

${movie.year || '2024'}

play_circle
`; el.addEventListener('click', () => handleVideoPlay(movie)); container.appendChild(el); }); } // Populate Recommended const recommended = await api.getRophimCatalog({ category: 'phim-le', limit: 9 }); if (recommended && recommended.movies) { const grid = document.getElementById('recommendedGrid'); recommended.movies.forEach(movie => { const card = document.createElement('div'); card.className = 'relative group aspect-[2/3] overflow-hidden rounded-lg cursor-pointer'; card.innerHTML = `
`; card.addEventListener('click', () => handleVideoPlay(movie)); grid.appendChild(card); }); } } catch (e) { console.error('Failed to load mobile search content', e); } // Set up genre filter chip click handlers const filterChips = document.querySelectorAll('.search-chip'); filterChips.forEach(chip => { chip.addEventListener('click', async () => { const genre = chip.dataset.genre; if (!genre) return; // Update active chip styling filterChips.forEach(c => { c.classList.remove('active', 'bg-white', 'text-black'); c.classList.add('bg-gray-200', 'dark:bg-surface-dark'); const p = c.querySelector('p'); if (p) { p.classList.remove('font-bold'); p.classList.add('font-medium', 'text-slate-700', 'dark:text-gray-300'); } }); chip.classList.add('active', 'bg-white', 'text-black'); chip.classList.remove('bg-gray-200', 'dark:bg-surface-dark'); const chipP = chip.querySelector('p'); if (chipP) { chipP.classList.add('font-bold'); chipP.classList.remove('font-medium', 'text-slate-700', 'dark:text-gray-300'); } // Fetch and display genre content const resultsContainer = document.getElementById('mobileSearchResults'); if (resultsContainer) { resultsContainer.innerHTML = '
'; try { const response = await api.getRophimCatalog({ category: genre, limit: 12 }); if (response && response.movies && response.movies.length > 0) { const chipName = chip.querySelector('p')?.textContent || genre; resultsContainer.innerHTML = `

${chipName}

`; const grid = resultsContainer.querySelector('.grid'); response.movies.forEach(movie => { const card = document.createElement('div'); card.className = 'relative group aspect-[2/3] overflow-hidden rounded-lg cursor-pointer'; card.innerHTML = `
`; card.addEventListener('click', () => handleVideoPlay(movie)); grid.appendChild(card); }); } else { resultsContainer.innerHTML = '

No results found

'; } } catch (e) { console.error('Genre filter error:', e); resultsContainer.innerHTML = '

Failed to load content

'; } } }); }); } /** * Render Mobile My List View - Netflix-style grid layout */ async function renderMobileMyList() { // Show standard header and hero if (elements.mainHeader) elements.mainHeader.style.display = ''; const heroContainer = document.getElementById('heroContainer'); if (heroContainer) { heroContainer.style.display = ''; renderHero(); } // Hide all footers on mobile document.querySelectorAll('footer').forEach(f => f.style.display = 'none'); // Explicitly hide search modal/popup const searchModal = document.getElementById('searchModal'); if (searchModal) searchModal.classList.remove('active'); // Update nav active state setMobileNavActive('mylist'); // Get saved items const items = window.historyService ? window.historyService.getFavorites() : []; elements.videoGrid.innerHTML = ''; elements.videoGrid.className = 'mobile-mylist-view min-h-screen bg-background-dark pb-24'; const mylistHTML = `

My List

`; elements.videoGrid.innerHTML = mylistHTML; // Populate grid with saved items or fallback content const grid = document.getElementById('mylistGrid'); if (items.length > 0) { items.forEach(movie => { const card = document.createElement('div'); card.className = 'group relative flex flex-col gap-2 cursor-pointer'; card.innerHTML = `
`; card.addEventListener('click', () => handleVideoPlay(movie)); grid.appendChild(card); }); } else { // Load trending as placeholder try { const trending = await api.getRophimCatalog({ category: 'trending', limit: 12 }); if (trending && trending.movies) { trending.movies.forEach((movie, index) => { const card = document.createElement('div'); card.className = 'group relative flex flex-col gap-2 cursor-pointer'; card.innerHTML = `
${index === 0 ? '
New
' : ''}
`; card.addEventListener('click', () => handleVideoPlay(movie)); grid.appendChild(card); }); } } catch (e) { console.error('Failed to load my list content', e); } } // Set up My List filter chip click handlers const mylistChips = document.querySelectorAll('.mylist-chip'); mylistChips.forEach(chip => { chip.addEventListener('click', async () => { const filter = chip.dataset.filter; const category = chip.dataset.category; if (!filter || !category) return; // Update active chip styling mylistChips.forEach(c => { c.classList.remove('active', 'bg-white'); c.classList.add('bg-surface-dark'); const p = c.querySelector('p'); if (p) { p.classList.remove('font-bold', 'text-black'); p.classList.add('font-medium', 'text-gray-200'); } }); chip.classList.add('active', 'bg-white'); chip.classList.remove('bg-surface-dark'); const chipP = chip.querySelector('p'); if (chipP) { chipP.classList.add('font-bold', 'text-black'); chipP.classList.remove('font-medium', 'text-gray-200'); } // Fetch and display filtered content const grid = document.getElementById('mylistGrid'); if (grid) { grid.innerHTML = '
'; try { const response = await api.getRophimCatalog({ category: category, limit: 12 }); grid.innerHTML = ''; if (response && response.movies && response.movies.length > 0) { response.movies.forEach((movie, index) => { const card = document.createElement('div'); card.className = 'group relative flex flex-col gap-2 cursor-pointer'; card.innerHTML = `
${index === 0 ? '
New
' : ''}
`; card.addEventListener('click', () => handleVideoPlay(movie)); grid.appendChild(card); }); } else { grid.innerHTML = '

No content found

'; } } catch (e) { console.error('Filter error:', e); grid.innerHTML = '

Failed to load content

'; } } }); }); }