/** * KV-Stream Watch Page * Handles video playback, episode navigation, and recommendations */ import { api } from './api.js'; import { showToast } from './components/Toast.js'; import { initPlayer, destroyPlayer } from './components/VideoPlayer.js'; import { hapticLight, hapticMedium, hapticSuccess } from './haptics.js'; import { KeyboardNavigation } from './keyboard-nav.js'; // Page State const state = { video: null, currentEpisode: 1, currentServer: 0, recommendations: [], isLoading: true }; // Expose state for debugging window.state = state; // DOM Elements - Resolved at runtime for robustness let elements = {}; function initElements() { elements = { // Video player videoPlayer: document.getElementById('videoPlayer'), videoPlayerContainer: document.getElementById('videoPlayerContainer'), playerLoading: document.getElementById('playerLoading'), closePlayer: document.getElementById('closePlayer'), // Hero section (Desktop) heroBg: document.getElementById('heroBg'), movieTitle: document.getElementById('movieTitleDesktop'), movieMatch: document.getElementById('movieMatchDesktop'), movieYear: document.getElementById('movieYearDesktop'), movieRating: document.getElementById('movieRatingDesktop'), movieQuality: document.getElementById('movieQualityDesktop'), movieDescription: document.getElementById('movieDescriptionDesktop'), movieTags: document.getElementById('movieTags'), // Mobile Elements movieTitleMobile: document.getElementById('movieTitleMobile'), movieMatchMobile: document.getElementById('movieMatchMobile'), movieYearMobile: document.getElementById('movieYearMobile'), movieRatingMobile: document.getElementById('movieRatingMobile'), movieDuration: document.getElementById('movieDurationDesktop'), // Added this movieDurationMobile: document.getElementById('movieDurationMobile'), movieQualityMobile: document.getElementById('movieQualityMobile'), movieDescriptionMobile: document.getElementById('movieDescriptionMobile'), // Action buttons playBtn: document.getElementById('playBtnDesktop'), addListBtn: document.getElementById('addListBtnDesktop'), addListIcon: document.getElementById('addListBtnDesktop')?.querySelector('.material-symbols-outlined'), addListText: document.getElementById('addListBtnDesktop')?.querySelector('span:last-child'), playBtnMobile: document.getElementById('playBtnMobile'), addListBtnMobile: document.getElementById('addListBtnMobile'), shareBtnMobile: document.getElementById('shareBtnMobile'), mobilePlayBtn: document.getElementById('mobilePlayBtn'), // Navigation watchHeader: document.getElementById('watchHeader'), tabNav: document.getElementById('tabNav'), watchBackBtn: document.getElementById('watchBackBtn'), // Panels episodesPanel: document.getElementById('episodesPanel'), trailersPanel: document.getElementById('trailersPanel'), detailsPanel: document.getElementById('detailsPanel'), // Content seasonSelect: document.getElementById('seasonSelect'), seasonSelectContainer: document.getElementById('seasonSelectContainer'), episodeCount: document.getElementById('episodeCount'), episodesGrid: document.getElementById('episodesGrid'), episodesLoading: document.getElementById('episodesLoading'), castCarousel: document.getElementById('castCarousel'), recommendationsContainer: document.getElementById('recommendationsContainer'), detailsList: document.getElementById('detailsList'), // Search searchModal: document.getElementById('searchModal'), searchBtn: document.getElementById('searchBtn'), searchInput: document.getElementById('searchInput'), closeSearch: document.getElementById('closeSearch') }; } /** * Initialize watch page */ async function init() { // Initialize UI elements initElements(); // Initialize TV Navigation const nav = new KeyboardNavigation(); nav.init(); // Parse URL parameters const params = new URLSearchParams(window.location.search); const videoId = params.get('id'); const videoSlug = params.get('slug'); const episode = parseInt(params.get('ep')) || 1; state.currentEpisode = episode; if (!videoId && !videoSlug) { showError('No video specified'); return; } // Resolve elements once DOM is ready initElements(); // Setup event listeners setupEventListeners(); // Load video data await loadVideoData(videoId, videoSlug); // Load recommendations await loadRecommendations(); } /** * Setup event listeners (StreamFlix Tailwind Design) */ function setupEventListeners() { // Scroll listener for header background window.addEventListener('scroll', () => { if (elements.watchHeader) { if (window.scrollY > 50) { elements.watchHeader.style.backgroundColor = 'rgba(20,20,20,0.95)'; } else { elements.watchHeader.style.backgroundColor = 'transparent'; } } }); // Back Button Logic (Robust Close) if (elements.watchBackBtn) { elements.watchBackBtn.addEventListener('click', (e) => { e.preventDefault(); const playerVisible = elements.videoPlayerContainer && (elements.videoPlayerContainer.style.display !== 'none' || !elements.videoPlayerContainer.classList.contains('hidden')); if (playerVisible) { hapticLight(); // Close player via history if possible if (window.history.state?.playerOpen) { window.history.back(); } else { closeVideoPlayer(); } } else if (document.referrer && document.referrer.includes(window.location.host)) { hapticLight(); window.history.back(); } else { window.location.href = '/index.html'; } }); } // New Dedicated Player Back Button const playerBackButton = document.getElementById('playerBackButton'); if (playerBackButton) { playerBackButton.addEventListener('click', () => { hapticLight(); if (window.history.state?.playerOpen) { window.history.back(); } else { closeVideoPlayer(); } }); } // History API for Hardware Back Button / Gestures window.addEventListener('popstate', (event) => { // If the player was open but the state changed (back button pressed) const container = elements.videoPlayerContainer || document.getElementById('videoPlayerContainer'); const isPlayerOpen = container && !container.classList.contains('hidden'); if (isPlayerOpen && !event.state?.playerOpen) { closeVideoPlayer(false); // Close without pushing state again } }); // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { // Close video player if (elements.videoPlayerContainer && !elements.videoPlayerContainer.classList.contains('hidden')) { closeVideoPlayer(); } // Close search modal if (elements.searchModal && !elements.searchModal.classList.contains('hidden')) { elements.searchModal.classList.add('hidden'); } } }); [elements.playBtn, elements.playBtnMobile, elements.mobilePlayBtn].forEach(btn => { if (btn) { btn.addEventListener('click', () => { if (btn) { hapticMedium(); } if (elements.videoPlayerContainer) { elements.videoPlayerContainer.classList.remove('hidden'); elements.videoPlayerContainer.style.display = 'block'; // Ensure visible } if (elements.videoPlayer) { elements.videoPlayer.style.display = 'block'; } playCurrentEpisode(); }); } }); // Close player button if (elements.closePlayer) { elements.closePlayer.addEventListener('click', () => { if (window.history.state?.playerOpen) { window.history.back(); } else { closeVideoPlayer(); } }); } // Search button - open search modal if (elements.searchBtn) { elements.searchBtn.addEventListener('click', () => { if (elements.searchModal) { elements.searchModal.classList.remove('hidden'); setTimeout(() => elements.searchInput?.focus(), 100); } }); } // Close search button if (elements.closeSearch) { elements.closeSearch.addEventListener('click', () => { if (elements.searchModal) { elements.searchModal.classList.add('hidden'); } }); } // Add to List button [elements.addListBtn, elements.addListBtnMobile].forEach(btn => { if (btn) { btn.addEventListener('click', () => { if (!state.video) return; const added = window.historyService?.toggleFavorite(state.video); updateAddListUI(added); hapticLight(); if (added) { showToast('Added to My List', 'success'); } else { showToast('Removed from My List', 'info'); } }); } }); // Share button if (elements.shareBtnMobile) { elements.shareBtnMobile.addEventListener('click', () => { if (navigator.share) { hapticLight(); navigator.share({ title: state.video?.title || 'StreamFlix', url: window.location.href }); } else { hapticLight(); // Fallback: Copy to clipboard navigator.clipboard.writeText(window.location.href); showToast('Link copied to clipboard', 'success'); } }); } // Tab Navigation (Tailwind design) if (elements.tabNav) { const tabs = elements.tabNav.querySelectorAll('.tab-btn'); const panels = { episodes: elements.episodesPanel, details: elements.detailsPanel }; tabs.forEach(tab => { tab.addEventListener('click', () => { hapticLight(); const targetPanel = tab.dataset.tab; // Update active tab styling tabs.forEach(t => { t.classList.remove('text-white', 'font-bold', 'border-b-4', 'border-primary'); t.classList.add('text-gray-400', 'font-medium'); }); tab.classList.remove('text-gray-400', 'font-medium'); tab.classList.add('text-white', 'font-bold', 'border-b-4', 'border-primary'); // Show/hide panels Object.entries(panels).forEach(([key, panel]) => { if (panel) { if (key === targetPanel) { panel.classList.remove('hidden'); } else { panel.classList.add('hidden'); } } }); }); }); } // Mobile Bottom Navigation Handlers const mobileNavButtons = document.querySelectorAll('#mobileBottomNav .nav-item'); mobileNavButtons.forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); const view = btn.dataset.view; if (view) { hapticLight(); // Redirect to home with view parameter window.location.href = `/index.html?view=${view}`; } }); }); } /** * Close Video Player (Robust Cleanup) * @param {boolean} shouldUpdateHistory - Whether to update history (defaults to true) */ function closeVideoPlayer(shouldUpdateHistory = true) { // Re-resolve just in case const container = elements.videoPlayerContainer || document.getElementById('videoPlayerContainer'); const player = elements.videoPlayer || document.getElementById('videoPlayer'); const loader = elements.playerLoading || document.getElementById('playerLoading'); if (container) { container.classList.add('hidden'); container.style.display = 'none'; // Forced hide } // Destroy ArtPlayer instance destroyPlayer(); if (player) { player.innerHTML = ''; player.style.display = 'none'; } if (loader) { loader.style.display = 'none'; } // 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) { // We handle this via history.back() usually, but if called directly: } } /** * Update Add to List UI buttons */ function updateAddListUI(isAdded) { const icon = isAdded ? 'check' : 'add'; const text = isAdded ? 'In List' : 'My List'; // Update Desktop if (elements.addListBtn) { const iconEl = elements.addListBtn.querySelector('.material-symbols-outlined'); const textEl = elements.addListBtn.querySelector('span:last-child'); if (iconEl) iconEl.textContent = icon; if (textEl) textEl.textContent = text; if (isAdded) elements.addListBtn.classList.add('bg-white/20'); else elements.addListBtn.classList.remove('bg-white/20'); } // Update Mobile if (elements.addListBtnMobile) { const iconEl = elements.addListBtnMobile.querySelector('.material-symbols-outlined'); const textEl = elements.addListBtnMobile.querySelector('span:last-child'); if (iconEl) iconEl.textContent = icon; if (textEl) textEl.textContent = text; if (isAdded) { elements.addListBtnMobile.classList.add('bg-white/10'); elements.addListBtnMobile.classList.remove('bg-[#2b2b2b]'); } else { elements.addListBtnMobile.classList.remove('bg-white/10'); elements.addListBtnMobile.classList.add('bg-[#2b2b2b]'); } } } /** * Load video data from API or stored state */ async function loadVideoData(videoId, videoSlug) { try { state.isLoading = true; let video = null; const slug = videoSlug || videoId; // Fetch fresh movie details from API if (slug) { try { const movieDetails = await api.getRophimMovie(slug); // API returns flat object, not nested under 'movie' if (movieDetails) { const movie = movieDetails.movie || movieDetails; // Support both structures const episodes = movieDetails.episodes || []; video = { id: movie.slug || slug, slug: movie.slug || slug, title: movie.name || movie.title || slug, original_title: movie.origin_name || movie.original_title || '', description: movie.content || movie.description || '', thumbnail: movie.poster_url || movie.thumb_url || movie.thumbnail || '', year: movie.year, rating: movie.tmdb?.vote_average || movie.rating || 'N/A', quality: movie.quality || 'HD', duration: movie.time || movie.duration || '', genres: (() => { if (Array.isArray(movie.category)) return movie.category.map(c => c.name || c); if (Array.isArray(movie.genres)) return movie.genres; if (typeof movie.genre === 'string') return movie.genre.split(',').map(g => g.trim()); return []; })(), country: movie.country?.[0]?.name || movie.country || '', country: movie.country?.[0]?.name || movie.country || '', cast: movie.actor || movie.cast || [], director: movie.director?.[0] || movie.director || '', source_url: `https://phimmoichill.network/phim/${slug}`, episodes: parseEpisodes(episodes) }; } } catch (apiError) { console.warn('API fetch failed:', apiError); } } if (!video) { throw new Error('Video data not found'); } state.video = video; // Save to watch history if (window.historyService) { window.historyService.addToHistory(video, { episode: state.currentEpisode }); } // Render video info renderVideoInfo(video); // Update Favorite Status if (window.historyService) { updateAddListUI(window.historyService.isFavorite(video.slug)); } // Video is ready, but wait for user interaction to play // await playCurrentEpisode(); // Disabled auto-play per user request } catch (error) { console.error('Failed to load video:', error); showError('Failed to load video data'); } finally { state.isLoading = false; } } /** * Parse episodes */ function parseEpisodes(episodesData) { if (!episodesData || !Array.isArray(episodesData) || episodesData.length === 0) { return []; } const server = episodesData[0]; const serverData = server?.server_data || []; return serverData.map((ep, index) => ({ number: index + 1, name: ep.name || `Episode ${index + 1}`, title: ep.filename || `Episode ${index + 1}`, slug: ep.slug || '', link_embed: ep.link_embed || '', link_m3u8: ep.link_m3u8 || '' })); } /** * Render video information (StreamFlix Tailwind Design) */ function renderVideoInfo(video) { // Hero Background Image if (elements.heroBg) { const backdrop = video.backdrop || video.poster_url || video.thumb_url || video.thumbnail || ''; if (backdrop) { elements.heroBg.style.backgroundImage = `url('${backdrop}')`; } } // Title if (elements.movieTitle) elements.movieTitle.textContent = video.title; // Meta Data if (elements.movieYear) elements.movieYear.textContent = video.year || ''; if (elements.movieDuration) { if (video.runtime_minutes) { const hours = Math.floor(video.runtime_minutes / 60); const mins = video.runtime_minutes % 60; elements.movieDuration.textContent = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; } else if (video.duration) { elements.movieDuration.textContent = video.duration; } } if (elements.movieQuality) elements.movieQuality.textContent = video.quality || 'HD'; // Rating (show as PG-13 style or numeric) if (elements.movieRating) { const rating = video.rating || video.tmdb_rating; if (rating && rating !== 'N/A') { elements.movieRating.textContent = typeof rating === 'number' ? `${rating.toFixed(1)} ★` : rating; } else { elements.movieRating.textContent = 'TV-MA'; } } // Match percentage (fake Netflix-style) if (elements.movieMatch) { const matchPercent = Math.floor(85 + Math.random() * 14); // 85-98% elements.movieMatch.textContent = `${matchPercent}% Match`; } // Description if (elements.movieDescription) { const description = video.tmdb_description || video.description || 'No description available.'; // Use innerHTML to render any HTML tags provided by the API (e.g.
,
)
elements.movieDescription.innerHTML = description;
if (elements.movieDescriptionMobile) elements.movieDescriptionMobile.innerHTML = description;
}
// Mobile Data Population
if (elements.movieTitleMobile) elements.movieTitleMobile.textContent = video.title;
if (elements.movieYearMobile) elements.movieYearMobile.textContent = video.year || '';
if (elements.movieRatingMobile) {
const rating = video.rating || video.tmdb_rating;
elements.movieRatingMobile.textContent = (rating && rating !== 'N/A') ? (typeof rating === 'number' ? rating.toFixed(1) : rating) : 'TV-MA';
}
if (elements.movieDurationMobile) elements.movieDurationMobile.textContent = elements.movieDuration ? elements.movieDuration.textContent : (video.duration || '');
if (elements.movieQualityMobile) elements.movieQualityMobile.textContent = video.quality || 'HD';
if (elements.movieMatchMobile && elements.movieMatch) elements.movieMatchMobile.textContent = elements.movieMatch.textContent;
// Genre Tags
if (elements.movieTags) {
const genres = video.genres || [];
const director = video.director;
const country = video.country;
let tagsHTML = '';
if (genres.length > 0) {
tagsHTML += `
Full Movie
Click Play to watch
${epTitle}
` : ''}This stream is currently unavailable. Please try again later or choose another source.
Error loading video: ${msg}
${person.name}
${person.character || 'Actor'}
`; }).join(''); } else { elements.castCarousel.innerHTML = displayCast.map(actor => { const searchUrl = `/?search=${encodeURIComponent(actor)}`; const initials = actor.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); return `${actor}
Actor
`; }).join(''); } } /** * Load Recommendations (StreamFlix Tailwind Design) */ /** * Load Recommendations (Expanded: Genre, Country, Year) */ async function loadRecommendations() { const container = elements.recommendationsContainer; if (!container) return; try { container.innerHTML = 'No specific recommendations found.
'; } } catch (error) { console.error('Failed to load recommendations:', error); container.innerHTML = 'Failed to load recommendations
'; } } /** * Helper to create card HTML (Smaller for Recommendations) */ function createCardHtml(v) { const poster = v.poster_url || v.thumbnail || v.thumb_url || ''; const title = v.name || v.title || 'Untitled'; const year = v.year || ''; const quality = v.quality || 'HD'; const match = v.matchScore || Math.floor(Math.random() * (99 - 85 + 1) + 85); const tmdb = v.tmdb_rating || 0; const rtScore = Math.round(tmdb * 10); const slug = v.slug || v.id || ''; // Smaller card dimensions for "More Like This" return `