3219 lines
134 KiB
JavaScript
3219 lines
134 KiB
JavaScript
/**
|
||
* 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 = `
|
||
<h2 class="section-title-apple">${title}</h2>
|
||
<div class="slider-container">
|
||
<button class="slider-btn slider-btn--left" aria-label="Previous">
|
||
<svg viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
|
||
</button>
|
||
<div class="slider-track scrollbar-hide top10-track">
|
||
<!-- Ranked cards will be injected here -->
|
||
</div>
|
||
<button class="slider-btn slider-btn--right" aria-label="Next">
|
||
<svg viewBox="0 0 24 24"><path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z"/></svg>
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<span class="rank-number">${rank}</span>
|
||
<div class="poster-card" data-id="${video.id}">
|
||
<img src="${video.thumbnail}" alt="${video.title}" loading="lazy">
|
||
<span class="poster-badge">${video.resolution || 'HD'}</span>
|
||
</div>
|
||
<div class="ranked-info">
|
||
<div class="ranked-title">${video.title}</div>
|
||
<div class="ranked-meta">${video.year || ''} • ${video.country || ''}</div>
|
||
</div>
|
||
`;
|
||
|
||
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}
|
||
<span class="material-symbols-outlined text-sm opacity-0 group-hover:opacity-100 transition-opacity text-primary">arrow_forward_ios</span>
|
||
`;
|
||
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 = '<span class="material-symbols-outlined text-white text-3xl">chevron_left</span>';
|
||
|
||
// 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 = '<span class="material-symbols-outlined text-white text-3xl">chevron_right</span>';
|
||
|
||
// 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 = `
|
||
<div class="relative ${aspectClass} rounded-md overflow-hidden bg-surface-dark shadow-lg transition-all duration-300 group-hover:shadow-2xl ring-0 group-hover:ring-2 group-hover:ring-white/20">
|
||
<div class="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-110" style="background-image: url('${proxiedImage}');"></div>
|
||
|
||
<!-- Gradient Overlay (Only visible on hover) -->
|
||
<div class="absolute inset-0 bg-gradient-to-t from-black via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||
|
||
<!-- Badges Container -->
|
||
<div class="absolute top-2 left-2 flex flex-col gap-1 z-20">
|
||
${!showRank && year === new Date().getFullYear().toString() ? `<span class="bg-primary text-white text-[9px] font-bold px-1.5 py-0.5 rounded shadow">NEW</span>` : ''}
|
||
${video.quality ? `<span class="bg-black/60 backdrop-blur-md text-white text-[9px] font-bold px-1.5 py-0.5 rounded border border-white/10 uppercase">${video.quality.replace('FHD', 'HD')}</span>` : ''}
|
||
${video.current_episode ? `<span class="bg-black/60 backdrop-blur-md text-white text-[9px] font-bold px-1.5 py-0.5 rounded border border-white/10">EP ${video.current_episode}</span>` : ''}
|
||
</div>
|
||
|
||
<!-- Number Badge -->
|
||
${showRank ? `<span class="absolute top-0 right-0 bg-primary text-white text-4xl font-black p-2 leading-none shadow-lg z-20">${rank}</span>` : ''}
|
||
|
||
<!-- Hover Content -->
|
||
<div class="absolute inset-0 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-3 pointer-events-none">
|
||
|
||
<!-- Action Buttons -->
|
||
<div class="flex items-center justify-between mb-3 pointer-events-auto">
|
||
<div class="flex gap-2">
|
||
<button class="bg-white text-black h-8 w-8 rounded-full flex items-center justify-center hover:bg-gray-200 transition-transform hover:scale-110 btn-play" title="Play">
|
||
<span class="material-symbols-outlined text-[20px] fill-current" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
|
||
</button>
|
||
<button class="bg-zinc-800/60 backdrop-blur-md border border-gray-400 text-white h-8 w-8 rounded-full flex items-center justify-center hover:border-white hover:bg-zinc-700 transition-transform hover:scale-110 btn-add-list" data-slug="${slug}" title="Add to List">
|
||
<span class="material-symbols-outlined text-[18px]">add</span>
|
||
</button>
|
||
</div>
|
||
<button class="bg-zinc-800/60 backdrop-blur-md border border-gray-400 text-white h-8 w-8 rounded-full flex items-center justify-center hover:border-white hover:bg-zinc-700 transition-transform hover:scale-110 btn-info" data-slug="${slug}" title="More Info">
|
||
<span class="material-symbols-outlined text-[18px]">info</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Metadata -->
|
||
<div class="space-y-1">
|
||
<div class="flex items-center gap-2 text-[10px] font-semibold">
|
||
<span class="text-green-400">${matchScore}% Match</span>
|
||
<span class="border border-gray-400 px-1 rounded text-gray-200">${quality}</span>
|
||
<span class="text-gray-300">${year}</span>
|
||
</div>
|
||
|
||
<!-- Ratings & Tags -->
|
||
<div class="flex items-center gap-3 text-[10px] font-bold">
|
||
<div class="flex items-center gap-1 text-yellow-500">
|
||
<span class="bg-[#FA320A] text-white px-1 rounded flex items-center gap-0.5 h-3.5">
|
||
<span class="material-symbols-outlined text-[10px]">local_pizza</span> ${rtScore}%
|
||
</span>
|
||
</div>
|
||
${video.genres && video.genres.length > 0 ? `<span class="text-white/70 font-normal truncate max-w-[100px]">${video.genres[0]}</span>` : ''}
|
||
</div>
|
||
|
||
<h3 class="text-sm font-bold text-white leading-tight line-clamp-2 drop-shadow-md mt-1">
|
||
${title}
|
||
</h3>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 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 = `
|
||
<div class="relative aspect-video rounded-md overflow-hidden bg-surface-dark card-hover">
|
||
<div class="absolute inset-0 bg-cover bg-center" style="background-image: url('${poster}');"></div>
|
||
<div class="absolute inset-0 bg-black/30 group-hover/card:bg-black/10 transition-colors flex items-center justify-center opacity-0 group-hover/card:opacity-100">
|
||
<span class="material-symbols-outlined text-5xl bg-black/50 rounded-full p-2 border-2 border-white">play_arrow</span>
|
||
</div>
|
||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-700">
|
||
<div class="h-full bg-primary" style="width: ${progress}%;"></div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-2 flex justify-between items-center px-1">
|
||
<span class="text-sm font-semibold text-gray-200">${title}</span>
|
||
${episode ? `<span class="text-xs text-gray-400">${episode}</span>` : ''}
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<button class="view-tab ${tab === 'history' ? 'active' : ''}" data-tab="history">Watch History</button>
|
||
<button class="view-tab ${tab === 'mylist' ? 'active' : ''}" data-tab="mylist">My List</button>
|
||
`;
|
||
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 = `
|
||
<div class="section-banner__bg" style="${bgStyle}"></div>
|
||
<div class="section-banner__overlay"></div>
|
||
<div class="section-banner__content">
|
||
<h2 class="section-banner__title">${title}</h2>
|
||
<span class="section-banner__subtitle">Explore Collection <span style="font-size: 1.2em">›</span></span>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<button class="slider-btn slider-btn--left" aria-label="Previous">
|
||
<svg viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
|
||
</button>
|
||
<div class="slider-track scrollbar-hide">
|
||
<!-- Cards injected here -->
|
||
</div>
|
||
<button class="slider-btn slider-btn--right" aria-label="Next">
|
||
<svg viewBox="0 0 24 24"><path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z"/></svg>
|
||
</button>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="embed-player-wrapper" style="width: 100%; height: 100%; position: relative; background: #000;">
|
||
<iframe
|
||
src="${streamUrl}"
|
||
style="width: 100%; height: 100%; border: none;"
|
||
allowfullscreen
|
||
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
|
||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||
></iframe>
|
||
</div>
|
||
`;
|
||
} 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 = `
|
||
<div class="player-skeleton" style="flex-direction: column; gap: 16px;">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" width="48" height="48" style="color: var(--color-error)">
|
||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||
</svg>
|
||
<p>Cannot load video</p>
|
||
<p style="font-size: 12px; color: var(--color-text-tertiary)">${video.title}</p>
|
||
<button class="btn btn--ghost" onclick="location.reload()">Thử lại</button>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* 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 = '<div class="flex justify-center py-8"><div class="loading-spinner"></div></div>';
|
||
|
||
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 = `<h2 class="text-xl md:text-2xl font-bold text-white/30 px-4 md:px-12">${sections[i].title}</h2>`;
|
||
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 = `
|
||
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
|
||
<span class="material-symbols-outlined text-6xl mb-4 opacity-30">movie</span>
|
||
<p>No content available for this category</p>
|
||
</div>
|
||
`;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error rendering category view:', error);
|
||
showToast('Connection failed: ' + error.message, 'error');
|
||
elements.videoGrid.innerHTML = `
|
||
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
|
||
<span class="material-symbols-outlined text-6xl mb-4 opacity-30">error</span>
|
||
<p>Failed to load content. Please try again.</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 = `
|
||
<h3>${item.title}</h3>
|
||
<span>${item.sub}</span>
|
||
<div class="shortcut-icon">›</div>
|
||
`;
|
||
|
||
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 = `
|
||
<!-- Sticky Top Bar (at offset) -->
|
||
<div class="sticky top-[60px] md:top-[80px] z-40 flex items-center bg-background-light/95 dark:bg-background-dark/95 backdrop-blur-md px-4 py-3 justify-between border-b border-gray-200 dark:border-white/10">
|
||
<button class="flex size-10 shrink-0 items-center justify-center rounded-full hover:bg-black/5 dark:hover:bg-white/10 transition-colors" onclick="renderHome()">
|
||
<span class="material-symbols-outlined text-slate-900 dark:text-white" style="font-size: 24px;">arrow_back</span>
|
||
</button>
|
||
<h2 class="text-slate-900 dark:text-white text-lg font-bold leading-tight tracking-tight flex-1 text-center">Profile</h2>
|
||
<button class="flex w-12 items-center justify-center rounded text-sm font-semibold text-primary hover:text-red-500 transition-colors">Edit</button>
|
||
</div>
|
||
|
||
<div class="flex-1 overflow-y-auto no-scrollbar">
|
||
<!-- Profile Header -->
|
||
<div class="flex flex-col items-center pt-6 pb-6 px-4">
|
||
<div class="relative group cursor-pointer">
|
||
<div class="bg-center bg-no-repeat bg-cover rounded-lg w-28 h-28 shadow-lg ring-2 ring-transparent group-hover:ring-primary transition-all duration-300"
|
||
style='background-image: url("https://wallpapers.com/images/hd/netflix-profile-pictures-1000-x-1000-qo9h82134t9nv0j0.jpg");'>
|
||
</div>
|
||
<div class="absolute -bottom-2 -right-2 bg-surface-dark p-1.5 rounded-full border border-gray-700 shadow-md">
|
||
<span class="material-symbols-outlined text-white text-xs block">edit</span>
|
||
</div>
|
||
</div>
|
||
<h3 class="mt-4 text-2xl font-bold text-slate-900 dark:text-white tracking-tight">Isabella Hall</h3>
|
||
<button class="mt-2 text-sm font-medium text-secondary-text hover:text-white transition-colors flex items-center gap-1">
|
||
Manage Profiles <span class="material-symbols-outlined text-sm">chevron_right</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Profile Stats -->
|
||
<div class="grid grid-cols-3 gap-3 px-4 mb-8">
|
||
<div class="flex flex-col gap-1 rounded-lg bg-white dark:bg-[#1E1E1E] p-3 items-center text-center shadow-sm border border-gray-100 dark:border-white/5">
|
||
<p class="text-primary text-xl font-bold leading-tight">42</p>
|
||
<p class="text-slate-500 dark:text-[#B3B3B3] text-[11px] font-medium uppercase tracking-wider">Movies</p>
|
||
</div>
|
||
<div class="flex flex-col gap-1 rounded-lg bg-white dark:bg-[#1E1E1E] p-3 items-center text-center shadow-sm border border-gray-100 dark:border-white/5">
|
||
<p class="text-primary text-xl font-bold leading-tight">128h</p>
|
||
<p class="text-slate-500 dark:text-[#B3B3B3] text-[11px] font-medium uppercase tracking-wider">Streamed</p>
|
||
</div>
|
||
<div class="flex flex-col gap-1 rounded-lg bg-white dark:bg-[#1E1E1E] p-3 items-center text-center shadow-sm border border-gray-100 dark:border-white/5">
|
||
<p class="text-primary text-xl font-bold leading-tight">15</p>
|
||
<p class="text-slate-500 dark:text-[#B3B3B3] text-[11px] font-medium uppercase tracking-wider">Reviews</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Continue Watching Container -->
|
||
<div id="profileHistoryContainer" class="mb-8"></div>
|
||
|
||
<!-- Menu List -->
|
||
<div class="flex flex-col px-4 gap-2 mb-8">
|
||
<a class="flex items-center justify-between p-4 rounded-lg bg-white dark:bg-[#1E1E1E] hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group border border-gray-100 dark:border-white/5 cursor-pointer" onclick="renderHistoryView('mylist'); return false;">
|
||
<div class="flex items-center gap-4">
|
||
<div class="p-2 rounded-full bg-slate-100 dark:bg-white/10 text-slate-600 dark:text-white group-hover:text-primary transition-colors">
|
||
<span class="material-symbols-outlined">checklist</span>
|
||
</div>
|
||
<span class="text-base font-medium text-slate-900 dark:text-white">My List</span>
|
||
</div>
|
||
<span class="material-symbols-outlined text-secondary-text text-xl">chevron_right</span>
|
||
</a>
|
||
<a class="flex items-center justify-between p-4 rounded-lg bg-white dark:bg-[#1E1E1E] hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group border border-gray-100 dark:border-white/5 cursor-pointer">
|
||
<div class="flex items-center gap-4">
|
||
<div class="p-2 rounded-full bg-slate-100 dark:bg-white/10 text-slate-600 dark:text-white group-hover:text-primary transition-colors">
|
||
<span class="material-symbols-outlined">settings</span>
|
||
</div>
|
||
<span class="text-base font-medium text-slate-900 dark:text-white">App Settings</span>
|
||
</div>
|
||
<span class="material-symbols-outlined text-secondary-text text-xl">chevron_right</span>
|
||
</a>
|
||
<a class="flex items-center justify-between p-4 rounded-lg bg-white dark:bg-[#1E1E1E] hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group border border-gray-100 dark:border-white/5 cursor-pointer">
|
||
<div class="flex items-center gap-4">
|
||
<div class="p-2 rounded-full bg-slate-100 dark:bg-white/10 text-slate-600 dark:text-white group-hover:text-primary transition-colors">
|
||
<span class="material-symbols-outlined">person</span>
|
||
</div>
|
||
<span class="text-base font-medium text-slate-900 dark:text-white">Account</span>
|
||
</div>
|
||
<span class="material-symbols-outlined text-secondary-text text-xl">chevron_right</span>
|
||
</a>
|
||
<a class="flex items-center justify-between p-4 rounded-lg bg-white dark:bg-[#1E1E1E] hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group border border-gray-100 dark:border-white/5 cursor-pointer">
|
||
<div class="flex items-center gap-4">
|
||
<div class="p-2 rounded-full bg-slate-100 dark:bg-white/10 text-slate-600 dark:text-white group-hover:text-primary transition-colors">
|
||
<span class="material-symbols-outlined">help</span>
|
||
</div>
|
||
<span class="text-base font-medium text-slate-900 dark:text-white">Help</span>
|
||
</div>
|
||
<span class="material-symbols-outlined text-secondary-text text-xl">chevron_right</span>
|
||
</a>
|
||
</div>
|
||
|
||
<!-- Footer Actions -->
|
||
<div class="px-4 pb-8 flex flex-col items-center gap-4">
|
||
<button class="w-full py-3.5 px-4 rounded-lg bg-white dark:bg-transparent border border-gray-200 dark:border-gray-700 text-slate-900 dark:text-white font-semibold text-base hover:bg-gray-50 dark:hover:bg-white/5 hover:border-gray-300 dark:hover:border-gray-500 transition-all">
|
||
Sign Out
|
||
</button>
|
||
<p class="text-xs text-secondary-text">Version 4.12.0</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 Header (Sticky below main header) -->
|
||
<div class="shrink-0 bg-background-dark/80 backdrop-blur-md pt-4 z-50 px-4 py-2 sticky top-[60px] md:top-[80px] w-full border-b border-white/5">
|
||
<div class="flex items-center gap-3">
|
||
<div class="relative flex-1">
|
||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400 dark:text-[#cc8f92]">
|
||
<span class="material-symbols-outlined text-[20px]">search</span>
|
||
</div>
|
||
<input autofocus class="block w-full pl-10 pr-3 py-2.5 border-none rounded-lg text-sm bg-gray-100 dark:bg-[#361618] text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-[#cc8f92]/70 focus:ring-2 focus:ring-primary focus:outline-none transition-shadow" placeholder="Search for shows, movies, genres..." type="text" id="mobileSearchInput">
|
||
<div class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-slate-400 dark:text-[#cc8f92]">
|
||
<span class="material-symbols-outlined text-[20px]">mic</span>
|
||
</div>
|
||
</div>
|
||
<button class="text-sm font-medium text-slate-500 dark:text-white/80 active:text-white" id="mobileSearchCancel">Cancel</button>
|
||
</div>
|
||
<!-- Filter Chips -->
|
||
<div id="searchFilterChips" class="mt-3 flex gap-2 overflow-x-auto no-scrollbar pb-1">
|
||
<button class="search-chip active flex h-8 shrink-0 items-center justify-center rounded-full bg-white text-black px-4" data-genre="trending">
|
||
<p class="text-xs font-bold leading-normal">Top Searches</p>
|
||
</button>
|
||
<button class="search-chip flex h-8 shrink-0 items-center justify-center rounded-full bg-gray-200 dark:bg-surface-dark border border-transparent dark:border-white/10 px-4" data-genre="hanh-dong">
|
||
<p class="text-slate-700 dark:text-gray-300 text-xs font-medium leading-normal">Action</p>
|
||
</button>
|
||
<button class="search-chip flex h-8 shrink-0 items-center justify-center rounded-full bg-gray-200 dark:bg-surface-dark border border-transparent dark:border-white/10 px-4" data-genre="hoat-hinh">
|
||
<p class="text-slate-700 dark:text-gray-300 text-xs font-medium leading-normal">Anime</p>
|
||
</button>
|
||
<button class="search-chip flex h-8 shrink-0 items-center justify-center rounded-full bg-gray-200 dark:bg-surface-dark border border-transparent dark:border-white/10 px-4" data-genre="vien-tuong">
|
||
<p class="text-slate-700 dark:text-gray-300 text-xs font-medium leading-normal">Sci-Fi</p>
|
||
</button>
|
||
<button class="search-chip flex h-8 shrink-0 items-center justify-center rounded-full bg-gray-200 dark:bg-surface-dark border border-transparent dark:border-white/10 px-4" data-genre="hai-huoc">
|
||
<p class="text-slate-700 dark:text-gray-300 text-xs font-medium leading-normal">Comedy</p>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Results/Content Area with bottom padding for nav bar -->
|
||
<div id="mobileSearchResults" class="flex-1 overflow-y-auto no-scrollbar pt-4 pb-24">
|
||
<div class="mb-3">
|
||
<h2 class="text-slate-900 dark:text-white text-lg font-bold px-4">Top Searches</h2>
|
||
</div>
|
||
<div id="topSearchesList" class="flex flex-col gap-1"></div>
|
||
|
||
<div class="pt-8 px-4">
|
||
<h2 class="text-slate-900 dark:text-white text-lg font-bold mb-4">Recommended for You</h2>
|
||
<div id="recommendedGrid" class="grid grid-cols-3 gap-3"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 = '<div class="flex justify-center py-12"><div class="loading-spinner"></div></div>';
|
||
|
||
try {
|
||
const response = await api.searchRophim(query);
|
||
|
||
if (response && response.movies && response.movies.length > 0) {
|
||
resultsContainer.innerHTML = `
|
||
<h2 class="text-white text-sm font-bold px-4 mb-3">Results for "${query}"</h2>
|
||
<div class="grid grid-cols-3 gap-3 px-4"></div>
|
||
`;
|
||
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 = `
|
||
<div class="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-110" style='background-image: url("${movie.thumbnail}");'></div>
|
||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<div class="absolute bottom-0 left-0 right-0 p-2">
|
||
<p class="text-white text-[10px] font-bold line-clamp-1">${movie.title}</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
card.addEventListener('click', () => handleVideoPlay(movie));
|
||
grid.appendChild(card);
|
||
});
|
||
} else {
|
||
resultsContainer.innerHTML = `
|
||
<div class="text-center py-12">
|
||
<span class="material-symbols-outlined text-4xl text-white/30 mb-2">search_off</span>
|
||
<p class="text-white/50">No results for "${query}"</p>
|
||
</div>
|
||
`;
|
||
}
|
||
} catch (error) {
|
||
console.error('Mobile search failed:', error);
|
||
resultsContainer.innerHTML = '<div class="text-center py-12 text-white/50">Search failed. Try again.</div>';
|
||
}
|
||
}, 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 = `
|
||
<div class="shrink-0 relative">
|
||
<div class="bg-center bg-cover rounded-lg h-16 w-28 shadow-sm" style='background-image: url("${movie.thumbnail}");'></div>
|
||
</div>
|
||
<div class="flex flex-col justify-center flex-1 min-w-0">
|
||
<p class="text-slate-900 dark:text-white text-sm font-semibold leading-normal truncate group-hover:text-primary transition-colors">${movie.title}</p>
|
||
<p class="text-slate-500 dark:text-[#cc8f92] text-xs font-normal leading-normal truncate">${movie.year || '2024'}</p>
|
||
</div>
|
||
<div class="shrink-0">
|
||
<span class="material-symbols-outlined text-slate-400 dark:text-white text-[28px] group-hover:text-primary">play_circle</span>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<div class="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-110" style='background-image: url("${movie.thumbnail}");'></div>
|
||
`;
|
||
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 = '<div class="flex justify-center py-12"><div class="loading-spinner"></div></div>';
|
||
|
||
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 = `
|
||
<h2 class="text-white text-lg font-bold px-4 mb-4">${chipName}</h2>
|
||
<div class="grid grid-cols-3 gap-3 px-4"></div>
|
||
`;
|
||
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 = `
|
||
<div class="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-110" style='background-image: url("${movie.thumbnail}");'></div>
|
||
`;
|
||
card.addEventListener('click', () => handleVideoPlay(movie));
|
||
grid.appendChild(card);
|
||
});
|
||
} else {
|
||
resultsContainer.innerHTML = '<p class="text-center text-gray-400 py-12">No results found</p>';
|
||
}
|
||
} catch (e) {
|
||
console.error('Genre filter error:', e);
|
||
resultsContainer.innerHTML = '<p class="text-center text-gray-400 py-12">Failed to load content</p>';
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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 = `
|
||
<!-- Sticky Header (Using sticky at an offset to allow scrolling past hero and main header) -->
|
||
<header class="sticky top-[60px] md:top-[80px] left-0 right-0 z-[100] flex flex-col bg-background-dark/90 backdrop-blur-md pt-4 border-b border-white/5">
|
||
<div class="flex items-center justify-between px-4 pb-2">
|
||
<h1 class="text-2xl font-bold tracking-tight text-white">My List</h1>
|
||
<button class="flex h-10 w-10 items-center justify-center rounded-full text-white hover:bg-white/10 transition-colors">
|
||
<span class="material-symbols-outlined text-[24px]">edit</span>
|
||
</button>
|
||
</div>
|
||
<!-- Filter Chips -->
|
||
<div id="mylistFilterChips" class="flex w-full gap-3 overflow-x-auto px-4 pb-4 pt-2 no-scrollbar">
|
||
<button class="mylist-chip active flex h-8 shrink-0 items-center justify-center rounded-full bg-white px-4 shadow-lg shadow-white/10" data-filter="all" data-category="trending">
|
||
<p class="text-xs font-bold text-black">All</p>
|
||
</button>
|
||
<button class="mylist-chip flex h-8 shrink-0 items-center justify-center rounded-full bg-surface-dark border border-white/20 px-4 hover:bg-white/10" data-filter="movies" data-category="phim-le">
|
||
<p class="text-xs font-medium text-gray-200">Movies</p>
|
||
</button>
|
||
<button class="mylist-chip flex h-8 shrink-0 items-center justify-center rounded-full bg-surface-dark border border-white/20 px-4 hover:bg-white/10" data-filter="tvshows" data-category="phim-bo">
|
||
<p class="text-xs font-medium text-gray-200">TV Shows</p>
|
||
</button>
|
||
<button class="mylist-chip flex h-8 shrink-0 items-center justify-center rounded-full bg-surface-dark border border-white/20 px-4 hover:bg-white/10" data-filter="anime" data-category="hoat-hinh">
|
||
<p class="text-xs font-medium text-gray-200">Anime</p>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Grid Container -->
|
||
<main class="px-4 pt-4 pb-24">
|
||
<div id="mylistGrid" class="grid grid-cols-3 gap-3"></div>
|
||
</main>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="relative w-full overflow-hidden rounded-md bg-surface-dark shadow-md aspect-[2/3]">
|
||
<div class="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-105"
|
||
style='background-image: url("${movie.thumbnail || movie.poster_url}");'></div>
|
||
<div class="absolute inset-0 bg-black/0 transition-colors group-active:bg-black/20"></div>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<div class="relative w-full overflow-hidden rounded-md bg-surface-dark shadow-md aspect-[2/3]">
|
||
<div class="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-105"
|
||
style='background-image: url("${movie.thumbnail}");'></div>
|
||
${index === 0 ? '<div class="absolute top-0 right-0 rounded-bl-md bg-primary px-1.5 py-0.5"><span class="text-[10px] font-bold uppercase text-white tracking-wider">New</span></div>' : ''}
|
||
<div class="absolute inset-0 bg-black/0 transition-colors group-active:bg-black/20"></div>
|
||
</div>
|
||
`;
|
||
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 = '<div class="col-span-3 flex justify-center py-12"><div class="loading-spinner"></div></div>';
|
||
|
||
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 = `
|
||
<div class="relative w-full overflow-hidden rounded-md bg-surface-dark shadow-md aspect-[2/3]">
|
||
<div class="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-105"
|
||
style='background-image: url("${movie.thumbnail}");'></div>
|
||
${index === 0 ? '<div class="absolute top-0 right-0 rounded-bl-md bg-primary px-1.5 py-0.5"><span class="text-[10px] font-bold uppercase text-white tracking-wider">New</span></div>' : ''}
|
||
<div class="absolute inset-0 bg-black/0 transition-colors group-active:bg-black/20"></div>
|
||
</div>
|
||
`;
|
||
card.addEventListener('click', () => handleVideoPlay(movie));
|
||
grid.appendChild(card);
|
||
});
|
||
} else {
|
||
grid.innerHTML = '<p class="col-span-3 text-center text-gray-400 py-12">No content found</p>';
|
||
}
|
||
} catch (e) {
|
||
console.error('Filter error:', e);
|
||
grid.innerHTML = '<p class="col-span-3 text-center text-gray-400 py-12">Failed to load content</p>';
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|